feat: enhance UI styling and functionality for extension components

- Added zebra striping to extension lists for improved readability across various screen sizes.
- Updated the ExtensionStoreView to include margin adjustments for better layout.
- Enhanced InstallButton component to manage pending actions more effectively, improving user feedback during installation processes.
- Refactored McpServersPanel and SkillsPanel to apply new grid classes for consistent styling.
- Adjusted CapabilityChips and CategoryChips for better spacing and visual consistency.
- Improved the TeamProvisioningBanner to handle state changes more efficiently.
- Updated KanbanTaskCard to replace icons for better clarity in task actions.
- Enhanced teamSlice to manage provisioning runs and spawn statuses more effectively, ensuring better state handling.
This commit is contained in:
iliya 2026-03-12 15:08:26 +02:00
parent 3723eba5b4
commit 88722598ac
12 changed files with 270 additions and 114 deletions

View file

@ -194,7 +194,7 @@ export const ExtensionStoreView = (): React.JSX.Element => {
variant="outline"
size="sm"
onClick={() => setCustomMcpDialogOpen(true)}
className="whitespace-nowrap"
className="mb-1 whitespace-nowrap"
>
<Plus className="mr-1 size-3.5" />
Add Custom

View file

@ -3,6 +3,8 @@
* States: idle pending (spinner) success (checkmark, 2s) idle
*/
import { useEffect, useState } from 'react';
import { Check, Loader2, Trash2 } from 'lucide-react';
import { Button } from '@renderer/components/ui/button';
@ -38,11 +40,22 @@ export function InstallButton({
const cliStatus = useStore((s) => s.cliStatus);
const cliMissing = cliStatus !== null && !cliStatus.installed;
const isDisabled = disabled || cliMissing;
const [lastAction, setLastAction] = useState<'install' | 'uninstall' | null>(null);
useEffect(() => {
if (state === 'idle' || state === 'success') {
setLastAction(null);
}
}, [state]);
const pendingAction = lastAction ?? (isInstalled ? 'uninstall' : 'install');
if (state === 'pending') {
return (
<Button size={size} variant="outline" disabled>
<Loader2 className="h-3.5 w-3.5 animate-spin" />
<span className="ml-1.5">{isInstalled ? 'Removing...' : 'Installing...'}</span>
<span className="ml-1.5">
{pendingAction === 'uninstall' ? 'Removing...' : 'Installing...'}
</span>
</Button>
);
}
@ -64,7 +77,14 @@ export function InstallButton({
className="border-red-500/30 text-red-400 hover:bg-red-500/10"
onClick={(e) => {
e.stopPropagation();
(isInstalled ? onUninstall : onInstall)();
if (pendingAction === 'uninstall') {
setLastAction('uninstall');
onUninstall();
return;
}
setLastAction('install');
onInstall();
}}
disabled={isDisabled}
>
@ -96,6 +116,7 @@ export function InstallButton({
className="border-red-500/30 text-red-400 hover:bg-red-500/10"
onClick={(e) => {
e.stopPropagation();
setLastAction('uninstall');
onUninstall();
}}
disabled={isDisabled}
@ -109,6 +130,7 @@ export function InstallButton({
variant="default"
onClick={(e) => {
e.stopPropagation();
setLastAction('install');
onInstall();
}}
disabled={isDisabled}

View file

@ -6,8 +6,6 @@ import { useEffect, useMemo, useState } from 'react';
import { Badge } from '@renderer/components/ui/badge';
import { Button } from '@renderer/components/ui/button';
import { Checkbox } from '@renderer/components/ui/checkbox';
import { Label } from '@renderer/components/ui/label';
import {
Select,
SelectContent,
@ -87,7 +85,6 @@ export const McpServersPanel = ({
const runMcpDiagnostics = useStore((s) => s.runMcpDiagnostics);
const [mcpSort, setMcpSort] = useState<McpSortValue>('name-asc');
const [mcpInstalledOnly, setMcpInstalledOnly] = useState(false);
// Load initial browse data
useEffect(() => {
@ -155,14 +152,8 @@ export const McpServersPanel = ({
}
};
// Sort + filter
const displayServers = useMemo(() => {
let result = rawServers;
if (mcpInstalledOnly) {
result = result.filter(isServerInstalled);
}
return sortMcpServers(result, mcpSort);
}, [rawServers, mcpSort, mcpInstalledOnly, installedNames]);
// Sort displayed servers
const displayServers = useMemo(() => sortMcpServers(rawServers, mcpSort), [rawServers, mcpSort]);
// Find selected server (search in both lists to avoid losing selection during search toggle)
const selectedServer = useMemo(() => {
@ -219,7 +210,7 @@ export const McpServersPanel = ({
)}
</div>
{allDiagnostics.length > 0 ? (
<div className="max-h-[18.5rem] space-y-2 overflow-y-auto pr-1">
<div className="mcp-diagnostics-list max-h-[18.5rem] space-y-2 overflow-y-auto pr-1">
{allDiagnostics.map((diagnostic) => (
<div
key={diagnostic.name}
@ -247,7 +238,7 @@ export const McpServersPanel = ({
)}
</div>
{/* Search + Sort + Installed only row */}
{/* Search + sort row */}
<div className="flex items-center gap-3">
<div className="flex-1">
<SearchInput
@ -268,19 +259,6 @@ export const McpServersPanel = ({
))}
</SelectContent>
</Select>
<div className="flex items-center gap-2">
<Checkbox
id="mcp-installed-only"
checked={mcpInstalledOnly}
onCheckedChange={() => setMcpInstalledOnly(!mcpInstalledOnly)}
/>
<Label
htmlFor="mcp-installed-only"
className="whitespace-nowrap text-xs text-text-secondary"
>
Installed only
</Label>
</div>
</div>
{/* Warnings */}
@ -343,31 +321,23 @@ export const McpServersPanel = ({
{!isLoading && displayServers.length === 0 && (
<div className="flex flex-col items-center gap-3 rounded-sm border border-dashed border-border px-8 py-16">
<div className="flex size-10 items-center justify-center rounded-lg border border-border bg-surface-raised">
{isSearching || mcpInstalledOnly ? (
{isSearching ? (
<Search className="size-5 text-text-muted" />
) : (
<Server className="size-5 text-text-muted" />
)}
</div>
<p className="text-sm text-text-secondary">
{isSearching
? 'No servers found'
: mcpInstalledOnly
? 'No installed servers'
: 'No MCP servers available'}
{isSearching ? 'No servers found' : 'No MCP servers available'}
</p>
<p className="text-xs text-text-muted">
{isSearching
? 'Try a different search term'
: mcpInstalledOnly
? 'Install servers from the catalog to see them here'
: 'Check back later for new servers'}
{isSearching ? 'Try a different search term' : 'Check back later for new servers'}
</p>
</div>
)}
{displayServers.length > 0 && (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3">
<div className="mcp-servers-grid grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3">
{displayServers.map((server) => (
<McpServerCard
key={server.id}

View file

@ -34,7 +34,7 @@ export const CapabilityChips = ({
}, [plugins]);
return (
<div className="flex flex-wrap gap-2">
<div className="flex flex-wrap gap-1.5">
{ALL_CAPABILITIES.map((cap) => {
const count = capabilityCounts.get(cap) ?? 0;
if (count === 0) return null;
@ -46,7 +46,7 @@ export const CapabilityChips = ({
size="sm"
onClick={() => onToggle(cap)}
aria-pressed={isActive}
className={`h-8 rounded-full border px-3 text-xs font-medium transition-all ${
className={`h-7 rounded-full border px-2.5 text-[11px] font-medium transition-all ${
isActive
? 'border-purple-500/40 bg-purple-500/15 text-purple-300 shadow-sm'
: 'hover:bg-surface-raised/60 border-border bg-transparent text-text-secondary hover:border-border-emphasis hover:text-text'
@ -54,7 +54,7 @@ export const CapabilityChips = ({
>
<span>{getCapabilityLabel(cap)}</span>
<span
className={`ml-2 rounded-full px-1.5 py-0.5 text-[10px] leading-none ${
className={`ml-1.5 rounded-full px-1 py-0.5 text-[9px] leading-none ${
isActive
? 'bg-surface-raised text-text-secondary'
: 'bg-surface-raised/70 text-text-muted'

View file

@ -33,7 +33,7 @@ export const CategoryChips = ({
if (categoryCounts.length === 0) return <></>;
return (
<div className="flex flex-wrap gap-2">
<div className="flex flex-wrap gap-1.5">
{categoryCounts.map(([category, count]) => {
const isActive = selected.includes(category);
return (
@ -43,7 +43,7 @@ export const CategoryChips = ({
size="sm"
onClick={() => onToggle(category)}
aria-pressed={isActive}
className={`h-8 rounded-full border px-3 text-xs font-medium transition-all ${
className={`h-7 rounded-full border px-2.5 text-[11px] font-medium transition-all ${
isActive
? 'border-blue-500/40 bg-blue-500/15 text-blue-300 shadow-sm'
: 'hover:bg-surface-raised/60 border-border bg-transparent text-text-secondary hover:border-border-emphasis hover:text-text'
@ -51,7 +51,7 @@ export const CategoryChips = ({
>
<span>{category}</span>
<span
className={`ml-2 rounded-full px-1.5 py-0.5 text-[10px] leading-none ${
className={`ml-1.5 rounded-full px-1 py-0.5 text-[9px] leading-none ${
isActive
? 'bg-surface-raised text-text-secondary'
: 'bg-surface-raised/70 text-text-muted'

View file

@ -17,7 +17,7 @@ import {
} from '@renderer/components/ui/select';
import { useStore } from '@renderer/store';
import { inferCapabilities, normalizeCategory } from '@shared/utils/extensionNormalizers';
import { Filter, Puzzle, Search } from 'lucide-react';
import { ArrowUpDown, Filter, Puzzle, Search } from 'lucide-react';
import { SearchInput } from '../common/SearchInput';
@ -176,7 +176,8 @@ export const PluginsPanel = ({
setPluginSort({ field, order });
}}
>
<SelectTrigger className="w-full sm:w-40">
<SelectTrigger className="w-full gap-2 sm:w-40">
<ArrowUpDown className="size-3.5 shrink-0 text-text-muted" />
<SelectValue />
</SelectTrigger>
<SelectContent>
@ -246,38 +247,40 @@ export const PluginsPanel = ({
)}
</div>
<div className="grid gap-4 xl:grid-cols-2">
<section className="space-y-3 rounded-lg border border-border bg-transparent p-3">
<div className="flex items-center justify-between gap-2">
<span className="text-[11px] font-semibold uppercase tracking-[0.18em] text-text-muted">
Categories
</span>
<span className="text-[11px] text-text-muted">
{pluginFilters.categories.length} selected
</span>
</div>
<CategoryChips
plugins={catalog}
selected={pluginFilters.categories}
onToggle={toggleCategory}
/>
</section>
<div className="overflow-hidden rounded-lg border border-border bg-transparent">
<div className="grid gap-0 xl:grid-cols-2">
<section className="space-y-3 p-3 xl:border-r xl:border-border">
<div className="flex items-center justify-between gap-2">
<span className="text-[11px] font-semibold uppercase tracking-[0.18em] text-text-muted">
Categories
</span>
<span className="text-[11px] text-text-muted">
{pluginFilters.categories.length} selected
</span>
</div>
<CategoryChips
plugins={catalog}
selected={pluginFilters.categories}
onToggle={toggleCategory}
/>
</section>
<section className="space-y-3 rounded-lg border border-border bg-transparent p-3">
<div className="flex items-center justify-between gap-2">
<span className="text-[11px] font-semibold uppercase tracking-[0.18em] text-text-muted">
Capabilities
</span>
<span className="text-[11px] text-text-muted">
{pluginFilters.capabilities.length} selected
</span>
</div>
<CapabilityChips
plugins={catalog}
selected={pluginFilters.capabilities}
onToggle={toggleCapability}
/>
</section>
<section className="space-y-3 border-t border-border p-3 xl:border-t-0">
<div className="flex items-center justify-between gap-2">
<span className="text-[11px] font-semibold uppercase tracking-[0.18em] text-text-muted">
Capabilities
</span>
<span className="text-[11px] text-text-muted">
{pluginFilters.capabilities.length} selected
</span>
</div>
<CapabilityChips
plugins={catalog}
selected={pluginFilters.capabilities}
onToggle={toggleCapability}
/>
</section>
</div>
</div>
</div>
</div>
@ -358,7 +361,7 @@ export const PluginsPanel = ({
)}
{!loading && !error && filtered.length > 0 && (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3">
<div className="plugins-grid grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3">
{filtered.map((plugin, index) => (
<PluginCard
key={plugin.pluginId}

View file

@ -388,7 +388,7 @@ export const SkillsPanel = ({
{visibleProjectSkills.length}
</Badge>
</div>
<div className="grid grid-cols-1 gap-3 xl:grid-cols-2">
<div className="skills-grid grid grid-cols-1 gap-3 xl:grid-cols-2">
{visibleProjectSkills.map((skill) => (
<button
key={skill.id}
@ -471,7 +471,7 @@ export const SkillsPanel = ({
{visibleUserSkills.length}
</Badge>
</div>
<div className="grid grid-cols-1 gap-3 xl:grid-cols-2">
<div className="skills-grid grid grid-cols-1 gap-3 xl:grid-cols-2">
{visibleUserSkills.map((skill) => (
<button
key={skill.id}

View file

@ -1,4 +1,4 @@
import { useRef, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { Button } from '@renderer/components/ui/button';
import { useStore } from '@renderer/store';
@ -25,12 +25,14 @@ export const TeamProvisioningBanner = ({
}))
);
const [dismissed, setDismissed] = useState(false);
const prevRunIdRef = useRef(progress?.runId);
const bannerInstanceKey = useMemo(() => {
if (!progress) return null;
return `${teamName}:${progress.runId}:${progress.startedAt}`;
}, [teamName, progress?.runId, progress?.startedAt]);
if (prevRunIdRef.current !== progress?.runId) {
prevRunIdRef.current = progress?.runId;
useEffect(() => {
setDismissed(false);
}
}, [bannerInstanceKey]);
// NOTE: we intentionally do NOT auto-dismiss "ready" banners.
// Users frequently need to inspect launch output after fast stop→start cycles,

View file

@ -18,9 +18,9 @@ import {
ArrowLeftFromLine,
ArrowRightFromLine,
CheckCircle2,
Eye,
FileCode,
FilePenLine,
GitPullRequestArrow,
HelpCircle,
Play,
RotateCcw,
@ -435,7 +435,7 @@ export const KanbanTaskCard = ({
/>
<TaskActionIconButton
label="Request review"
icon={<GitPullRequestArrow size={14} />}
icon={<Eye size={14} />}
className="border-violet-500/40 text-violet-400 hover:bg-violet-500/10 hover:text-violet-300"
onClick={(e) => {
e.stopPropagation();

View file

@ -979,6 +979,92 @@ body {
line-height: 0.875rem;
}
/* Extension list zebra rows */
.mcp-servers-grid > *,
.plugins-grid > *,
.skills-grid > *,
.mcp-diagnostics-list > * {
background-color: transparent;
}
@media (max-width: 639px) {
.mcp-servers-grid > *:nth-child(even),
.plugins-grid > *:nth-child(even),
.skills-grid > *:nth-child(even) {
background-color: rgba(148, 163, 184, 0.025);
}
}
@media (min-width: 640px) and (max-width: 1279px) {
.mcp-servers-grid > *:nth-child(4n + 3),
.mcp-servers-grid > *:nth-child(4n + 4),
.plugins-grid > *:nth-child(4n + 3),
.plugins-grid > *:nth-child(4n + 4),
.skills-grid > *:nth-child(even) {
background-color: rgba(148, 163, 184, 0.025);
}
}
@media (min-width: 1280px) {
.mcp-servers-grid > *:nth-child(6n + 4),
.mcp-servers-grid > *:nth-child(6n + 5),
.mcp-servers-grid > *:nth-child(6n + 6),
.plugins-grid > *:nth-child(6n + 4),
.plugins-grid > *:nth-child(6n + 5),
.plugins-grid > *:nth-child(6n + 6),
.skills-grid > *:nth-child(4n + 3),
.skills-grid > *:nth-child(4n + 4) {
background-color: rgba(148, 163, 184, 0.025);
}
}
:root.light .mcp-servers-grid > *,
:root.light .plugins-grid > *,
:root.light .skills-grid > *,
:root.light .mcp-diagnostics-list > * {
background-color: transparent;
}
@media (max-width: 639px) {
:root.light .mcp-servers-grid > *:nth-child(even),
:root.light .plugins-grid > *:nth-child(even),
:root.light .skills-grid > *:nth-child(even) {
background-color: rgba(15, 23, 42, 0.05);
}
}
@media (min-width: 640px) and (max-width: 1279px) {
:root.light .mcp-servers-grid > *:nth-child(4n + 3),
:root.light .mcp-servers-grid > *:nth-child(4n + 4),
:root.light .plugins-grid > *:nth-child(4n + 3),
:root.light .plugins-grid > *:nth-child(4n + 4),
:root.light .skills-grid > *:nth-child(even) {
background-color: rgba(15, 23, 42, 0.05);
}
}
@media (min-width: 1280px) {
:root.light .mcp-servers-grid > *:nth-child(6n + 4),
:root.light .mcp-servers-grid > *:nth-child(6n + 5),
:root.light .mcp-servers-grid > *:nth-child(6n + 6),
:root.light .plugins-grid > *:nth-child(6n + 4),
:root.light .plugins-grid > *:nth-child(6n + 5),
:root.light .plugins-grid > *:nth-child(6n + 6),
:root.light .skills-grid > *:nth-child(4n + 3),
:root.light .skills-grid > *:nth-child(4n + 4) {
background-color: rgba(15, 23, 42, 0.05);
}
}
.mcp-diagnostics-list > *:nth-child(even) {
background-color: rgba(148, 163, 184, 0.025);
}
:root.light .mcp-diagnostics-list > *:nth-child(even) {
background-color: rgba(15, 23, 42, 0.05);
}
:root.light .checkerboard-bg {
background-image:
linear-gradient(45deg, #e2e8f0 25%, transparent 25%),

View file

@ -638,25 +638,37 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
if (!api.teams?.getMemberSpawnStatuses) return;
try {
const snapshot = await api.teams.getMemberSpawnStatuses(teamName);
set((prev) => ({
...(snapshot.runId != null &&
prev.currentRuntimeRunIdByTeam[teamName] != null &&
prev.currentRuntimeRunIdByTeam[teamName] !== snapshot.runId
? {}
: {
currentRuntimeRunIdByTeam:
snapshot.runId == null
? prev.currentRuntimeRunIdByTeam
: {
...prev.currentRuntimeRunIdByTeam,
[teamName]: prev.currentRuntimeRunIdByTeam[teamName] ?? snapshot.runId,
},
memberSpawnStatusesByTeam: {
...prev.memberSpawnStatusesByTeam,
[teamName]: snapshot.statuses,
},
}),
}));
set((prev) => {
if (
prev.currentRuntimeRunIdByTeam[teamName] == null &&
prev.leadActivityByTeam[teamName] === 'offline' &&
snapshot.runId != null
) {
return {};
}
if (
snapshot.runId != null &&
prev.currentRuntimeRunIdByTeam[teamName] != null &&
prev.currentRuntimeRunIdByTeam[teamName] !== snapshot.runId
) {
return {};
}
return {
currentRuntimeRunIdByTeam:
snapshot.runId == null
? prev.currentRuntimeRunIdByTeam
: {
...prev.currentRuntimeRunIdByTeam,
[teamName]: prev.currentRuntimeRunIdByTeam[teamName] ?? snapshot.runId,
},
memberSpawnStatusesByTeam: {
...prev.memberSpawnStatusesByTeam,
[teamName]: snapshot.statuses,
},
};
});
} catch {
// ignore — spawn statuses are best-effort
}
@ -1683,6 +1695,9 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
}
set((state) => {
const nextRuns: Record<string, TeamProvisioningProgress> = {
...state.provisioningRuns,
};
const nextCurrentRunIdByTeam = { ...state.currentProvisioningRunIdByTeam };
const previousCurrentRunId = nextCurrentRunIdByTeam[progress.teamName];
let isCanonicalRun = false;
@ -1704,15 +1719,11 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
if (!(progress.runId in state.provisioningRuns)) {
return {};
}
const nextRuns = { ...state.provisioningRuns };
delete nextRuns[progress.runId];
return { provisioningRuns: nextRuns };
}
const nextRuns: Record<string, TeamProvisioningProgress> = {
...state.provisioningRuns,
[progress.runId]: progress,
};
nextRuns[progress.runId] = progress;
for (const [runId, run] of Object.entries(nextRuns)) {
if (runId !== progress.runId && run.teamName === progress.teamName) {
delete nextRuns[runId];

View file

@ -420,6 +420,45 @@ describe('teamSlice actions', () => {
expect(store.getState().provisioningRuns['run-stale']).toBeUndefined();
});
it('promotes a pending run to a real run without throwing', () => {
const store = createSliceStore();
store.setState({
provisioningRuns: {
'pending:my-team:1': {
runId: 'pending:my-team:1',
teamName: 'my-team',
state: 'spawning',
message: 'Launching',
startedAt: '2026-03-12T10:00:00.000Z',
updatedAt: '2026-03-12T10:00:00.000Z',
},
},
currentProvisioningRunIdByTeam: {
'my-team': 'pending:my-team:1',
},
});
expect(() =>
store.getState().onProvisioningProgress({
runId: 'run-real',
teamName: 'my-team',
state: 'monitoring',
message: 'Real run',
startedAt: '2026-03-12T10:00:01.000Z',
updatedAt: '2026-03-12T10:00:01.000Z',
})
).not.toThrow();
expect(store.getState().currentProvisioningRunIdByTeam['my-team']).toBe('run-real');
expect(store.getState().provisioningRuns['pending:my-team:1']).toBeUndefined();
expect(store.getState().provisioningRuns['run-real']).toEqual(
expect.objectContaining({
runId: 'run-real',
state: 'monitoring',
})
);
});
it('clears orphaned runs when polling reports Unknown runId', () => {
const store = createSliceStore();
store.setState({
@ -507,6 +546,29 @@ describe('teamSlice actions', () => {
});
});
it('ignores stale spawn-status fetches after runtime already went offline', async () => {
const store = createSliceStore();
store.setState({
currentProvisioningRunIdByTeam: {
'my-team': 'provisioning-run',
},
leadActivityByTeam: {
'my-team': 'offline',
},
});
hoisted.getMemberSpawnStatuses.mockResolvedValue({
runId: 'old-runtime-run',
statuses: {
alice: { status: 'spawning', updatedAt: '2026-03-12T10:00:00.000Z' },
},
});
await store.getState().fetchMemberSpawnStatuses('my-team');
expect(store.getState().currentRuntimeRunIdByTeam['my-team']).toBeUndefined();
expect(store.getState().memberSpawnStatusesByTeam['my-team']).toBeUndefined();
});
it('preserves current spawn statuses when clearing a non-canonical missing run', () => {
const store = createSliceStore();
store.setState({