- {/* Search + Sort + Installed only row */}
+ {/* Search + sort row */}
-
- setMcpInstalledOnly(!mcpInstalledOnly)}
- />
-
- Installed only
-
-
{/* Warnings */}
@@ -343,31 +321,23 @@ export const McpServersPanel = ({
{!isLoading && displayServers.length === 0 && (
- {isSearching || mcpInstalledOnly ? (
+ {isSearching ? (
) : (
)}
- {isSearching
- ? 'No servers found'
- : mcpInstalledOnly
- ? 'No installed servers'
- : 'No MCP servers available'}
+ {isSearching ? 'No servers found' : 'No MCP servers available'}
- {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'}
)}
{displayServers.length > 0 && (
-
+
{displayServers.map((server) => (
+
{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 = ({
>
{getCapabilityLabel(cap)}
>;
return (
-
+
{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 = ({
>
{category}
-
+
+
@@ -246,38 +247,40 @@ export const PluginsPanel = ({
)}
-
-
-
-
- Categories
-
-
- {pluginFilters.categories.length} selected
-
-
-
-
+
+
+
+
+
+ Categories
+
+
+ {pluginFilters.categories.length} selected
+
+
+
+
-
-
-
- Capabilities
-
-
- {pluginFilters.capabilities.length} selected
-
-
-
-
+
+
+
+ Capabilities
+
+
+ {pluginFilters.capabilities.length} selected
+
+
+
+
+
@@ -358,7 +361,7 @@ export const PluginsPanel = ({
)}
{!loading && !error && filtered.length > 0 && (
-
+
{filtered.map((plugin, index) => (
-
+
{visibleProjectSkills.map((skill) => (
-
+
{visibleUserSkills.map((skill) => (
{
+ 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,
diff --git a/src/renderer/components/team/kanban/KanbanTaskCard.tsx b/src/renderer/components/team/kanban/KanbanTaskCard.tsx
index 3127006d..fea7e9f8 100644
--- a/src/renderer/components/team/kanban/KanbanTaskCard.tsx
+++ b/src/renderer/components/team/kanban/KanbanTaskCard.tsx
@@ -18,9 +18,9 @@ import {
ArrowLeftFromLine,
ArrowRightFromLine,
CheckCircle2,
+ Eye,
FileCode,
FilePenLine,
- GitPullRequestArrow,
HelpCircle,
Play,
RotateCcw,
@@ -435,7 +435,7 @@ export const KanbanTaskCard = ({
/>
}
+ icon={ }
className="border-violet-500/40 text-violet-400 hover:bg-violet-500/10 hover:text-violet-300"
onClick={(e) => {
e.stopPropagation();
diff --git a/src/renderer/index.css b/src/renderer/index.css
index d410aecc..f9022b04 100644
--- a/src/renderer/index.css
+++ b/src/renderer/index.css
@@ -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%),
diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts
index 79906b92..d0396a5b 100644
--- a/src/renderer/store/slices/teamSlice.ts
+++ b/src/renderer/store/slices/teamSlice.ts
@@ -638,25 +638,37 @@ export const createTeamSlice: StateCreator = (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 = (set,
}
set((state) => {
+ const nextRuns: Record = {
+ ...state.provisioningRuns,
+ };
const nextCurrentRunIdByTeam = { ...state.currentProvisioningRunIdByTeam };
const previousCurrentRunId = nextCurrentRunIdByTeam[progress.teamName];
let isCanonicalRun = false;
@@ -1704,15 +1719,11 @@ export const createTeamSlice: StateCreator = (set,
if (!(progress.runId in state.provisioningRuns)) {
return {};
}
- const nextRuns = { ...state.provisioningRuns };
delete nextRuns[progress.runId];
return { provisioningRuns: nextRuns };
}
- const nextRuns: Record = {
- ...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];
diff --git a/test/renderer/store/teamSlice.test.ts b/test/renderer/store/teamSlice.test.ts
index a04b94c8..72fb7157 100644
--- a/test/renderer/store/teamSlice.test.ts
+++ b/test/renderer/store/teamSlice.test.ts
@@ -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({