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"
|
||||
size="sm"
|
||||
onClick={() => setCustomMcpDialogOpen(true)}
|
||||
className="whitespace-nowrap"
|
||||
className="mb-1 whitespace-nowrap"
|
||||
>
|
||||
<Plus className="mr-1 size-3.5" />
|
||||
Add Custom
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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%),
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
Loading…
Reference in a new issue