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:
parent
3723eba5b4
commit
88722598ac
12 changed files with 270 additions and 114 deletions
|
|
@ -194,7 +194,7 @@ export const ExtensionStoreView = (): React.JSX.Element => {
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setCustomMcpDialogOpen(true)}
|
onClick={() => setCustomMcpDialogOpen(true)}
|
||||||
className="whitespace-nowrap"
|
className="mb-1 whitespace-nowrap"
|
||||||
>
|
>
|
||||||
<Plus className="mr-1 size-3.5" />
|
<Plus className="mr-1 size-3.5" />
|
||||||
Add Custom
|
Add Custom
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@
|
||||||
* States: idle → pending (spinner) → success (checkmark, 2s) → idle
|
* States: idle → pending (spinner) → success (checkmark, 2s) → idle
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
import { Check, Loader2, Trash2 } from 'lucide-react';
|
import { Check, Loader2, Trash2 } from 'lucide-react';
|
||||||
|
|
||||||
import { Button } from '@renderer/components/ui/button';
|
import { Button } from '@renderer/components/ui/button';
|
||||||
|
|
@ -38,11 +40,22 @@ export function InstallButton({
|
||||||
const cliStatus = useStore((s) => s.cliStatus);
|
const cliStatus = useStore((s) => s.cliStatus);
|
||||||
const cliMissing = cliStatus !== null && !cliStatus.installed;
|
const cliMissing = cliStatus !== null && !cliStatus.installed;
|
||||||
const isDisabled = disabled || cliMissing;
|
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') {
|
if (state === 'pending') {
|
||||||
return (
|
return (
|
||||||
<Button size={size} variant="outline" disabled>
|
<Button size={size} variant="outline" disabled>
|
||||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
<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>
|
</Button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -64,7 +77,14 @@ export function InstallButton({
|
||||||
className="border-red-500/30 text-red-400 hover:bg-red-500/10"
|
className="border-red-500/30 text-red-400 hover:bg-red-500/10"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
(isInstalled ? onUninstall : onInstall)();
|
if (pendingAction === 'uninstall') {
|
||||||
|
setLastAction('uninstall');
|
||||||
|
onUninstall();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLastAction('install');
|
||||||
|
onInstall();
|
||||||
}}
|
}}
|
||||||
disabled={isDisabled}
|
disabled={isDisabled}
|
||||||
>
|
>
|
||||||
|
|
@ -96,6 +116,7 @@ export function InstallButton({
|
||||||
className="border-red-500/30 text-red-400 hover:bg-red-500/10"
|
className="border-red-500/30 text-red-400 hover:bg-red-500/10"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
setLastAction('uninstall');
|
||||||
onUninstall();
|
onUninstall();
|
||||||
}}
|
}}
|
||||||
disabled={isDisabled}
|
disabled={isDisabled}
|
||||||
|
|
@ -109,6 +130,7 @@ export function InstallButton({
|
||||||
variant="default"
|
variant="default"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
setLastAction('install');
|
||||||
onInstall();
|
onInstall();
|
||||||
}}
|
}}
|
||||||
disabled={isDisabled}
|
disabled={isDisabled}
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,6 @@ import { useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { Badge } from '@renderer/components/ui/badge';
|
import { Badge } from '@renderer/components/ui/badge';
|
||||||
import { Button } from '@renderer/components/ui/button';
|
import { Button } from '@renderer/components/ui/button';
|
||||||
import { Checkbox } from '@renderer/components/ui/checkbox';
|
|
||||||
import { Label } from '@renderer/components/ui/label';
|
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
|
|
@ -87,7 +85,6 @@ export const McpServersPanel = ({
|
||||||
const runMcpDiagnostics = useStore((s) => s.runMcpDiagnostics);
|
const runMcpDiagnostics = useStore((s) => s.runMcpDiagnostics);
|
||||||
|
|
||||||
const [mcpSort, setMcpSort] = useState<McpSortValue>('name-asc');
|
const [mcpSort, setMcpSort] = useState<McpSortValue>('name-asc');
|
||||||
const [mcpInstalledOnly, setMcpInstalledOnly] = useState(false);
|
|
||||||
|
|
||||||
// Load initial browse data
|
// Load initial browse data
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -155,14 +152,8 @@ export const McpServersPanel = ({
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Sort + filter
|
// Sort displayed servers
|
||||||
const displayServers = useMemo(() => {
|
const displayServers = useMemo(() => sortMcpServers(rawServers, mcpSort), [rawServers, mcpSort]);
|
||||||
let result = rawServers;
|
|
||||||
if (mcpInstalledOnly) {
|
|
||||||
result = result.filter(isServerInstalled);
|
|
||||||
}
|
|
||||||
return sortMcpServers(result, mcpSort);
|
|
||||||
}, [rawServers, mcpSort, mcpInstalledOnly, installedNames]);
|
|
||||||
|
|
||||||
// Find selected server (search in both lists to avoid losing selection during search toggle)
|
// Find selected server (search in both lists to avoid losing selection during search toggle)
|
||||||
const selectedServer = useMemo(() => {
|
const selectedServer = useMemo(() => {
|
||||||
|
|
@ -219,7 +210,7 @@ export const McpServersPanel = ({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{allDiagnostics.length > 0 ? (
|
{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) => (
|
{allDiagnostics.map((diagnostic) => (
|
||||||
<div
|
<div
|
||||||
key={diagnostic.name}
|
key={diagnostic.name}
|
||||||
|
|
@ -247,7 +238,7 @@ export const McpServersPanel = ({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Search + Sort + Installed only row */}
|
{/* Search + sort row */}
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<SearchInput
|
<SearchInput
|
||||||
|
|
@ -268,19 +259,6 @@ export const McpServersPanel = ({
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Warnings */}
|
{/* Warnings */}
|
||||||
|
|
@ -343,31 +321,23 @@ export const McpServersPanel = ({
|
||||||
{!isLoading && displayServers.length === 0 && (
|
{!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 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">
|
<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" />
|
<Search className="size-5 text-text-muted" />
|
||||||
) : (
|
) : (
|
||||||
<Server className="size-5 text-text-muted" />
|
<Server className="size-5 text-text-muted" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-text-secondary">
|
<p className="text-sm text-text-secondary">
|
||||||
{isSearching
|
{isSearching ? 'No servers found' : 'No MCP servers available'}
|
||||||
? 'No servers found'
|
|
||||||
: mcpInstalledOnly
|
|
||||||
? 'No installed servers'
|
|
||||||
: 'No MCP servers available'}
|
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-text-muted">
|
<p className="text-xs text-text-muted">
|
||||||
{isSearching
|
{isSearching ? 'Try a different search term' : 'Check back later for new servers'}
|
||||||
? 'Try a different search term'
|
|
||||||
: mcpInstalledOnly
|
|
||||||
? 'Install servers from the catalog to see them here'
|
|
||||||
: 'Check back later for new servers'}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{displayServers.length > 0 && (
|
{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) => (
|
{displayServers.map((server) => (
|
||||||
<McpServerCard
|
<McpServerCard
|
||||||
key={server.id}
|
key={server.id}
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@ export const CapabilityChips = ({
|
||||||
}, [plugins]);
|
}, [plugins]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{ALL_CAPABILITIES.map((cap) => {
|
{ALL_CAPABILITIES.map((cap) => {
|
||||||
const count = capabilityCounts.get(cap) ?? 0;
|
const count = capabilityCounts.get(cap) ?? 0;
|
||||||
if (count === 0) return null;
|
if (count === 0) return null;
|
||||||
|
|
@ -46,7 +46,7 @@ export const CapabilityChips = ({
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => onToggle(cap)}
|
onClick={() => onToggle(cap)}
|
||||||
aria-pressed={isActive}
|
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
|
isActive
|
||||||
? 'border-purple-500/40 bg-purple-500/15 text-purple-300 shadow-sm'
|
? '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'
|
: '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>{getCapabilityLabel(cap)}</span>
|
||||||
<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
|
isActive
|
||||||
? 'bg-surface-raised text-text-secondary'
|
? 'bg-surface-raised text-text-secondary'
|
||||||
: 'bg-surface-raised/70 text-text-muted'
|
: 'bg-surface-raised/70 text-text-muted'
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@ export const CategoryChips = ({
|
||||||
if (categoryCounts.length === 0) return <></>;
|
if (categoryCounts.length === 0) return <></>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{categoryCounts.map(([category, count]) => {
|
{categoryCounts.map(([category, count]) => {
|
||||||
const isActive = selected.includes(category);
|
const isActive = selected.includes(category);
|
||||||
return (
|
return (
|
||||||
|
|
@ -43,7 +43,7 @@ export const CategoryChips = ({
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => onToggle(category)}
|
onClick={() => onToggle(category)}
|
||||||
aria-pressed={isActive}
|
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
|
isActive
|
||||||
? 'border-blue-500/40 bg-blue-500/15 text-blue-300 shadow-sm'
|
? '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'
|
: '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>{category}</span>
|
||||||
<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
|
isActive
|
||||||
? 'bg-surface-raised text-text-secondary'
|
? 'bg-surface-raised text-text-secondary'
|
||||||
: 'bg-surface-raised/70 text-text-muted'
|
: 'bg-surface-raised/70 text-text-muted'
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ import {
|
||||||
} from '@renderer/components/ui/select';
|
} from '@renderer/components/ui/select';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { inferCapabilities, normalizeCategory } from '@shared/utils/extensionNormalizers';
|
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';
|
import { SearchInput } from '../common/SearchInput';
|
||||||
|
|
||||||
|
|
@ -176,7 +176,8 @@ export const PluginsPanel = ({
|
||||||
setPluginSort({ field, order });
|
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 />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
|
|
@ -246,38 +247,40 @@ export const PluginsPanel = ({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-4 xl:grid-cols-2">
|
<div className="overflow-hidden rounded-lg border border-border bg-transparent">
|
||||||
<section className="space-y-3 rounded-lg border border-border bg-transparent p-3">
|
<div className="grid gap-0 xl:grid-cols-2">
|
||||||
<div className="flex items-center justify-between gap-2">
|
<section className="space-y-3 p-3 xl:border-r xl:border-border">
|
||||||
<span className="text-[11px] font-semibold uppercase tracking-[0.18em] text-text-muted">
|
<div className="flex items-center justify-between gap-2">
|
||||||
Categories
|
<span className="text-[11px] font-semibold uppercase tracking-[0.18em] text-text-muted">
|
||||||
</span>
|
Categories
|
||||||
<span className="text-[11px] text-text-muted">
|
</span>
|
||||||
{pluginFilters.categories.length} selected
|
<span className="text-[11px] text-text-muted">
|
||||||
</span>
|
{pluginFilters.categories.length} selected
|
||||||
</div>
|
</span>
|
||||||
<CategoryChips
|
</div>
|
||||||
plugins={catalog}
|
<CategoryChips
|
||||||
selected={pluginFilters.categories}
|
plugins={catalog}
|
||||||
onToggle={toggleCategory}
|
selected={pluginFilters.categories}
|
||||||
/>
|
onToggle={toggleCategory}
|
||||||
</section>
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section className="space-y-3 rounded-lg border border-border bg-transparent p-3">
|
<section className="space-y-3 border-t border-border p-3 xl:border-t-0">
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<span className="text-[11px] font-semibold uppercase tracking-[0.18em] text-text-muted">
|
<span className="text-[11px] font-semibold uppercase tracking-[0.18em] text-text-muted">
|
||||||
Capabilities
|
Capabilities
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[11px] text-text-muted">
|
<span className="text-[11px] text-text-muted">
|
||||||
{pluginFilters.capabilities.length} selected
|
{pluginFilters.capabilities.length} selected
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<CapabilityChips
|
<CapabilityChips
|
||||||
plugins={catalog}
|
plugins={catalog}
|
||||||
selected={pluginFilters.capabilities}
|
selected={pluginFilters.capabilities}
|
||||||
onToggle={toggleCapability}
|
onToggle={toggleCapability}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -358,7 +361,7 @@ export const PluginsPanel = ({
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!loading && !error && filtered.length > 0 && (
|
{!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) => (
|
{filtered.map((plugin, index) => (
|
||||||
<PluginCard
|
<PluginCard
|
||||||
key={plugin.pluginId}
|
key={plugin.pluginId}
|
||||||
|
|
|
||||||
|
|
@ -388,7 +388,7 @@ export const SkillsPanel = ({
|
||||||
{visibleProjectSkills.length}
|
{visibleProjectSkills.length}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</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) => (
|
{visibleProjectSkills.map((skill) => (
|
||||||
<button
|
<button
|
||||||
key={skill.id}
|
key={skill.id}
|
||||||
|
|
@ -471,7 +471,7 @@ export const SkillsPanel = ({
|
||||||
{visibleUserSkills.length}
|
{visibleUserSkills.length}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</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) => (
|
{visibleUserSkills.map((skill) => (
|
||||||
<button
|
<button
|
||||||
key={skill.id}
|
key={skill.id}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useRef, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { Button } from '@renderer/components/ui/button';
|
import { Button } from '@renderer/components/ui/button';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
|
|
@ -25,12 +25,14 @@ export const TeamProvisioningBanner = ({
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
const [dismissed, setDismissed] = useState(false);
|
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) {
|
useEffect(() => {
|
||||||
prevRunIdRef.current = progress?.runId;
|
|
||||||
setDismissed(false);
|
setDismissed(false);
|
||||||
}
|
}, [bannerInstanceKey]);
|
||||||
|
|
||||||
// NOTE: we intentionally do NOT auto-dismiss "ready" banners.
|
// NOTE: we intentionally do NOT auto-dismiss "ready" banners.
|
||||||
// Users frequently need to inspect launch output after fast stop→start cycles,
|
// Users frequently need to inspect launch output after fast stop→start cycles,
|
||||||
|
|
|
||||||
|
|
@ -18,9 +18,9 @@ import {
|
||||||
ArrowLeftFromLine,
|
ArrowLeftFromLine,
|
||||||
ArrowRightFromLine,
|
ArrowRightFromLine,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
|
Eye,
|
||||||
FileCode,
|
FileCode,
|
||||||
FilePenLine,
|
FilePenLine,
|
||||||
GitPullRequestArrow,
|
|
||||||
HelpCircle,
|
HelpCircle,
|
||||||
Play,
|
Play,
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
|
|
@ -435,7 +435,7 @@ export const KanbanTaskCard = ({
|
||||||
/>
|
/>
|
||||||
<TaskActionIconButton
|
<TaskActionIconButton
|
||||||
label="Request review"
|
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"
|
className="border-violet-500/40 text-violet-400 hover:bg-violet-500/10 hover:text-violet-300"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
|
||||||
|
|
@ -979,6 +979,92 @@ body {
|
||||||
line-height: 0.875rem;
|
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 {
|
:root.light .checkerboard-bg {
|
||||||
background-image:
|
background-image:
|
||||||
linear-gradient(45deg, #e2e8f0 25%, transparent 25%),
|
linear-gradient(45deg, #e2e8f0 25%, transparent 25%),
|
||||||
|
|
|
||||||
|
|
@ -638,25 +638,37 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
if (!api.teams?.getMemberSpawnStatuses) return;
|
if (!api.teams?.getMemberSpawnStatuses) return;
|
||||||
try {
|
try {
|
||||||
const snapshot = await api.teams.getMemberSpawnStatuses(teamName);
|
const snapshot = await api.teams.getMemberSpawnStatuses(teamName);
|
||||||
set((prev) => ({
|
set((prev) => {
|
||||||
...(snapshot.runId != null &&
|
if (
|
||||||
prev.currentRuntimeRunIdByTeam[teamName] != null &&
|
prev.currentRuntimeRunIdByTeam[teamName] == null &&
|
||||||
prev.currentRuntimeRunIdByTeam[teamName] !== snapshot.runId
|
prev.leadActivityByTeam[teamName] === 'offline' &&
|
||||||
? {}
|
snapshot.runId != null
|
||||||
: {
|
) {
|
||||||
currentRuntimeRunIdByTeam:
|
return {};
|
||||||
snapshot.runId == null
|
}
|
||||||
? prev.currentRuntimeRunIdByTeam
|
|
||||||
: {
|
if (
|
||||||
...prev.currentRuntimeRunIdByTeam,
|
snapshot.runId != null &&
|
||||||
[teamName]: prev.currentRuntimeRunIdByTeam[teamName] ?? snapshot.runId,
|
prev.currentRuntimeRunIdByTeam[teamName] != null &&
|
||||||
},
|
prev.currentRuntimeRunIdByTeam[teamName] !== snapshot.runId
|
||||||
memberSpawnStatusesByTeam: {
|
) {
|
||||||
...prev.memberSpawnStatusesByTeam,
|
return {};
|
||||||
[teamName]: snapshot.statuses,
|
}
|
||||||
},
|
|
||||||
}),
|
return {
|
||||||
}));
|
currentRuntimeRunIdByTeam:
|
||||||
|
snapshot.runId == null
|
||||||
|
? prev.currentRuntimeRunIdByTeam
|
||||||
|
: {
|
||||||
|
...prev.currentRuntimeRunIdByTeam,
|
||||||
|
[teamName]: prev.currentRuntimeRunIdByTeam[teamName] ?? snapshot.runId,
|
||||||
|
},
|
||||||
|
memberSpawnStatusesByTeam: {
|
||||||
|
...prev.memberSpawnStatusesByTeam,
|
||||||
|
[teamName]: snapshot.statuses,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
} catch {
|
} catch {
|
||||||
// ignore — spawn statuses are best-effort
|
// ignore — spawn statuses are best-effort
|
||||||
}
|
}
|
||||||
|
|
@ -1683,6 +1695,9 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
}
|
}
|
||||||
|
|
||||||
set((state) => {
|
set((state) => {
|
||||||
|
const nextRuns: Record<string, TeamProvisioningProgress> = {
|
||||||
|
...state.provisioningRuns,
|
||||||
|
};
|
||||||
const nextCurrentRunIdByTeam = { ...state.currentProvisioningRunIdByTeam };
|
const nextCurrentRunIdByTeam = { ...state.currentProvisioningRunIdByTeam };
|
||||||
const previousCurrentRunId = nextCurrentRunIdByTeam[progress.teamName];
|
const previousCurrentRunId = nextCurrentRunIdByTeam[progress.teamName];
|
||||||
let isCanonicalRun = false;
|
let isCanonicalRun = false;
|
||||||
|
|
@ -1704,15 +1719,11 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
if (!(progress.runId in state.provisioningRuns)) {
|
if (!(progress.runId in state.provisioningRuns)) {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
const nextRuns = { ...state.provisioningRuns };
|
|
||||||
delete nextRuns[progress.runId];
|
delete nextRuns[progress.runId];
|
||||||
return { provisioningRuns: nextRuns };
|
return { provisioningRuns: nextRuns };
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextRuns: Record<string, TeamProvisioningProgress> = {
|
nextRuns[progress.runId] = progress;
|
||||||
...state.provisioningRuns,
|
|
||||||
[progress.runId]: progress,
|
|
||||||
};
|
|
||||||
for (const [runId, run] of Object.entries(nextRuns)) {
|
for (const [runId, run] of Object.entries(nextRuns)) {
|
||||||
if (runId !== progress.runId && run.teamName === progress.teamName) {
|
if (runId !== progress.runId && run.teamName === progress.teamName) {
|
||||||
delete nextRuns[runId];
|
delete nextRuns[runId];
|
||||||
|
|
|
||||||
|
|
@ -420,6 +420,45 @@ describe('teamSlice actions', () => {
|
||||||
expect(store.getState().provisioningRuns['run-stale']).toBeUndefined();
|
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', () => {
|
it('clears orphaned runs when polling reports Unknown runId', () => {
|
||||||
const store = createSliceStore();
|
const store = createSliceStore();
|
||||||
store.setState({
|
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', () => {
|
it('preserves current spawn statuses when clearing a non-canonical missing run', () => {
|
||||||
const store = createSliceStore();
|
const store = createSliceStore();
|
||||||
store.setState({
|
store.setState({
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue