feat(opencode): improve runtime delivery diagnostics

This commit is contained in:
777genius 2026-05-12 13:26:33 +03:00
parent 894bee97e2
commit 3f2b807bbc
74 changed files with 6437 additions and 642 deletions

3
.gitignore vendored
View file

@ -52,3 +52,6 @@ remotion/*
.board-task-log-freshness/ .board-task-log-freshness/
.serena/ .serena/
# Local release operator notes
/ORCHESTRATOR_RELEASE_RUNBOOK.local.md

View file

@ -183,13 +183,18 @@ export default defineConfig({
provider: "local", provider: "local",
options: { options: {
translations: { translations: {
button: "Search...", button: {
buttonAriaLabel: "Search documentation", buttonText: "Search...",
buttonAriaLabel: "Search documentation"
},
modal: {
noResultsText: "No results found", noResultsText: "No results found",
suggestedQueryText: "Try searching for", footer: {
reportMissing: "Found a problem? Create an issue", selectText: "to select",
reportMissingText: "Report missing result", navigateText: "to navigate",
reportMissingLink: "https://github.com/777genius/agent-teams-ai/issues/new" closeText: "to close"
}
}
} }
} }
}, },
@ -214,7 +219,6 @@ export default defineConfig({
lang: "en-US", lang: "en-US",
themeConfig: { themeConfig: {
nav: rootNav, nav: rootNav,
sidebar: rootGuide,
docFooter: { docFooter: {
prev: "Previous", prev: "Previous",
next: "Next" next: "Next"
@ -228,7 +232,6 @@ export default defineConfig({
description: "Документация Agent Teams, локального desktop-приложения для оркестрации AI-агентов.", description: "Документация Agent Teams, локального desktop-приложения для оркестрации AI-агентов.",
themeConfig: { themeConfig: {
nav: ruNav, nav: ruNav,
sidebar: ruGuide,
outline: { outline: {
level: [2, 3], level: [2, 3],
label: "На этой странице" label: "На этой странице"

View file

@ -9,7 +9,7 @@ Agent Teams is distributed as a desktop app for macOS, Windows, and Linux.
## Download builds ## Download builds
Use the <a href="/download/" target="_self">download page</a> or the latest [GitHub release](https://github.com/777genius/agent-teams-ai/releases) when you want the packaged app: Use the <a href="https://github.com/777genius/agent-teams-ai/releases" target="_self">download page</a> or the latest [GitHub release](https://github.com/777genius/agent-teams-ai/releases) when you want the packaged app:
- macOS Apple Silicon: `.dmg` - macOS Apple Silicon: `.dmg`
- macOS Intel: `.dmg` - macOS Intel: `.dmg`

View file

@ -9,7 +9,7 @@ This guide gets you from a fresh install to a running team in a few minutes.
## 1. Install Agent Teams ## 1. Install Agent Teams
Download the latest release for your platform from the <a href="/download/" target="_self">download page</a> or [GitHub releases](https://github.com/777genius/agent-teams-ai/releases). Download the latest release for your platform from the <a href="https://github.com/777genius/agent-teams-ai/releases" target="_self">download page</a> or [GitHub releases](https://github.com/777genius/agent-teams-ai/releases).
::: tip ::: tip
The app is free and open source. The agent runtime you choose may still require provider access — see [Installation](/guide/installation) for details. The app is free and open source. The agent runtime you choose may still require provider access — see [Installation](/guide/installation) for details.
@ -47,7 +47,7 @@ Gemini support is in development and will appear in the runtime list when availa
See [Runtime setup](/guide/runtime-setup) for detailed configuration per provider. See [Runtime setup](/guide/runtime-setup) for detailed configuration per provider.
To verify the selected runtime outside the app, run the matching version command: To verify the selected runtime outside the app, run its version command:
```bash ```bash
claude --version claude --version

View file

@ -1,8 +1,3 @@
---
title: Runtime Setup
description: Configure Claude Code, Codex, or OpenCode runtimes and provider authentication for agent teams.
---
--- ---
title: Runtime Setup Agent Teams Docs title: Runtime Setup Agent Teams Docs
description: Configure Claude Code, Codex, or OpenCode runtimes. Covers auth, provider access, multimodel mode, and prelaunch checks. description: Configure Claude Code, Codex, or OpenCode runtimes. Covers auth, provider access, multimodel mode, and prelaunch checks.

View file

@ -1,8 +1,3 @@
---
title: Troubleshooting
description: Fix launch failures, missing agent replies, rate limits, auth issues, and lane bootstrap problems in Agent Teams.
---
--- ---
title: Troubleshooting Agent Teams Docs title: Troubleshooting Agent Teams Docs
description: Fix team launch issues, missing agent replies, rate limits, CLI auth problems, and lane bootstrap stalls with local diagnostics. description: Fix team launch issues, missing agent replies, rate limits, CLI auth problems, and lane bootstrap stalls with local diagnostics.
@ -88,7 +83,7 @@ If a provider reports a known reset time, Agent Teams can nudge the lead to cont
## CLI auth issues ## CLI auth issues
### `claude login` not persist ### `claude login` does not persist
If the CLI is authenticated in one terminal but the app says it is not, verify the auth is saved to the expected config path and that the app process sees the same `$HOME`. If the CLI is authenticated in one terminal but the app says it is not, verify the auth is saved to the expected config path and that the app process sees the same `$HOME`.

View file

@ -1,18 +1,8 @@
--- ---
title: FAQ title: FAQ Agent Teams Docs
description: Frequently asked questions about Agent Teams — pricing, model access, runtimes, privacy, review, and troubleshooting. description: Frequently asked questions about Agent Teams — pricing, model access, runtimes, privacy, review, and troubleshooting.
--- ---
---
title: FAQ Agent Teams Docs
description: Frequently asked questions about pricing, model access, runtime setup, data privacy, worktree isolation, and code review.
---
---
title: FAQ Agent Teams Docs
description: Frequently asked questions about Agent Teams — pricing, model access, runtimes, privacy, review, and debugging.
---
# FAQ # FAQ
## Is Agent Teams free? ## Is Agent Teams free?

View file

@ -1,18 +1,8 @@
---
title: Privacy and Local Data
description: What the Agent Teams desktop app stores locally and what data may leave your machine through provider-backed models.
---
--- ---
title: Privacy and Local Data Agent Teams Docs title: Privacy and Local Data Agent Teams Docs
description: What Agent Teams stores locally, what may leave your machine through provider-backed model calls, and practical privacy guidance. description: What Agent Teams stores locally, what may leave your machine through provider-backed model calls, and practical privacy guidance.
--- ---
---
title: Privacy and Local Data Agent Teams Docs
description: What the Agent Teams desktop app stores locally and what data may leave your machine through provider-backed model calls.
---
# Privacy and Local Data # Privacy and Local Data
Agent Teams is local-first, but the selected runtime/provider path still matters. This page describes what the desktop app stores locally and what may leave your machine when agents call provider-backed models. Agent Teams is local-first, but the selected runtime/provider path still matters. This page describes what the desktop app stores locally and what may leave your machine when agents call provider-backed models.

View file

@ -1,18 +1,8 @@
---
title: Providers and Runtimes
description: Supported runtime paths, provider ids, model ids, multi-provider strategy, and capability checks in Agent Teams.
---
--- ---
title: Providers and Runtimes Agent Teams Docs title: Providers and Runtimes Agent Teams Docs
description: Supported runtime paths (Claude Code, Codex, OpenCode), provider IDs, model naming, multi-provider strategies, and capability checks. description: Supported runtime paths (Claude Code, Codex, OpenCode), provider IDs, model naming, multi-provider strategies, and capability checks.
--- ---
---
title: Providers and Runtimes Agent Teams Docs
description: Supported runtime paths, provider ids, model ids, multi-provider strategy, and capability checks in Agent Teams.
---
# Providers and Runtimes # Providers and Runtimes
Agent Teams separates orchestration from model access. The app manages teams, tasks, messages, launch state, and review UI; the selected runtime/provider path performs the actual model work. Agent Teams separates orchestration from model access. The app manages teams, tasks, messages, launch state, and review UI; the selected runtime/provider path performs the actual model work.
@ -43,7 +33,7 @@ The runtime provides:
## Supported runtime paths ## Supported runtime paths
| Runtime path | Provider/model path | Best fit | Notes | | Runtime path | Provider/model path | Best fit | Notes |
| --- | --- | | --- | --- | --- | --- |
| Claude Code | Anthropic / Claude models | Claude Code users and Anthropic-backed workflows | Default local-first path for Claude teams. Requires the runtime and account access to be available locally. | | Claude Code | Anthropic / Claude models | Claude Code users and Anthropic-backed workflows | Default local-first path for Claude teams. Requires the runtime and account access to be available locally. |
| Codex | Codex / OpenAI-backed models | Codex-native workflows | Uses Codex runtime integration and Codex auth/account state where available. Some diagnostics are different from Claude transcripts. | | Codex | Codex / OpenAI-backed models | Codex-native workflows | Uses Codex runtime integration and Codex auth/account state where available. Some diagnostics are different from Claude transcripts. |
| OpenCode | OpenCode-managed model routing | Multi-provider teams and broad model coverage | OpenCode can route through many model providers. Agent Teams treats OpenCode lanes as runtime-specific evidence and avoids guessing when lane identity is ambiguous. | | OpenCode | OpenCode-managed model routing | Multi-provider teams and broad model coverage | OpenCode can route through many model providers. Agent Teams treats OpenCode lanes as runtime-specific evidence and avoids guessing when lane identity is ambiguous. |

View file

@ -29,6 +29,7 @@
"team:prove-agent-cli-launch": "node ./scripts/prove-agent-cli-launch.mjs", "team:prove-agent-cli-launch": "node ./scripts/prove-agent-cli-launch.mjs",
"team:prove-provider-launch-stress": "node ./scripts/prove-provider-launch-stress.mjs", "team:prove-provider-launch-stress": "node ./scripts/prove-provider-launch-stress.mjs",
"team:prove-launch-matrix": "pnpm exec vitest run --maxWorkers 1 --minWorkers 1 test/main/services/team/TeamAgentLaunchMatrix.safe-e2e.test.ts", "team:prove-launch-matrix": "pnpm exec vitest run --maxWorkers 1 --minWorkers 1 test/main/services/team/TeamAgentLaunchMatrix.safe-e2e.test.ts",
"team:smoke-changes-real-data": "tsx scripts/team-changes-real-data-smoke.ts",
"prebuild": "tsx scripts/fetch-pricing-data.ts && pnpm --filter agent-teams-controller build && pnpm --filter agent-teams-mcp build", "prebuild": "tsx scripts/fetch-pricing-data.ts && pnpm --filter agent-teams-controller build && pnpm --filter agent-teams-mcp build",
"build": "node --max-old-space-size=8192 ./node_modules/electron-vite/bin/electron-vite.js build", "build": "node --max-old-space-size=8192 ./node_modules/electron-vite/bin/electron-vite.js build",
"dist": "electron-builder --mac --win --linux", "dist": "electron-builder --mac --win --linux",
@ -110,7 +111,7 @@
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@fastify/cors": "^11.2.0", "@fastify/cors": "^11.2.0",
"@fastify/static": "^9.0.0", "@fastify/static": "^9.1.3",
"@floating-ui/dom": "^1.7.6", "@floating-ui/dom": "^1.7.6",
"@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-checkbox": "^1.3.3",
@ -126,7 +127,7 @@
"@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-tooltip": "^1.2.8",
"@sentry/electron": "^7.10.0", "@sentry/electron": "^7.10.0",
"@sentry/react": "^10.45.0", "@sentry/react": "^10.45.0",
"@tanstack/react-virtual": "^3.10.8", "@tanstack/react-virtual": "^3.13.24",
"@tiptap/extension-placeholder": "^3.20.4", "@tiptap/extension-placeholder": "^3.20.4",
"@tiptap/markdown": "^3.20.4", "@tiptap/markdown": "^3.20.4",
"@tiptap/pm": "^3.20.4", "@tiptap/pm": "^3.20.4",
@ -146,7 +147,7 @@
"diff": "^8.0.3", "diff": "^8.0.3",
"dompurify": "^3.3.1", "dompurify": "^3.3.1",
"electron-updater": "^6.7.3", "electron-updater": "^6.7.3",
"fastify": "^5.7.4", "fastify": "^5.8.5",
"highlight.js": "^11.11.1", "highlight.js": "^11.11.1",
"idb-keyval": "^6.2.2", "idb-keyval": "^6.2.2",
"isbinaryfile": "^6.0.0", "isbinaryfile": "^6.0.0",
@ -169,7 +170,7 @@
"rehype-sanitize": "^6.0.0", "rehype-sanitize": "^6.0.0",
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
"remark-parse": "^11.0.0", "remark-parse": "^11.0.0",
"simple-git": "^3.32.3", "simple-git": "^3.36.0",
"ssh-config": "^5.0.4", "ssh-config": "^5.0.4",
"ssh2": "^1.17.0", "ssh2": "^1.17.0",
"strip-markdown": "^6.0.0", "strip-markdown": "^6.0.0",
@ -196,7 +197,7 @@
"@vitejs/plugin-react": "^4.3.1", "@vitejs/plugin-react": "^4.3.1",
"@vitest/coverage-v8": "^3.1.4", "@vitest/coverage-v8": "^3.1.4",
"autoprefixer": "^10.4.17", "autoprefixer": "^10.4.17",
"electron": "^40.3.0", "electron": "^40.10.0",
"electron-builder": "^26.8.1", "electron-builder": "^26.8.1",
"electron-vite": "^5.0.0", "electron-vite": "^5.0.0",
"eslint": "^9.39.2", "eslint": "^9.39.2",

View file

@ -11,6 +11,10 @@ import { drawPillShell, drawPillStackLayer } from './draw-pill-shell';
import { hexWithAlpha } from './render-cache'; import { hexWithAlpha } from './render-cache';
import type { KanbanZoneInfo } from '../layout/kanbanLayout'; import type { KanbanZoneInfo } from '../layout/kanbanLayout';
const KANBAN_HEADER_FONT = '600 10px monospace';
const KANBAN_HEADER_ALPHA = 0.92;
const KANBAN_HEADER_LETTER_SPACING = 2;
/** /**
* Draw all task nodes as pill-shaped cards. * Draw all task nodes as pill-shaped cards.
*/ */
@ -159,9 +163,9 @@ function drawTaskPill(
const hasReviewChip = const hasReviewChip =
node.reviewState !== 'approved' && node.reviewState !== 'approved' &&
(node.reviewMode === 'manual' || (node.reviewMode === 'assigned' && !!node.reviewerName)); (node.reviewMode === 'manual' || (node.reviewMode === 'assigned' && !!node.reviewerName));
const maxW = hasReviewChip ? w - 64 : w - 18; const maxW = hasReviewChip ? w - 88 : w - 24;
const subject = truncateText(ctx, node.sublabel, maxW, ctx.font); const subject = truncateText(ctx, node.sublabel, maxW, ctx.font);
ctx.fillText(subject, textX, -4); ctx.fillText(subject, textX, -12);
} }
// Display ID (secondary — small) // Display ID (secondary — small)
@ -170,11 +174,11 @@ function drawTaskPill(
ctx.textAlign = 'left'; ctx.textAlign = 'left';
ctx.textBaseline = 'middle'; ctx.textBaseline = 'middle';
ctx.fillStyle = COLORS.textDim; ctx.fillStyle = COLORS.textDim;
ctx.fillText(displayId, -halfW + 10, 8); ctx.fillText(displayId, -halfW + 10, 12);
// Approved badge: checkmark at right side // Approved badge: checkmark at right side
if (node.reviewState === 'approved') { if (node.reviewState === 'approved') {
ctx.font = 'bold 11px sans-serif'; ctx.font = 'bold 13px sans-serif';
ctx.textAlign = 'right'; ctx.textAlign = 'right';
ctx.textBaseline = 'middle'; ctx.textBaseline = 'middle';
ctx.fillStyle = COLORS.reviewApproved; ctx.fillStyle = COLORS.reviewApproved;
@ -367,11 +371,11 @@ function drawOverflowStack(
ctx.textAlign = 'left'; ctx.textAlign = 'left';
ctx.textBaseline = 'middle'; ctx.textBaseline = 'middle';
ctx.fillStyle = COLORS.textPrimary; ctx.fillStyle = COLORS.textPrimary;
ctx.fillText(node.label, -halfW + 12, -2); ctx.fillText(node.label, -halfW + 14, -8);
ctx.font = '7px monospace'; ctx.font = '10px monospace';
ctx.fillStyle = COLORS.textDim; ctx.fillStyle = COLORS.textDim;
ctx.fillText('more tasks', -halfW + 12, 10); ctx.fillText('more tasks', -halfW + 14, 12);
} }
function drawReviewChip( function drawReviewChip(
@ -413,6 +417,34 @@ function drawReviewChip(
} }
} }
function measureSpacedText(
ctx: CanvasRenderingContext2D,
text: string,
letterSpacing: number
): number {
const chars = Array.from(text);
const glyphWidth = chars.reduce((width, char) => width + ctx.measureText(char).width, 0);
return glyphWidth + Math.max(0, chars.length - 1) * letterSpacing;
}
function drawCenteredSpacedText(
ctx: CanvasRenderingContext2D,
text: string,
x: number,
y: number,
letterSpacing: number
): void {
const chars = Array.from(text);
const previousAlign = ctx.textAlign;
ctx.textAlign = 'left';
let cursorX = x - measureSpacedText(ctx, text, letterSpacing) / 2;
for (const char of chars) {
ctx.fillText(char, cursorX, y);
cursorX += ctx.measureText(char).width + letterSpacing;
}
ctx.textAlign = previousAlign;
}
/** /**
* Draw kanban column headers above task columns. * Draw kanban column headers above task columns.
*/ */
@ -425,12 +457,12 @@ export function drawColumnHeaders(
for (const zone of zones) { for (const zone of zones) {
// Section header for unassigned tasks — larger, centered above all columns // Section header for unassigned tasks — larger, centered above all columns
if (zone.ownerId === '__unassigned__') { if (zone.ownerId === '__unassigned__') {
ctx.font = 'bold 10px monospace'; ctx.font = KANBAN_HEADER_FONT;
ctx.textAlign = 'center'; ctx.textAlign = 'center';
ctx.textBaseline = 'bottom'; ctx.textBaseline = 'middle';
ctx.fillStyle = hexWithAlpha(COLORS.taskPending, 0.5); ctx.fillStyle = hexWithAlpha(COLORS.taskPending, KANBAN_HEADER_ALPHA);
const labelY = (zone.headers[0]?.y ?? zone.ownerY + 60) - 16; const labelY = (zone.headers[0]?.y ?? zone.ownerY + 60) + 10;
ctx.fillText('Unassigned', zone.ownerX, labelY); drawCenteredSpacedText(ctx, 'Unassigned', zone.ownerX, labelY, KANBAN_HEADER_LETTER_SPACING);
// Overflow badge // Overflow badge
for (const header of zone.headers) { for (const header of zone.headers) {
@ -446,16 +478,22 @@ export function drawColumnHeaders(
} }
for (const header of zone.headers) { for (const header of zone.headers) {
ctx.font = 'bold 8px monospace'; ctx.font = KANBAN_HEADER_FONT;
ctx.textAlign = 'center'; ctx.textAlign = 'center';
ctx.textBaseline = 'bottom'; ctx.textBaseline = 'middle';
ctx.fillStyle = hexWithAlpha(header.color, 0.6); ctx.fillStyle = hexWithAlpha(header.color, KANBAN_HEADER_ALPHA);
ctx.fillText(header.label, header.x, header.y - 2); drawCenteredSpacedText(
ctx,
header.label,
header.x,
header.y + 10,
KANBAN_HEADER_LETTER_SPACING
);
// Overflow badge: "+N more" // Overflow badge: "+N more"
if (header.overflowCount > 0) { if (header.overflowCount > 0) {
const badgeText = `+${header.overflowCount} more`; const badgeText = `+${header.overflowCount} more`;
ctx.font = '7px monospace'; ctx.font = '10px monospace';
ctx.textAlign = 'center'; ctx.textAlign = 'center';
ctx.textBaseline = 'top'; ctx.textBaseline = 'top';
ctx.fillStyle = hexWithAlpha(header.color, 0.45); ctx.fillStyle = hexWithAlpha(header.color, 0.45);

View file

@ -70,19 +70,19 @@ export const NODE = {
// ─── Task pill dimensions ─────────────────────────────────────────────────── // ─── Task pill dimensions ───────────────────────────────────────────────────
export const TASK_PILL = { export const TASK_PILL = {
width: 160, width: 260,
height: 36, height: 72,
borderRadius: 6, borderRadius: 8,
statusDotRadius: 4, statusDotRadius: 4,
statusDotX: 12, statusDotX: 12,
/** Font size for display ID */ /** Font size for the task title */
idFontSize: 9, idFontSize: 16.5,
/** Font size for subject text */ /** Font size for the display ID */
subjectFontSize: 7, subjectFontSize: 10,
/** Max chars for subject before truncation */ /** Max chars for subject before truncation */
subjectMaxChars: 18, subjectMaxChars: 32,
/** X offset for text content */ /** X offset for text content */
textOffsetX: 20, textOffsetX: 18,
} as const; } as const;
// ─── Agent drawing constants ──────────────────────────────────────────────── // ─── Agent drawing constants ────────────────────────────────────────────────
@ -259,12 +259,12 @@ export const BACKGROUND = {
// ─── Kanban zone layout ───────────────────────────────────────────────────── // ─── Kanban zone layout ─────────────────────────────────────────────────────
export const KANBAN_ZONE = { export const KANBAN_ZONE = {
/** Column width: pill (160) + gap (20) */ /** Column width: task card (260) + gap (20) */
columnWidth: 180, columnWidth: 280,
/** Row height: pill (36) + gap (10) */ /** Row height: task card (72) + gap (8) */
rowHeight: 46, rowHeight: 80,
/** Space reserved for column header label */ /** Task center offset from band top: header (20) + gap (4) + half card */
headerHeight: 20, headerHeight: 60,
/** Zone starts this far below member node center */ /** Zone starts this far below member node center */
offsetY: 70, offsetY: 70,
/** Column sequence: pending → wip → done → review → approved */ /** Column sequence: pending → wip → done → review → approved */

View file

@ -217,8 +217,16 @@ export class KanbanLayoutEngine {
for (const [rowIdx, task] of col.tasks.entries()) { for (const [rowIdx, task] of col.tasks.entries()) {
const targetX = colX; const targetX = colX;
const targetY = baseY + headerHeight + rowIdx * rowHeight; const targetY = baseY + headerHeight + rowIdx * rowHeight;
task.x = slotFrame ? targetX : task.x != null ? task.x + (targetX - task.x) * 0.15 : targetX; task.x = slotFrame
task.y = slotFrame ? targetY : task.y != null ? task.y + (targetY - task.y) * 0.15 : targetY; ? targetX
: task.x != null
? task.x + (targetX - task.x) * 0.15
: targetX;
task.y = slotFrame
? targetY
: task.y != null
? task.y + (targetY - task.y) * 0.15
: targetY;
task.fx = task.x; task.fx = task.x;
task.fy = task.y; task.fy = task.y;
task.vx = 0; task.vx = 0;
@ -254,18 +262,19 @@ export class KanbanLayoutEngine {
if (unassignedTaskRect) { if (unassignedTaskRect) {
const cols = Math.min(Math.max(tasks.length, 1), 5); const cols = Math.min(Math.max(tasks.length, 1), 5);
const baseX = unassignedTaskRect.left + TASK_PILL.width / 2; const baseX = unassignedTaskRect.left + TASK_PILL.width / 2;
const baseY = unassignedTaskRect.top; const headerY = unassignedTaskRect.top;
const baseY = headerY + KANBAN_ZONE.headerHeight;
const overflowCount = tasks.reduce((sum, task) => sum + (task.overflowCount ?? 0), 0); const overflowCount = tasks.reduce((sum, task) => sum + (task.overflowCount ?? 0), 0);
this.zones.push({ this.zones.push({
ownerId: '__unassigned__', ownerId: '__unassigned__',
ownerX: 0, ownerX: 0,
ownerY: baseY - 48, ownerY: headerY - 48,
headers: [ headers: [
{ {
label: 'Unassigned', label: 'Unassigned',
x: 0, x: 0,
y: baseY, y: headerY,
color: COLORS.taskPending, color: COLORS.taskPending,
overflowCount, overflowCount,
overflowY: baseY + KANBAN_ZONE.maxVisibleRows * rowHeight, overflowY: baseY + KANBAN_ZONE.maxVisibleRows * rowHeight,
@ -305,7 +314,8 @@ export class KanbanLayoutEngine {
const centerX = memberCount > 0 ? sumX / memberCount : 0; const centerX = memberCount > 0 ? sumX / memberCount : 0;
// Place unassigned tasks well below the lowest element // Place unassigned tasks well below the lowest element
const baseY = (maxY > -Infinity ? maxY : 0) + 150; const headerY = (maxY > -Infinity ? maxY : 0) + 150;
const baseY = headerY + KANBAN_ZONE.headerHeight;
const cols = Math.min(tasks.length, 4); const cols = Math.min(tasks.length, 4);
const totalWidth = cols * columnWidth; const totalWidth = cols * columnWidth;
const baseX = centerX - totalWidth / 2; const baseX = centerX - totalWidth / 2;
@ -316,15 +326,17 @@ export class KanbanLayoutEngine {
this.zones.push({ this.zones.push({
ownerId: '__unassigned__', ownerId: '__unassigned__',
ownerX: centerX, ownerX: centerX,
ownerY: baseY - 70, ownerY: headerY - 70,
headers: [{ headers: [
{
label: 'Unassigned', label: 'Unassigned',
x: centerX, x: centerX,
y: baseY - 10, y: headerY,
color: COLORS.taskPending, color: COLORS.taskPending,
overflowCount, overflowCount,
overflowY: baseY + KANBAN_ZONE.maxVisibleRows * rowHeight, overflowY: baseY + KANBAN_ZONE.maxVisibleRows * rowHeight,
}], },
],
}); });
} }

View file

@ -14,7 +14,7 @@ export const STABLE_SLOT_GEOMETRY = {
ownerMinWidth: 200, ownerMinWidth: 200,
processBandHeight: 32, processBandHeight: 32,
processRailWidth: 220, processRailWidth: 220,
taskMaxVisibleRows: 5, taskMaxVisibleRows: 3,
} as const; } as const;
export const STABLE_SLOT_SECTOR_VECTORS = [ export const STABLE_SLOT_SECTOR_VECTORS = [

View file

@ -102,8 +102,8 @@ importers:
specifier: ^11.2.0 specifier: ^11.2.0
version: 11.2.0 version: 11.2.0
'@fastify/static': '@fastify/static':
specifier: ^9.0.0 specifier: ^9.1.3
version: 9.0.0 version: 9.1.3
'@floating-ui/dom': '@floating-ui/dom':
specifier: ^1.7.6 specifier: ^1.7.6
version: 1.7.6 version: 1.7.6
@ -150,8 +150,8 @@ importers:
specifier: ^10.45.0 specifier: ^10.45.0
version: 10.45.0(react@19.2.4) version: 10.45.0(react@19.2.4)
'@tanstack/react-virtual': '@tanstack/react-virtual':
specifier: ^3.10.8 specifier: ^3.13.24
version: 3.13.18(react-dom@19.2.4(react@19.2.4))(react@19.2.4) version: 3.13.24(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@tiptap/extension-placeholder': '@tiptap/extension-placeholder':
specifier: ^3.20.4 specifier: ^3.20.4
version: 3.20.4(@tiptap/extensions@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)) version: 3.20.4(@tiptap/extensions@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4))
@ -210,8 +210,8 @@ importers:
specifier: ^6.7.3 specifier: ^6.7.3
version: 6.7.3 version: 6.7.3
fastify: fastify:
specifier: ^5.7.4 specifier: ^5.8.5
version: 5.7.4 version: 5.8.5
highlight.js: highlight.js:
specifier: ^11.11.1 specifier: ^11.11.1
version: 11.11.1 version: 11.11.1
@ -279,8 +279,8 @@ importers:
specifier: ^11.0.0 specifier: ^11.0.0
version: 11.0.0 version: 11.0.0
simple-git: simple-git:
specifier: ^3.32.3 specifier: ^3.36.0
version: 3.32.3 version: 3.36.0
ssh-config: ssh-config:
specifier: ^5.0.4 specifier: ^5.0.4
version: 5.0.4 version: 5.0.4
@ -355,8 +355,8 @@ importers:
specifier: ^10.4.17 specifier: ^10.4.17
version: 10.4.23(postcss@8.5.6) version: 10.4.23(postcss@8.5.6)
electron: electron:
specifier: ^40.3.0 specifier: ^40.10.0
version: 40.3.0 version: 40.10.0
electron-builder: electron-builder:
specifier: ^26.8.1 specifier: ^26.8.1
version: 26.8.1(electron-builder-squirrel-windows@26.8.1) version: 26.8.1(electron-builder-squirrel-windows@26.8.1)
@ -1714,8 +1714,8 @@ packages:
'@fastify/send@4.1.0': '@fastify/send@4.1.0':
resolution: {integrity: sha512-TMYeQLCBSy2TOFmV95hQWkiTYgC/SEx7vMdV+wnZVX4tt8VBLKzmH8vV9OzJehV0+XBfg+WxPMt5wp+JBUKsVw==} resolution: {integrity: sha512-TMYeQLCBSy2TOFmV95hQWkiTYgC/SEx7vMdV+wnZVX4tt8VBLKzmH8vV9OzJehV0+XBfg+WxPMt5wp+JBUKsVw==}
'@fastify/static@9.0.0': '@fastify/static@9.1.3':
resolution: {integrity: sha512-r64H8Woe/vfilg5RTy7lwWlE8ZZcTrc3kebYFMEUBrMqlydhQyoiExQXdYAy2REVpST/G35+stAM8WYp1WGmMA==} resolution: {integrity: sha512-aXrYtsiryLhRxRNaxNqsn7FUISeb7rB9q4eHUPIot5aeQBLNahnz1m6thzm7JWC1poSGXS9XrX8DvuMivp2hkQ==}
'@floating-ui/core@1.7.5': '@floating-ui/core@1.7.5':
resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==} resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==}
@ -4077,6 +4077,12 @@ packages:
'@shikijs/vscode-textmate@10.0.2': '@shikijs/vscode-textmate@10.0.2':
resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==}
'@simple-git/args-pathspec@1.0.3':
resolution: {integrity: sha512-ngJMaHlsWDTfjyq9F3VIQ8b7NXbBLq5j9i5bJ6XLYtD6qlDXT7fdKY2KscWWUF8t18xx052Y/PUO1K1TRc9yKA==}
'@simple-git/argv-parser@1.1.1':
resolution: {integrity: sha512-Q9lBcfQ+VQCpQqGJFHe5yooOS5hGdLFFbJ5R+R5aDsnkPCahtn1hSkMcORX65J2Z5lxSkD0lQorMsncuBQxYUw==}
'@sindresorhus/base62@1.0.0': '@sindresorhus/base62@1.0.0':
resolution: {integrity: sha512-TeheYy0ILzBEI/CO55CP6zJCSdSWeRtGnHy8U8dWSUH4I68iqTsy7HkMktR4xakThc9jotkPQUXT4ITdbV7cHA==} resolution: {integrity: sha512-TeheYy0ILzBEI/CO55CP6zJCSdSWeRtGnHy8U8dWSUH4I68iqTsy7HkMktR4xakThc9jotkPQUXT4ITdbV7cHA==}
engines: {node: '>=18'} engines: {node: '>=18'}
@ -4114,14 +4120,14 @@ packages:
peerDependencies: peerDependencies:
tailwindcss: '>=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1' tailwindcss: '>=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1'
'@tanstack/react-virtual@3.13.18': '@tanstack/react-virtual@3.13.24':
resolution: {integrity: sha512-dZkhyfahpvlaV0rIKnvQiVoWPyURppl6w4m9IwMDpuIjcJ1sD9YGWrt0wISvgU7ewACXx2Ct46WPgI6qAD4v6A==} resolution: {integrity: sha512-aIJvz5OSkhNIhZIpYivrxrPTKYsjW9Uzy+sP/mx0S3sev2HyvPb7xmjbYvokzEpfgYHy/HjzJ2zFAETuUfgCpg==}
peerDependencies: peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
'@tanstack/virtual-core@3.13.18': '@tanstack/virtual-core@3.14.0':
resolution: {integrity: sha512-Mx86Hqu1k39icq2Zusq+Ey2J6dDWTjDvEv43PJtRCoEYTLyfaPnxIQ6iy7YAOK0NV/qOEmZQ/uCufrppZxTgcg==} resolution: {integrity: sha512-JLANqGy/D6k4Ujmh8Tr25lGimuOXNiaVyXaCAZS0W+1390sADdGnyUdSWNIfd49gebtIxGMij4IktRVzrdr12Q==}
'@tiptap/core@3.20.4': '@tiptap/core@3.20.4':
resolution: {integrity: sha512-3i/DG89TFY/b34T5P+j35UcjYuB5d3+9K8u6qID+iUqNPiza015HPIZLuPfE5elNwVdV3EXIoPo0LLeBLgXXAg==} resolution: {integrity: sha512-3i/DG89TFY/b34T5P+j35UcjYuB5d3+9K8u6qID+iUqNPiza015HPIZLuPfE5elNwVdV3EXIoPo0LLeBLgXXAg==}
@ -6300,8 +6306,8 @@ packages:
resolution: {integrity: sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg==} resolution: {integrity: sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg==}
engines: {node: '>=8.0.0'} engines: {node: '>=8.0.0'}
electron@40.3.0: electron@40.10.0:
resolution: {integrity: sha512-ZaDkTZpNHr863tyZHieoqbaiLI0e3RVCXoEC5y1Ld70/Q5H1mPV9d5TK0h1dWtaSFVOW0w8iDvtdLwAXtasXpg==} resolution: {integrity: sha512-e7XVcAfyWoFQGS7ZhgxeNn0AijHaqgRCa6uA6TYOrvBWv8smI6JILvMR/8DYBIn07oqvxDLRC90tu/xa2cJCow==}
engines: {node: '>= 12.20.55'} engines: {node: '>= 12.20.55'}
hasBin: true hasBin: true
@ -6815,8 +6821,8 @@ packages:
fastify-plugin@5.1.0: fastify-plugin@5.1.0:
resolution: {integrity: sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==} resolution: {integrity: sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==}
fastify@5.7.4: fastify@5.8.5:
resolution: {integrity: sha512-e6l5NsRdaEP8rdD8VR0ErJASeyaRbzXYpmkrpr2SuvuMq6Si3lvsaVy5C+7gLanEkvjpMDzBXWE5HPeb/hgTxA==} resolution: {integrity: sha512-Yqptv59pQzPgQUSIm87hMqHJmdkb1+GPxdE6vW6FRyVE9G86mt7rOghitiU4JHRaTyDUk9pfeKmDeu70lAwM4Q==}
fastmcp@3.34.0: fastmcp@3.34.0:
resolution: {integrity: sha512-xKOXjU+MK7OZy91BY3FS5aenSiclJBCRMaZtXb3HYaKZVFbq4qYvAlFu6xYI3UU1NGLtv+h8izoStnOQ1By0BA==} resolution: {integrity: sha512-xKOXjU+MK7OZy91BY3FS5aenSiclJBCRMaZtXb3HYaKZVFbq4qYvAlFu6xYI3UU1NGLtv+h8izoStnOQ1By0BA==}
@ -7092,10 +7098,6 @@ packages:
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
hasBin: true hasBin: true
glob@13.0.2:
resolution: {integrity: sha512-035InabNu/c1lW0tzPhAgapKctblppqsKKG9ZaNzbr+gXwWMjXoiyGSyB9sArzrjG7jY+zntRq5ZSUYemrnWVQ==}
engines: {node: 20 || >=22}
glob@13.0.6: glob@13.0.6:
resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==}
engines: {node: 18 || 20 || >=22} engines: {node: 18 || 20 || >=22}
@ -8319,10 +8321,6 @@ packages:
resolution: {integrity: sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==} resolution: {integrity: sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==}
engines: {node: '>=8'} engines: {node: '>=8'}
minipass@7.1.2:
resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==}
engines: {node: '>=16 || 14 >=14.17'}
minipass@7.1.3: minipass@7.1.3:
resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==}
engines: {node: '>=16 || 14 >=14.17'} engines: {node: '>=16 || 14 >=14.17'}
@ -8743,10 +8741,6 @@ packages:
resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==}
engines: {node: '>=16 || 14 >=14.18'} engines: {node: '>=16 || 14 >=14.18'}
path-scurry@2.0.1:
resolution: {integrity: sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==}
engines: {node: 20 || >=22}
path-scurry@2.0.2: path-scurry@2.0.2:
resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==}
engines: {node: 18 || 20 || >=22} engines: {node: 18 || 20 || >=22}
@ -9820,12 +9814,12 @@ packages:
resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
engines: {node: '>=14'} engines: {node: '>=14'}
simple-git@3.32.3:
resolution: {integrity: sha512-56a5oxFdWlsGygOXHWrG+xjj5w9ZIt2uQbzqiIGdR/6i5iococ7WQ/bNPzWxCJdEUGUCmyMH0t9zMpRJTaKxmw==}
simple-git@3.33.0: simple-git@3.33.0:
resolution: {integrity: sha512-D4V/tGC2sjsoNhoMybKyGoE+v8A60hRawKQ1iFRA1zwuDgGZCBJ4ByOzZ5J8joBbi4Oam0qiPH+GhzmSBwbJng==} resolution: {integrity: sha512-D4V/tGC2sjsoNhoMybKyGoE+v8A60hRawKQ1iFRA1zwuDgGZCBJ4ByOzZ5J8joBbi4Oam0qiPH+GhzmSBwbJng==}
simple-git@3.36.0:
resolution: {integrity: sha512-cGQjLjK8bxJw4QuYT7gxHw3/IouVESbhahSsHrX97MzCL1gu2u7oy38W6L2ZIGECEfIBG4BabsWDPjBxJENv9Q==}
simple-update-notifier@2.0.0: simple-update-notifier@2.0.0:
resolution: {integrity: sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==} resolution: {integrity: sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==}
engines: {node: '>=10'} engines: {node: '>=10'}
@ -12415,14 +12409,14 @@ snapshots:
http-errors: 2.0.1 http-errors: 2.0.1
mime: 3.0.0 mime: 3.0.0
'@fastify/static@9.0.0': '@fastify/static@9.1.3':
dependencies: dependencies:
'@fastify/accept-negotiator': 2.0.1 '@fastify/accept-negotiator': 2.0.1
'@fastify/send': 4.1.0 '@fastify/send': 4.1.0
content-disposition: 1.0.1 content-disposition: 1.0.1
fastify-plugin: 5.1.0 fastify-plugin: 5.1.0
fastq: 1.20.1 fastq: 1.20.1
glob: 13.0.2 glob: 13.0.6
'@floating-ui/core@1.7.5': '@floating-ui/core@1.7.5':
dependencies: dependencies:
@ -14849,6 +14843,12 @@ snapshots:
'@shikijs/vscode-textmate@10.0.2': {} '@shikijs/vscode-textmate@10.0.2': {}
'@simple-git/args-pathspec@1.0.3': {}
'@simple-git/argv-parser@1.1.1':
dependencies:
'@simple-git/args-pathspec': 1.0.3
'@sindresorhus/base62@1.0.0': {} '@sindresorhus/base62@1.0.0': {}
'@sindresorhus/is@4.6.0': {} '@sindresorhus/is@4.6.0': {}
@ -14880,13 +14880,13 @@ snapshots:
postcss-selector-parser: 6.0.10 postcss-selector-parser: 6.0.10
tailwindcss: 3.4.19(tsx@4.21.0)(yaml@2.8.2) tailwindcss: 3.4.19(tsx@4.21.0)(yaml@2.8.2)
'@tanstack/react-virtual@3.13.18(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': '@tanstack/react-virtual@3.13.24(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies: dependencies:
'@tanstack/virtual-core': 3.13.18 '@tanstack/virtual-core': 3.14.0
react: 19.2.4 react: 19.2.4
react-dom: 19.2.4(react@19.2.4) react-dom: 19.2.4(react@19.2.4)
'@tanstack/virtual-core@3.13.18': {} '@tanstack/virtual-core@3.14.0': {}
'@tiptap/core@3.20.4(@tiptap/pm@3.20.4)': '@tiptap/core@3.20.4(@tiptap/pm@3.20.4)':
dependencies: dependencies:
@ -15519,7 +15519,7 @@ snapshots:
'@typescript-eslint/types': 8.57.1 '@typescript-eslint/types': 8.57.1
'@typescript-eslint/visitor-keys': 8.57.1 '@typescript-eslint/visitor-keys': 8.57.1
debug: 4.4.3 debug: 4.4.3
minimatch: 10.2.2 minimatch: 10.2.5
semver: 7.7.4 semver: 7.7.4
tinyglobby: 0.2.15 tinyglobby: 0.2.15
ts-api-utils: 2.4.0(typescript@5.9.3) ts-api-utils: 2.4.0(typescript@5.9.3)
@ -17415,7 +17415,7 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
electron@40.3.0: electron@40.10.0:
dependencies: dependencies:
'@electron/get': 2.0.3 '@electron/get': 2.0.3
'@types/node': 24.10.12 '@types/node': 24.10.12
@ -17787,7 +17787,7 @@ snapshots:
eslint: 9.39.2(jiti@1.21.7) eslint: 9.39.2(jiti@1.21.7)
eslint-import-context: 0.1.9(unrs-resolver@1.11.1) eslint-import-context: 0.1.9(unrs-resolver@1.11.1)
is-glob: 4.0.3 is-glob: 4.0.3
minimatch: 10.2.2 minimatch: 10.2.5
semver: 7.7.4 semver: 7.7.4
stable-hash-x: 0.2.0 stable-hash-x: 0.2.0
unrs-resolver: 1.11.1 unrs-resolver: 1.11.1
@ -17807,7 +17807,7 @@ snapshots:
eslint: 9.39.2(jiti@2.6.1) eslint: 9.39.2(jiti@2.6.1)
eslint-import-context: 0.1.9(unrs-resolver@1.11.1) eslint-import-context: 0.1.9(unrs-resolver@1.11.1)
is-glob: 4.0.3 is-glob: 4.0.3
minimatch: 10.2.2 minimatch: 10.2.5
semver: 7.7.4 semver: 7.7.4
stable-hash-x: 0.2.0 stable-hash-x: 0.2.0
unrs-resolver: 1.11.1 unrs-resolver: 1.11.1
@ -18309,7 +18309,7 @@ snapshots:
fastify-plugin@5.1.0: {} fastify-plugin@5.1.0: {}
fastify@5.7.4: fastify@5.8.5:
dependencies: dependencies:
'@fastify/ajv-compiler': 4.0.5 '@fastify/ajv-compiler': 4.0.5
'@fastify/error': 4.2.0 '@fastify/error': 4.2.0
@ -18324,7 +18324,7 @@ snapshots:
process-warning: 5.0.0 process-warning: 5.0.0
rfdc: 1.4.1 rfdc: 1.4.1
secure-json-parse: 4.1.0 secure-json-parse: 4.1.0
semver: 7.7.3 semver: 7.7.4
toad-cache: 3.7.0 toad-cache: 3.7.0
fastmcp@3.34.0: fastmcp@3.34.0:
@ -18630,15 +18630,9 @@ snapshots:
package-json-from-dist: 1.0.1 package-json-from-dist: 1.0.1
path-scurry: 1.11.1 path-scurry: 1.11.1
glob@13.0.2:
dependencies:
minimatch: 10.2.2
minipass: 7.1.2
path-scurry: 2.0.1
glob@13.0.6: glob@13.0.6:
dependencies: dependencies:
minimatch: 10.2.2 minimatch: 10.2.5
minipass: 7.1.3 minipass: 7.1.3
path-scurry: 2.0.2 path-scurry: 2.0.2
@ -20222,8 +20216,6 @@ snapshots:
dependencies: dependencies:
yallist: 4.0.0 yallist: 4.0.0
minipass@7.1.2: {}
minipass@7.1.3: {} minipass@7.1.3: {}
minisearch@7.2.0: {} minisearch@7.2.0: {}
@ -20941,11 +20933,6 @@ snapshots:
lru-cache: 10.4.3 lru-cache: 10.4.3
minipass: 7.1.3 minipass: 7.1.3
path-scurry@2.0.1:
dependencies:
lru-cache: 11.2.6
minipass: 7.1.2
path-scurry@2.0.2: path-scurry@2.0.2:
dependencies: dependencies:
lru-cache: 11.2.6 lru-cache: 11.2.6
@ -22138,7 +22125,7 @@ snapshots:
signal-exit@4.1.0: {} signal-exit@4.1.0: {}
simple-git@3.32.3: simple-git@3.33.0:
dependencies: dependencies:
'@kwsites/file-exists': 1.1.1 '@kwsites/file-exists': 1.1.1
'@kwsites/promise-deferred': 1.1.1 '@kwsites/promise-deferred': 1.1.1
@ -22146,10 +22133,12 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
simple-git@3.33.0: simple-git@3.36.0:
dependencies: dependencies:
'@kwsites/file-exists': 1.1.1 '@kwsites/file-exists': 1.1.1
'@kwsites/promise-deferred': 1.1.1 '@kwsites/promise-deferred': 1.1.1
'@simple-git/args-pathspec': 1.0.3
'@simple-git/argv-parser': 1.1.1
debug: 4.4.3 debug: 4.4.3
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color

View file

@ -1,27 +1,27 @@
{ {
"version": "0.0.29", "version": "0.0.30",
"sourceRef": "v0.0.29", "sourceRef": "v0.0.30",
"sourceRepository": "777genius/agent_teams_orchestrator", "sourceRepository": "777genius/agent_teams_orchestrator",
"releaseRepository": "777genius/agent-teams-ai", "releaseRepository": "777genius/agent-teams-ai",
"releaseTag": "v1.2.0", "releaseTag": "v1.2.0",
"assets": { "assets": {
"darwin-arm64": { "darwin-arm64": {
"file": "agent-teams-runtime-darwin-arm64-v0.0.29.tar.gz", "file": "agent-teams-runtime-darwin-arm64-v0.0.30.tar.gz",
"archiveKind": "tar.gz", "archiveKind": "tar.gz",
"binaryName": "claude-multimodel" "binaryName": "claude-multimodel"
}, },
"darwin-x64": { "darwin-x64": {
"file": "agent-teams-runtime-darwin-x64-v0.0.29.tar.gz", "file": "agent-teams-runtime-darwin-x64-v0.0.30.tar.gz",
"archiveKind": "tar.gz", "archiveKind": "tar.gz",
"binaryName": "claude-multimodel" "binaryName": "claude-multimodel"
}, },
"linux-x64": { "linux-x64": {
"file": "agent-teams-runtime-linux-x64-v0.0.29.tar.gz", "file": "agent-teams-runtime-linux-x64-v0.0.30.tar.gz",
"archiveKind": "tar.gz", "archiveKind": "tar.gz",
"binaryName": "claude-multimodel" "binaryName": "claude-multimodel"
}, },
"win32-x64": { "win32-x64": {
"file": "agent-teams-runtime-win32-x64-v0.0.29.zip", "file": "agent-teams-runtime-win32-x64-v0.0.30.zip",
"archiveKind": "zip", "archiveKind": "zip",
"binaryName": "claude-multimodel.exe" "binaryName": "claude-multimodel.exe"
} }

View file

@ -0,0 +1,488 @@
/* eslint-disable security/detect-non-literal-fs-filename, security/detect-object-injection */
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import type { TeamTask, TeamTaskChangeSummaryItem, TeamTaskWithKanban } from '../src/shared/types';
process.env.CLAUDE_TEAM_ENABLE_PERSISTED_TASK_CHANGE_CACHE = '0';
const FIRST_STAGE_REQUESTS = 3;
const SECOND_STAGE_REQUESTS = 9;
const FIRST_STAGE_UNKNOWN_SCAN_LIMIT = 3;
const SECOND_STAGE_UNKNOWN_SCAN_LIMIT = 6;
const DEFAULT_TEAM_LIMIT = 3;
interface Args {
teams: string[];
limit: number;
}
interface PresenceEntry {
presence?: string;
}
interface CandidateSuccess {
teamName: string;
tasks: TeamTaskWithKanban[];
taskCount: number;
changedPresenceCount: number;
eligibleCount: number;
presenceCounts: Record<string, number>;
}
interface CandidateFailure {
teamName: string;
error: string;
}
type Candidate = CandidateSuccess | CandidateFailure;
interface RuntimeModules {
TeamTaskReader: typeof import('../src/main/services/team/TeamTaskReader')['TeamTaskReader'];
ChangeExtractorService: typeof import('../src/main/services/team/ChangeExtractorService')['ChangeExtractorService'];
TeamMemberLogsFinder: typeof import('../src/main/services/team/TeamMemberLogsFinder')['TeamMemberLogsFinder'];
TaskBoundaryParser: typeof import('../src/main/services/team/TaskBoundaryParser')['TaskBoundaryParser'];
TaskChangeWorkerClient: typeof import('../src/main/services/team/TaskChangeWorkerClient')['TaskChangeWorkerClient'];
buildTeamChangeRequestPlan: typeof import('../src/renderer/components/team/teamChangesRequestPlan')['buildTeamChangeRequestPlan'];
TEAM_CHANGES_MAX_REQUESTS: typeof import('../src/renderer/components/team/teamChangesRequestPlan')['TEAM_CHANGES_MAX_REQUESTS'];
}
interface StageReport {
label: string;
requested: number;
duplicateRequests: string[];
responseItems: number;
truncated: boolean;
ms: number;
deferredBeforeResponse: number;
satisfiedAfterStage: number;
itemErrors: number;
nullItems: number;
countableItems: number;
fileRows: number;
confidenceCounts: Record<string, number>;
sourceKindCounts: Record<string, number>;
firstTaskIds: string[];
}
interface TeamSmokeReport {
kind: 'team-smoke';
teamName: string;
taskCount: number;
changedPresenceCount: number;
eligibleCount: number;
stages: StageReport[];
}
interface ForceRefreshSmokeReport {
kind: 'force-refresh-smoke';
teamName: string;
requested: number;
allForceFresh: boolean;
responseItems: number;
ms: number;
taskIds: string[];
}
function parseArgs(argv: string[]): Args {
const teams: string[] = [];
let limit = DEFAULT_TEAM_LIMIT;
let index = 0;
while (index < argv.length) {
const arg = argv[index];
const next = argv[index + 1] ?? '';
if (arg === '--team' || arg === '--teams') {
teams.push(...next.split(',').map((teamName) => teamName.trim()).filter(Boolean));
index += 2;
continue;
}
if (arg === '--limit') {
const parsedLimit = Number.parseInt(next, 10);
if (Number.isFinite(parsedLimit) && parsedLimit > 0) {
limit = parsedLimit;
}
index += 2;
continue;
}
index += 1;
}
return { teams: [...new Set(teams)], limit };
}
async function loadRuntimeModules(): Promise<RuntimeModules> {
const { TeamTaskReader } = await import('../src/main/services/team/TeamTaskReader');
const { ChangeExtractorService } = await import(
'../src/main/services/team/ChangeExtractorService'
);
const { TeamMemberLogsFinder } = await import('../src/main/services/team/TeamMemberLogsFinder');
const { TaskBoundaryParser } = await import('../src/main/services/team/TaskBoundaryParser');
const { TaskChangeWorkerClient } = await import(
'../src/main/services/team/TaskChangeWorkerClient'
);
const { buildTeamChangeRequestPlan, TEAM_CHANGES_MAX_REQUESTS } = await import(
'../src/renderer/components/team/teamChangesRequestPlan'
);
return {
TeamTaskReader,
ChangeExtractorService,
TeamMemberLogsFinder,
TaskBoundaryParser,
TaskChangeWorkerClient,
buildTeamChangeRequestPlan,
TEAM_CHANGES_MAX_REQUESTS,
};
}
async function readTeamNames(): Promise<string[]> {
const teamsDir = path.join(os.homedir(), '.claude', 'teams');
const entries = await fs.readdir(teamsDir, { withFileTypes: true }).catch(() => []);
return entries
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
.sort((left, right) => left.localeCompare(right));
}
async function readPresence(teamName: string): Promise<Record<string, PresenceEntry>> {
const filePath = path.join(
os.homedir(),
'.claude',
'task-change-presence',
`${encodeURIComponent(teamName)}.json`
);
try {
const parsed: unknown = JSON.parse(await fs.readFile(filePath, 'utf8'));
return parsed && typeof parsed === 'object'
? (parsed as Record<string, PresenceEntry>)
: {};
} catch {
return {};
}
}
function overlayPresence(
tasks: TeamTask[],
presenceByTaskId: Record<string, PresenceEntry>
): TeamTaskWithKanban[] {
return tasks.map((task) => {
const presence = presenceByTaskId[task.id]?.presence;
if (
presence === 'has_changes' ||
presence === 'needs_attention' ||
presence === 'no_changes' ||
presence === 'unknown'
) {
return { ...task, changePresence: presence };
}
return task;
});
}
function increment(counts: Record<string, number>, rawKey: string | undefined): void {
const key = rawKey && rawKey.trim().length > 0 ? rawKey : 'unknown';
counts[key] = (counts[key] ?? 0) + 1;
}
function isCandidateSuccess(candidate: Candidate): candidate is CandidateSuccess {
return !('error' in candidate);
}
function isCountableSummary(item: TeamTaskChangeSummaryItem): boolean {
if (item.error) return true;
const changeSet = item.changeSet;
if (!changeSet) return false;
const fileCount = Array.isArray(changeSet.files) ? changeSet.files.length : 0;
const diagnosticCount = Array.isArray(changeSet.reviewDiagnostics)
? changeSet.reviewDiagnostics.length
: 0;
const warningCount = Array.isArray(changeSet.warnings) ? changeSet.warnings.length : 0;
return (
fileCount > 0 ||
diagnosticCount > 0 ||
warningCount > 0
);
}
function isSatisfiedSummary(item: TeamTaskChangeSummaryItem): boolean {
return !item.error && item.changeSet !== null;
}
function createChangeExtractorService(modules: RuntimeModules): InstanceType<
RuntimeModules['ChangeExtractorService']
> {
return new modules.ChangeExtractorService(
new modules.TeamMemberLogsFinder(),
new modules.TaskBoundaryParser(),
undefined,
undefined,
new modules.TaskChangeWorkerClient({ enabled: false }),
null
);
}
async function loadCandidate(
modules: RuntimeModules,
taskReader: InstanceType<RuntimeModules['TeamTaskReader']>,
teamName: string
): Promise<Candidate> {
try {
const rawTasks = await taskReader.getTasks(teamName);
const presence = await readPresence(teamName);
const tasks = overlayPresence(rawTasks, presence);
const eligiblePlan = modules.buildTeamChangeRequestPlan(tasks, 0, false, {
maxRequests: modules.TEAM_CHANGES_MAX_REQUESTS,
});
const presenceCounts: Record<string, number> = {};
for (const entry of Object.values(presence)) {
increment(presenceCounts, entry.presence);
}
return {
teamName,
tasks,
taskCount: rawTasks.length,
changedPresenceCount:
(presenceCounts.has_changes ?? 0) + (presenceCounts.needs_attention ?? 0),
eligibleCount: eligiblePlan.eligibleCount,
presenceCounts,
};
} catch (error) {
return {
teamName,
error: error instanceof Error ? error.message : String(error),
};
}
}
function selectCandidates(candidates: Candidate[], limit: number): CandidateSuccess[] {
return candidates
.filter(isCandidateSuccess)
.sort((left, right) => {
const leftScore = left.taskCount + left.changedPresenceCount;
const rightScore = right.taskCount + right.changedPresenceCount;
return (
rightScore - leftScore ||
right.changedPresenceCount - left.changedPresenceCount ||
right.taskCount - left.taskCount ||
left.teamName.localeCompare(right.teamName)
);
})
.slice(0, limit);
}
function summarizeStageItems(
items: TeamTaskChangeSummaryItem[],
satisfiedTaskIds: Set<string>
): Omit<
StageReport,
| 'label'
| 'requested'
| 'duplicateRequests'
| 'responseItems'
| 'truncated'
| 'ms'
| 'deferredBeforeResponse'
| 'satisfiedAfterStage'
| 'firstTaskIds'
> {
const confidenceCounts: Record<string, number> = {};
const sourceKindCounts: Record<string, number> = {};
let itemErrors = 0;
let nullItems = 0;
let countableItems = 0;
let fileRows = 0;
for (const item of items) {
if (item.error) itemErrors += 1;
if (!item.changeSet) nullItems += 1;
if (isCountableSummary(item)) countableItems += 1;
if (isSatisfiedSummary(item)) satisfiedTaskIds.add(item.taskId);
fileRows += Array.isArray(item.changeSet?.files) ? item.changeSet.files.length : 0;
increment(confidenceCounts, item.changeSet?.confidence);
increment(sourceKindCounts, item.changeSet?.provenance?.sourceKind);
}
return { itemErrors, nullItems, countableItems, fileRows, confidenceCounts, sourceKindCounts };
}
async function runTeamSmoke(
modules: RuntimeModules,
team: CandidateSuccess
): Promise<TeamSmokeReport> {
const service = createChangeExtractorService(modules);
const satisfiedTaskIds = new Set<string>();
const requestedTaskIds = new Set<string>();
let cursor = 0;
const stages: StageReport[] = [];
const stageInputs = [
{
label: 'stage1-first-paint',
maxRequests: FIRST_STAGE_REQUESTS,
unknownScanLimit: FIRST_STAGE_UNKNOWN_SCAN_LIMIT,
},
{
label: 'stage2-expand',
maxRequests: SECOND_STAGE_REQUESTS,
unknownScanLimit: SECOND_STAGE_UNKNOWN_SCAN_LIMIT,
},
{
label: 'stage3-full',
maxRequests: modules.TEAM_CHANGES_MAX_REQUESTS,
unknownScanLimit: undefined,
},
];
for (const stage of stageInputs) {
const plan = modules.buildTeamChangeRequestPlan(team.tasks, cursor, false, {
maxRequests: stage.maxRequests,
unknownScanLimit: stage.unknownScanLimit,
satisfiedTaskIds,
});
cursor = plan.nextUnknownScanCursor;
if (plan.requests.length === 0) break;
const duplicateRequests = plan.requests
.map((request) => request.taskId)
.filter((taskId) => requestedTaskIds.has(taskId));
for (const request of plan.requests) {
requestedTaskIds.add(request.taskId);
}
const startedAt = Date.now();
const response = await service.getTeamTaskChangeSummaries(team.teamName, plan.requests);
const summary = summarizeStageItems(response.items, satisfiedTaskIds);
stages.push({
label: stage.label,
requested: plan.requests.length,
duplicateRequests,
responseItems: response.items.length,
truncated: response.truncated === true,
ms: Date.now() - startedAt,
deferredBeforeResponse: plan.deferredCount,
satisfiedAfterStage: satisfiedTaskIds.size,
firstTaskIds: plan.requests.slice(0, 5).map((request) => request.taskId.slice(0, 8)),
...summary,
});
if (plan.deferredCount === 0) break;
}
return {
kind: 'team-smoke',
teamName: team.teamName,
taskCount: team.taskCount,
changedPresenceCount: team.changedPresenceCount,
eligibleCount: team.eligibleCount,
stages,
};
}
function assertTeamSmoke(report: TeamSmokeReport): void {
const problems: string[] = [];
if (report.eligibleCount > 0 && report.stages.length === 0) {
problems.push('eligible tasks produced no staged requests');
}
for (const stage of report.stages) {
if (stage.duplicateRequests.length > 0) {
problems.push(`${stage.label} duplicated ${stage.duplicateRequests.join(', ')}`);
}
if (stage.responseItems > stage.requested) {
problems.push(`${stage.label} returned more items than requested`);
}
if (stage.requested === 0) {
problems.push(`${stage.label} was recorded with zero requests`);
}
}
const lastStage = report.stages.at(-1);
if (lastStage && lastStage.deferredBeforeResponse > 0 && lastStage.label !== 'stage3-full') {
problems.push(`${lastStage.label} left deferred work without reaching the full stage`);
}
if (problems.length > 0) {
throw new Error(`Team Changes real-data smoke failed for ${report.teamName}: ${problems.join('; ')}`);
}
}
async function runForceRefreshSmoke(
modules: RuntimeModules,
team: CandidateSuccess
): Promise<ForceRefreshSmokeReport> {
const service = createChangeExtractorService(modules);
const plan = modules.buildTeamChangeRequestPlan(team.tasks, 0, true, {
maxRequests: FIRST_STAGE_REQUESTS,
unknownScanLimit: FIRST_STAGE_UNKNOWN_SCAN_LIMIT,
});
const startedAt = Date.now();
const response = await service.getTeamTaskChangeSummaries(team.teamName, plan.requests);
return {
kind: 'force-refresh-smoke',
teamName: team.teamName,
requested: plan.requests.length,
allForceFresh: plan.requests.every((request) => request.options?.forceFresh === true),
responseItems: response.items.length,
ms: Date.now() - startedAt,
taskIds: plan.requests.map((request) => request.taskId.slice(0, 8)),
};
}
function assertForceRefreshSmoke(report: ForceRefreshSmokeReport): void {
const problems: string[] = [];
if (report.requested > 0 && !report.allForceFresh) {
problems.push('not every force refresh request carried forceFresh=true');
}
if (report.responseItems > report.requested) {
problems.push('force refresh returned more items than requested');
}
if (problems.length > 0) {
throw new Error(`Team Changes force-refresh smoke failed for ${report.teamName}: ${problems.join('; ')}`);
}
}
async function main(): Promise<void> {
const args = parseArgs(process.argv.slice(2));
const modules = await loadRuntimeModules();
const taskReader = new modules.TeamTaskReader();
const teamNames = args.teams.length > 0 ? args.teams : await readTeamNames();
const candidates = await Promise.all(
teamNames.map((teamName) => loadCandidate(modules, taskReader, teamName))
);
const selected = selectCandidates(candidates, args.limit);
const report: unknown[] = [
{
kind: 'selection',
selected: selected.map(
({ teamName, taskCount, changedPresenceCount, eligibleCount, presenceCounts }) => ({
teamName,
taskCount,
changedPresenceCount,
eligibleCount,
presenceCounts,
})
),
skipped: candidates.filter((candidate) => !isCandidateSuccess(candidate)),
},
];
for (const team of selected) {
const teamReport = await runTeamSmoke(modules, team);
assertTeamSmoke(teamReport);
report.push(teamReport);
}
if (selected[0]) {
const forceRefreshReport = await runForceRefreshSmoke(modules, selected[0]);
assertForceRefreshSmoke(forceRefreshReport);
report.push(forceRefreshReport);
}
console.log(JSON.stringify(report, null, 2));
}
void main().then(
() => process.exit(0),
(error) => {
console.error(error instanceof Error ? (error.stack ?? error.message) : String(error));
process.exit(1);
}
);

View file

@ -28,6 +28,7 @@ const ACTIVITY_SHELL_HEIGHT =
ACTIVITY_LANE.headerHeight + ACTIVITY_LANE.headerHeight +
ACTIVITY_LANE.maxVisibleItems * ACTIVITY_LANE.rowHeight + ACTIVITY_LANE.maxVisibleItems * ACTIVITY_LANE.rowHeight +
ACTIVITY_LANE.overflowHeight; ACTIVITY_LANE.overflowHeight;
const NEW_ACTIVITY_HIGHLIGHT_MS = 1_000;
interface GraphActivityHudProps { interface GraphActivityHudProps {
teamName: string; teamName: string;
@ -56,6 +57,10 @@ interface GraphActivityHudProps {
) => void; ) => void;
} }
function buildRenderedActivityItemKey(ownerNodeId: string, itemId: string): string {
return JSON.stringify([ownerNodeId, itemId]);
}
export const GraphActivityHud = ({ export const GraphActivityHud = ({
teamName, teamName,
nodes, nodes,
@ -73,7 +78,12 @@ export const GraphActivityHud = ({
const shellRefs = useRef(new Map<string, HTMLDivElement | null>()); const shellRefs = useRef(new Map<string, HTMLDivElement | null>());
const connectorRefs = useRef(new Map<string, SVGSVGElement | null>()); const connectorRefs = useRef(new Map<string, SVGSVGElement | null>());
const connectorPathRefs = useRef(new Map<string, SVGPathElement | null>()); const connectorPathRefs = useRef(new Map<string, SVGPathElement | null>());
const knownActivityItemIdsByOwnerRef = useRef(new Map<string, Set<string>>());
const highlightTimersRef = useRef(new Map<string, ReturnType<typeof setTimeout>>());
const [expandedItem, setExpandedItem] = useState<TimelineItem | null>(null); const [expandedItem, setExpandedItem] = useState<TimelineItem | null>(null);
const [highlightedActivityItemIds, setHighlightedActivityItemIds] = useState<ReadonlySet<string>>(
() => new Set()
);
const { teamData, teams } = useGraphActivityContext(teamName); const { teamData, teams } = useGraphActivityContext(teamName);
const teamSnapshot = teamData; const teamSnapshot = teamData;
const members = teamData?.members ?? []; const members = teamData?.members ?? [];
@ -114,8 +124,23 @@ export const GraphActivityHud = ({
useEffect(() => { useEffect(() => {
setExpandedItem(null); setExpandedItem(null);
knownActivityItemIdsByOwnerRef.current.clear();
setHighlightedActivityItemIds(new Set());
for (const timer of highlightTimersRef.current.values()) {
clearTimeout(timer);
}
highlightTimersRef.current.clear();
}, [teamName]); }, [teamName]);
useEffect(() => {
return () => {
for (const timer of highlightTimersRef.current.values()) {
clearTimeout(timer);
}
highlightTimersRef.current.clear();
};
}, []);
const visibleLanes = useMemo(() => { const visibleLanes = useMemo(() => {
return ownerNodes return ownerNodes
.map((node) => { .map((node) => {
@ -143,6 +168,51 @@ export const GraphActivityHud = ({
); );
}, [entryMapByOwnerNodeId, ownerNodes]); }, [entryMapByOwnerNodeId, ownerNodes]);
useEffect(() => {
if (!enabled) return;
const newItemKeys: string[] = [];
for (const lane of visibleLanes) {
const currentIds = new Set(lane.entries.map((entry) => entry.graphItem.id));
const knownIds = knownActivityItemIdsByOwnerRef.current.get(lane.node.id);
if (knownIds) {
for (const itemId of currentIds) {
if (!knownIds.has(itemId)) {
newItemKeys.push(buildRenderedActivityItemKey(lane.node.id, itemId));
}
}
}
knownActivityItemIdsByOwnerRef.current.set(lane.node.id, currentIds);
}
if (newItemKeys.length === 0) return;
setHighlightedActivityItemIds((current) => {
const next = new Set(current);
for (const itemKey of newItemKeys) {
next.add(itemKey);
}
return next;
});
for (const itemKey of newItemKeys) {
const existingTimer = highlightTimersRef.current.get(itemKey);
if (existingTimer) {
clearTimeout(existingTimer);
}
const timer = setTimeout(() => {
highlightTimersRef.current.delete(itemKey);
setHighlightedActivityItemIds((current) => {
if (!current.has(itemKey)) return current;
const next = new Set(current);
next.delete(itemKey);
return next;
});
}, NEW_ACTIVITY_HIGHLIGHT_MS);
highlightTimersRef.current.set(itemKey, timer);
}
}, [enabled, visibleLanes]);
useLayoutEffect(() => { useLayoutEffect(() => {
if (!enabled || visibleLanes.length === 0) { if (!enabled || visibleLanes.length === 0) {
for (const shell of shellRefs.current.values()) { for (const shell of shellRefs.current.values()) {
@ -377,11 +447,20 @@ export const GraphActivityHud = ({
message: entry.message, message: entry.message,
}; };
const isUnread = !entry.message.read && !readSet.has(messageKey); const isUnread = !entry.message.read && !readSet.has(messageKey);
const isHighlighted = highlightedActivityItemIds.has(
buildRenderedActivityItemKey(entry.ownerNodeId, entry.graphItem.id)
);
return ( return (
<div <div
key={entry.graphItem.id} key={entry.graphItem.id}
className="h-[72px] min-h-[72px] min-w-0 max-w-full cursor-pointer overflow-hidden" data-activity-entry-id={entry.graphItem.id}
className={[
'h-[72px] min-h-[72px] min-w-0 max-w-full cursor-pointer overflow-hidden rounded-md border transition-[border-color,background-color,box-shadow] duration-500',
isHighlighted
? 'border-sky-300/70 bg-[rgba(14,34,62,0.56)] shadow-[0_0_0_1px_rgba(125,211,252,0.30),0_0_18px_rgba(56,189,248,0.22)]'
: 'border-transparent',
].join(' ')}
role="button" role="button"
tabIndex={0} tabIndex={0}
onClick={() => handleMessageClick(timelineItem)} onClick={() => handleMessageClick(timelineItem)}
@ -405,6 +484,7 @@ export const GraphActivityHud = ({
[ [
handleMessageClick, handleMessageClick,
handleMessageKeyDown, handleMessageKeyDown,
highlightedActivityItemIds,
messageContext, messageContext,
onOpenMemberProfile, onOpenMemberProfile,
onOpenTaskDetail, onOpenTaskDetail,

View file

@ -116,8 +116,8 @@ function resolveEmptyText(
if (preview?.warnings.some((warning) => warning.code === 'codex_member_wide_not_supported')) { if (preview?.warnings.some((warning) => warning.code === 'codex_member_wide_not_supported')) {
return 'Unsupported provider'; return 'Unsupported provider';
} }
if (loading && (!preview || preview.items.length === 0)) return 'Loading logs'; if (loading && !preview) return 'Loading logs';
if (error && (!preview || preview.items.length === 0)) return 'Logs unavailable'; if (error && !preview) return 'Logs unavailable';
return 'No recent logs'; return 'No recent logs';
} }
@ -215,6 +215,26 @@ function setShellHidden(shell: HTMLDivElement): void {
shell.style.pointerEvents = 'none'; shell.style.pointerEvents = 'none';
} }
function renderLoadingSkeleton(): React.JSX.Element {
return (
<div className="flex h-full min-h-0 w-full flex-col gap-2 overflow-hidden" aria-hidden="true">
{[0, 1, 2].map((index) => (
<span
key={index}
className="flex h-[72px] min-h-[72px] w-full min-w-0 animate-pulse rounded-md border border-white/10 bg-[rgba(8,14,28,0.42)] px-2.5 py-1.5"
>
<span className="mr-2 mt-0.5 inline-flex size-5 shrink-0 rounded bg-white/10" />
<span className="flex min-w-0 flex-1 flex-col gap-2 pt-0.5">
<span className="h-3 w-2/5 rounded bg-slate-400/20" />
<span className="h-2.5 w-full rounded bg-slate-400/15" />
<span className="h-2.5 w-2/3 rounded bg-slate-400/10" />
</span>
</span>
))}
</div>
);
}
export const GraphMemberLogPreviewHud = ({ export const GraphMemberLogPreviewHud = ({
teamName, teamName,
nodes, nodes,
@ -532,6 +552,7 @@ export const GraphMemberLogPreviewHud = ({
: node.label; : node.label;
const preview = previewsByMember.get(normalizeMemberName(memberName)); const preview = previewsByMember.get(normalizeMemberName(memberName));
const items = preview?.items ?? []; const items = preview?.items ?? [];
const isInitialLoading = loading && !preview;
return ( return (
<div <div
@ -557,6 +578,17 @@ export const GraphMemberLogPreviewHud = ({
<div className="flex min-h-0 flex-1 flex-col gap-2 overflow-hidden"> <div className="flex min-h-0 flex-1 flex-col gap-2 overflow-hidden">
{items.length > 0 ? ( {items.length > 0 ? (
items.slice(0, 3).map((item) => renderItem(memberName, item)) items.slice(0, 3).map((item) => renderItem(memberName, item))
) : isInitialLoading ? (
<button
type="button"
className="flex min-h-0 flex-1 rounded-md text-left text-[11px] text-slate-400/60"
aria-busy="true"
aria-label="Loading logs"
onClick={() => openLogs(memberName)}
>
<span className="sr-only">Loading logs</span>
{renderLoadingSkeleton()}
</button>
) : ( ) : (
<button <button
type="button" type="button"

View file

@ -188,6 +188,7 @@ import {
LocalFileSystemProvider, LocalFileSystemProvider,
MemberStatsComputer, MemberStatsComputer,
NotificationManager, NotificationManager,
OpenCodeRuntimeInstallerService,
OpenCodeReadinessBridge, OpenCodeReadinessBridge,
OpenCodeTeamRuntimeAdapter, OpenCodeTeamRuntimeAdapter,
PtyTerminalService, PtyTerminalService,
@ -700,6 +701,7 @@ let teamDataService: TeamDataService;
let teamProvisioningService: TeamProvisioningService; let teamProvisioningService: TeamProvisioningService;
let launchIoGovernor: LaunchIoGovernor | null = null; let launchIoGovernor: LaunchIoGovernor | null = null;
let cliInstallerService: CliInstallerService; let cliInstallerService: CliInstallerService;
let openCodeRuntimeInstallerService: OpenCodeRuntimeInstallerService;
let ptyTerminalService: PtyTerminalService; let ptyTerminalService: PtyTerminalService;
let httpServer: HttpServer; let httpServer: HttpServer;
let schedulerService: SchedulerService; let schedulerService: SchedulerService;
@ -1312,6 +1314,7 @@ async function initializeServices(): Promise<void> {
} }
}); });
cliInstallerService = new CliInstallerService(); cliInstallerService = new CliInstallerService();
openCodeRuntimeInstallerService = new OpenCodeRuntimeInstallerService();
ptyTerminalService = new PtyTerminalService(); ptyTerminalService = new PtyTerminalService();
const teamMemberLogsFinder = new TeamMemberLogsFinder(); const teamMemberLogsFinder = new TeamMemberLogsFinder();
const teamLogSourceTracker = new TeamLogSourceTracker(teamMemberLogsFinder); const teamLogSourceTracker = new TeamLogSourceTracker(teamMemberLogsFinder);
@ -1810,6 +1813,7 @@ async function initializeServices(): Promise<void> {
reviewApplier, reviewApplier,
gitDiffFallback, gitDiffFallback,
cliInstallerService, cliInstallerService,
openCodeRuntimeInstallerService,
ptyTerminalService, ptyTerminalService,
schedulerService, schedulerService,
extensionFacadeService, extensionFacadeService,
@ -2055,6 +2059,7 @@ function attachMainWindowToServices(): void {
notificationManager?.setMainWindow(win); notificationManager?.setMainWindow(win);
updaterService?.setMainWindow(win); updaterService?.setMainWindow(win);
cliInstallerService?.setMainWindow(win); cliInstallerService?.setMainWindow(win);
openCodeRuntimeInstallerService?.setMainWindow(win);
setTmuxMainWindow(win); setTmuxMainWindow(win);
ptyTerminalService?.setMainWindow(win); ptyTerminalService?.setMainWindow(win);
teamProvisioningService?.setMainWindow(win); teamProvisioningService?.setMainWindow(win);
@ -2378,6 +2383,9 @@ function createWindow(): void {
if (cliInstallerService) { if (cliInstallerService) {
cliInstallerService.setMainWindow(null); cliInstallerService.setMainWindow(null);
} }
if (openCodeRuntimeInstallerService) {
openCodeRuntimeInstallerService.setMainWindow(null);
}
setTmuxMainWindow(null); setTmuxMainWindow(null);
if (ptyTerminalService) { if (ptyTerminalService) {
ptyTerminalService.setMainWindow(null); ptyTerminalService.setMainWindow(null);

View file

@ -44,6 +44,11 @@ import {
registerHttpServerHandlers, registerHttpServerHandlers,
removeHttpServerHandlers, removeHttpServerHandlers,
} from './httpServer'; } from './httpServer';
import {
initializeOpenCodeRuntimeHandlers,
registerOpenCodeRuntimeHandlers,
removeOpenCodeRuntimeHandlers,
} from './openCodeRuntime';
const logger = createLogger('IPC:handlers'); const logger = createLogger('IPC:handlers');
import { registerNotificationHandlers, removeNotificationHandlers } from './notifications'; import { registerNotificationHandlers, removeNotificationHandlers } from './notifications';
@ -100,6 +105,7 @@ import type {
FileContentResolver, FileContentResolver,
GitDiffFallback, GitDiffFallback,
MemberStatsComputer, MemberStatsComputer,
OpenCodeRuntimeInstallerService,
PtyTerminalService, PtyTerminalService,
ReviewApplierService, ReviewApplierService,
ServiceContext, ServiceContext,
@ -159,6 +165,7 @@ export function initializeIpcHandlers(
reviewApplier?: ReviewApplierService, reviewApplier?: ReviewApplierService,
gitDiffFallback?: GitDiffFallback, gitDiffFallback?: GitDiffFallback,
cliInstaller?: CliInstallerService, cliInstaller?: CliInstallerService,
openCodeRuntimeInstaller?: OpenCodeRuntimeInstallerService,
ptyTerminal?: PtyTerminalService, ptyTerminal?: PtyTerminalService,
schedulerService?: SchedulerService, schedulerService?: SchedulerService,
extensionFacade?: ExtensionFacadeService, extensionFacade?: ExtensionFacadeService,
@ -209,6 +216,9 @@ export function initializeIpcHandlers(
if (cliInstaller) { if (cliInstaller) {
initializeCliInstallerHandlers(cliInstaller); initializeCliInstallerHandlers(cliInstaller);
} }
if (openCodeRuntimeInstaller) {
initializeOpenCodeRuntimeHandlers(openCodeRuntimeInstaller);
}
if (ptyTerminal) { if (ptyTerminal) {
initializeTerminalHandlers(ptyTerminal); initializeTerminalHandlers(ptyTerminal);
} }
@ -260,6 +270,9 @@ export function initializeIpcHandlers(
if (cliInstaller) { if (cliInstaller) {
registerCliInstallerHandlers(ipcMain); registerCliInstallerHandlers(ipcMain);
} }
if (openCodeRuntimeInstaller) {
registerOpenCodeRuntimeHandlers(ipcMain);
}
if (ptyTerminal) { if (ptyTerminal) {
registerTerminalHandlers(ipcMain); registerTerminalHandlers(ipcMain);
} }
@ -301,6 +314,7 @@ export function removeIpcHandlers(): void {
removeRendererLogHandlers(ipcMain); removeRendererLogHandlers(ipcMain);
removeScheduleHandlers(ipcMain); removeScheduleHandlers(ipcMain);
removeCliInstallerHandlers(ipcMain); removeCliInstallerHandlers(ipcMain);
removeOpenCodeRuntimeHandlers(ipcMain);
removeTerminalHandlers(ipcMain); removeTerminalHandlers(ipcMain);
removeTmuxHandlers(ipcMain); removeTmuxHandlers(ipcMain);
removeHttpServerHandlers(ipcMain); removeHttpServerHandlers(ipcMain);

View file

@ -0,0 +1,78 @@
import {
OPENCODE_RUNTIME_GET_STATUS,
OPENCODE_RUNTIME_INSTALL,
OPENCODE_RUNTIME_INVALIDATE_STATUS,
// eslint-disable-next-line boundaries/element-types -- IPC channel constants shared between main and preload
} from '@preload/constants/ipcChannels';
import { getErrorMessage } from '@shared/utils/errorHandling';
import { createLogger } from '@shared/utils/logger';
import type { OpenCodeRuntimeInstallerService } from '../services';
import type { IpcResult, OpenCodeRuntimeStatus } from '@shared/types';
import type { IpcMain, IpcMainInvokeEvent } from 'electron';
const logger = createLogger('IPC:openCodeRuntime');
let service: OpenCodeRuntimeInstallerService | null = null;
export function initializeOpenCodeRuntimeHandlers(
openCodeRuntimeService: OpenCodeRuntimeInstallerService
): void {
service = openCodeRuntimeService;
}
export function registerOpenCodeRuntimeHandlers(ipcMain: IpcMain): void {
ipcMain.handle(OPENCODE_RUNTIME_GET_STATUS, handleGetStatus);
ipcMain.handle(OPENCODE_RUNTIME_INSTALL, handleInstall);
ipcMain.handle(OPENCODE_RUNTIME_INVALIDATE_STATUS, handleInvalidateStatus);
logger.info('OpenCode runtime handlers registered');
}
export function removeOpenCodeRuntimeHandlers(ipcMain: IpcMain): void {
ipcMain.removeHandler(OPENCODE_RUNTIME_GET_STATUS);
ipcMain.removeHandler(OPENCODE_RUNTIME_INSTALL);
ipcMain.removeHandler(OPENCODE_RUNTIME_INVALIDATE_STATUS);
logger.info('OpenCode runtime handlers removed');
}
function requireService(): OpenCodeRuntimeInstallerService {
if (!service) {
throw new Error('OpenCode runtime installer service is not initialized');
}
return service;
}
async function handleGetStatus(
_event: IpcMainInvokeEvent
): Promise<IpcResult<OpenCodeRuntimeStatus>> {
try {
return { success: true, data: await requireService().getStatus() };
} catch (error) {
const message = getErrorMessage(error);
logger.error('Error in openCodeRuntime:getStatus:', message);
return { success: false, error: message };
}
}
async function handleInstall(
_event: IpcMainInvokeEvent
): Promise<IpcResult<OpenCodeRuntimeStatus>> {
try {
return { success: true, data: await requireService().install() };
} catch (error) {
const message = getErrorMessage(error);
logger.error('Error in openCodeRuntime:install:', message);
return { success: false, error: message };
}
}
function handleInvalidateStatus(_event: IpcMainInvokeEvent): IpcResult<void> {
try {
requireService().invalidateStatusCache();
return { success: true, data: undefined };
} catch (error) {
const message = getErrorMessage(error);
logger.error('Error in openCodeRuntime:invalidateStatus:', message);
return { success: false, error: message };
}
}

View file

@ -0,0 +1,578 @@
import { execCli } from '@main/utils/childProcess';
import { getAppDataPath } from '@main/utils/pathDecoder';
import { safeSendToRenderer } from '@main/utils/safeWebContentsSend';
import { getCachedShellEnv } from '@main/utils/shellEnv';
import { getErrorMessage } from '@shared/utils/errorHandling';
import { createLogger } from '@shared/utils/logger';
import { createHash, randomUUID } from 'crypto';
import { existsSync, promises as fsp, readFileSync, statSync } from 'fs';
import path from 'path';
import { gunzipSync } from 'zlib';
import type { OpenCodeRuntimeInstallProgress, OpenCodeRuntimeStatus } from '@shared/types';
import type { BrowserWindow } from 'electron';
const logger = createLogger('OpenCodeRuntimeInstallerService');
const CHANNEL = 'openCodeRuntime:progress';
const ROOT_PACKAGE_NAME = 'opencode-ai';
const NPM_REGISTRY_BASE_URL = 'https://registry.npmjs.org';
const CURRENT_MANIFEST_SCHEMA_VERSION = 1;
const MAX_TARBALL_BYTES = 250 * 1024 * 1024;
const MAX_BINARY_BYTES = 350 * 1024 * 1024;
const FETCH_TIMEOUT_MS = 60_000;
const VERSION_TIMEOUT_MS = 10_000;
interface NpmPackageMetadata {
name?: string;
version?: string;
dist?: {
tarball?: string;
integrity?: string;
};
optionalDependencies?: Record<string, string>;
}
interface OpenCodeRuntimeManifest {
schemaVersion: 1;
version: string;
platformPackage: string;
binaryPath: string;
integrity: string;
installedAt: string;
}
interface PlatformCandidate {
packageName: string;
reason: string;
}
function getRuntimeRootPath(): string {
return path.join(getAppDataPath(), 'runtimes', 'opencode');
}
function getCurrentManifestPath(): string {
return path.join(getRuntimeRootPath(), 'current.json');
}
function isAbsoluteExistingFile(filePath: string | null | undefined): filePath is string {
if (!filePath || !path.isAbsolute(filePath) || !existsSync(filePath)) {
return false;
}
try {
return statSync(filePath).isFile();
} catch {
return false;
}
}
function parseManifest(value: unknown): OpenCodeRuntimeManifest | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return null;
}
const manifest = value as Partial<OpenCodeRuntimeManifest>;
if (
manifest.schemaVersion !== CURRENT_MANIFEST_SCHEMA_VERSION ||
typeof manifest.version !== 'string' ||
typeof manifest.platformPackage !== 'string' ||
typeof manifest.binaryPath !== 'string' ||
typeof manifest.integrity !== 'string' ||
typeof manifest.installedAt !== 'string'
) {
return null;
}
return manifest as OpenCodeRuntimeManifest;
}
function readCurrentManifestSync(): OpenCodeRuntimeManifest | null {
try {
const raw = readFileSync(getCurrentManifestPath(), 'utf8');
return parseManifest(JSON.parse(raw));
} catch {
return null;
}
}
export function resolveAppManagedOpenCodeRuntimeBinaryPath(): string | null {
const manifest = readCurrentManifestSync();
return isAbsoluteExistingFile(manifest?.binaryPath) ? manifest.binaryPath : null;
}
function getExecutableName(): string {
return process.platform === 'win32' ? 'opencode.exe' : 'opencode';
}
function getPathExecutableNames(): string[] {
return process.platform === 'win32'
? ['opencode.exe', 'opencode.cmd', 'opencode.bat', 'opencode']
: ['opencode'];
}
function splitPathEnv(pathValue: string | undefined): string[] {
if (!pathValue) {
return [];
}
return pathValue
.split(path.delimiter)
.map((entry) => entry.trim())
.filter(Boolean);
}
function resolvePathOpenCodeBinary(): string | null {
const shellEnv = getCachedShellEnv() ?? {};
const pathEntries = [...splitPathEnv(shellEnv.PATH), ...splitPathEnv(process.env.PATH)];
const seen = new Set<string>();
for (const entry of pathEntries) {
const normalizedEntry = path.resolve(entry);
if (seen.has(normalizedEntry)) {
continue;
}
seen.add(normalizedEntry);
for (const executableName of getPathExecutableNames()) {
const candidate = path.join(normalizedEntry, executableName);
if (isAbsoluteExistingFile(candidate)) {
return candidate;
}
}
}
return null;
}
function isLinuxMuslRuntime(): boolean {
if (process.platform !== 'linux') {
return false;
}
const report =
typeof process.report?.getReport === 'function'
? (process.report.getReport() as { header?: { glibcVersionRuntime?: string } })
: null;
const header = report?.header;
return !header?.glibcVersionRuntime;
}
function getPlatformCandidates(): PlatformCandidate[] {
const arch = process.arch;
const musl = isLinuxMuslRuntime();
if (process.platform === 'darwin') {
if (arch === 'arm64') return [{ packageName: 'opencode-darwin-arm64', reason: 'macOS arm64' }];
if (arch === 'x64') {
return [
{ packageName: 'opencode-darwin-x64', reason: 'macOS x64' },
{ packageName: 'opencode-darwin-x64-baseline', reason: 'macOS x64 baseline fallback' },
];
}
}
if (process.platform === 'linux') {
if (arch === 'arm64') {
return musl
? [
{ packageName: 'opencode-linux-arm64-musl', reason: 'Linux arm64 musl' },
{ packageName: 'opencode-linux-arm64', reason: 'Linux arm64 glibc fallback' },
]
: [
{ packageName: 'opencode-linux-arm64', reason: 'Linux arm64 glibc' },
{ packageName: 'opencode-linux-arm64-musl', reason: 'Linux arm64 musl fallback' },
];
}
if (arch === 'x64') {
return musl
? [
{ packageName: 'opencode-linux-x64-musl', reason: 'Linux x64 musl' },
{
packageName: 'opencode-linux-x64-baseline-musl',
reason: 'Linux x64 musl baseline fallback',
},
{ packageName: 'opencode-linux-x64', reason: 'Linux x64 glibc fallback' },
]
: [
{ packageName: 'opencode-linux-x64', reason: 'Linux x64 glibc' },
{ packageName: 'opencode-linux-x64-baseline', reason: 'Linux x64 baseline fallback' },
{ packageName: 'opencode-linux-x64-musl', reason: 'Linux x64 musl fallback' },
];
}
}
if (process.platform === 'win32') {
if (arch === 'arm64')
return [{ packageName: 'opencode-windows-arm64', reason: 'Windows arm64' }];
if (arch === 'x64') {
return [
{ packageName: 'opencode-windows-x64', reason: 'Windows x64' },
{ packageName: 'opencode-windows-x64-baseline', reason: 'Windows x64 baseline fallback' },
];
}
}
throw new Error(`OpenCode app install is not supported on ${process.platform}/${arch}`);
}
async function fetchText(url: string): Promise<string> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
try {
const response = await fetch(url, { signal: controller.signal });
if (!response.ok) {
throw new Error(`HTTP ${response.status} from ${url}`);
}
return await response.text();
} finally {
clearTimeout(timer);
}
}
async function fetchPackageMetadata(
packageName: string,
version = 'latest'
): Promise<NpmPackageMetadata> {
const url = `${NPM_REGISTRY_BASE_URL}/${encodeURIComponent(packageName)}/${encodeURIComponent(version)}`;
const raw = await fetchText(url);
const parsed = JSON.parse(raw) as NpmPackageMetadata;
if (!parsed.version || !parsed.dist?.tarball || !parsed.dist.integrity) {
throw new Error(`Invalid npm metadata for ${packageName}@${version}`);
}
return parsed;
}
function verifyIntegrity(buffer: Buffer, integrity: string): void {
const match = /^sha512-([A-Za-z0-9+/=]+)$/.exec(integrity.trim());
if (!match) {
throw new Error('OpenCode package integrity is missing sha512 metadata');
}
const actual = createHash('sha512').update(buffer).digest('base64');
if (actual !== match[1]) {
throw new Error('OpenCode package integrity check failed');
}
}
async function downloadTarball(
url: string,
onProgress: (progress: OpenCodeRuntimeInstallProgress) => void
): Promise<Buffer> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
try {
const response = await fetch(url, { signal: controller.signal });
if (!response.ok || !response.body) {
throw new Error(`Failed to download OpenCode package: HTTP ${response.status}`);
}
const totalHeader = response.headers.get('content-length');
const totalBytes = totalHeader ? Number.parseInt(totalHeader, 10) : undefined;
if (totalBytes && totalBytes > MAX_TARBALL_BYTES) {
throw new Error('OpenCode package is unexpectedly large');
}
const chunks: Buffer[] = [];
let downloadedBytes = 0;
const reader = response.body.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) {
break;
}
const chunk = Buffer.from(value);
downloadedBytes += chunk.length;
if (downloadedBytes > MAX_TARBALL_BYTES) {
throw new Error('OpenCode package exceeded the maximum allowed download size');
}
chunks.push(chunk);
onProgress({
phase: 'downloading',
downloadedBytes,
totalBytes,
percent: totalBytes
? Math.min(100, Math.round((downloadedBytes / totalBytes) * 100))
: undefined,
detail: totalBytes
? `Downloading OpenCode ${Math.round((downloadedBytes / totalBytes) * 100)}%`
: 'Downloading OpenCode...',
});
}
return Buffer.concat(chunks, downloadedBytes);
} finally {
clearTimeout(timer);
}
}
function readTarString(buffer: Buffer, start: number, length: number): string {
const end = buffer.indexOf(0, start);
const safeEnd = end >= start && end < start + length ? end : start + length;
return buffer.toString('utf8', start, safeEnd).trim();
}
function readTarSize(buffer: Buffer, offset: number): number {
const raw = readTarString(buffer, offset + 124, 12)
.replace(/\0/g, '')
.trim();
const size = Number.parseInt(raw || '0', 8);
if (!Number.isFinite(size) || size < 0) {
throw new Error('Invalid OpenCode package tar entry size');
}
return size;
}
function assertSafeTarPath(name: string): void {
if (
!name ||
name.startsWith('/') ||
name.startsWith('\\') ||
name.includes('..') ||
name.includes('\\')
) {
throw new Error(`Unsafe OpenCode package tar entry: ${name}`);
}
}
function extractBinaryFromTarball(tarball: Buffer): Buffer {
const tar = gunzipSync(tarball, { maxOutputLength: MAX_BINARY_BYTES + 1024 * 1024 });
const targetName = `package/bin/${getExecutableName()}`;
let offset = 0;
while (offset + 512 <= tar.length) {
const name = readTarString(tar, offset, 100);
if (!name) {
break;
}
const prefix = readTarString(tar, offset + 345, 155);
const fullName = prefix ? `${prefix}/${name}` : name;
assertSafeTarPath(fullName);
const typeFlag = readTarString(tar, offset + 156, 1);
const size = readTarSize(tar, offset);
const dataStart = offset + 512;
const dataEnd = dataStart + size;
if (dataEnd > tar.length) {
throw new Error('OpenCode package tar entry exceeds archive bounds');
}
if ((typeFlag === '0' || typeFlag === '') && fullName === targetName) {
if (size <= 0 || size > MAX_BINARY_BYTES) {
throw new Error('OpenCode binary size is invalid');
}
return tar.subarray(dataStart, dataEnd);
}
offset = dataStart + Math.ceil(size / 512) * 512;
}
throw new Error(`OpenCode package did not contain ${targetName}`);
}
async function readCurrentManifest(): Promise<OpenCodeRuntimeManifest | null> {
try {
const raw = await fsp.readFile(getCurrentManifestPath(), 'utf8');
return parseManifest(JSON.parse(raw));
} catch {
return null;
}
}
export class OpenCodeRuntimeInstallerService {
private mainWindow: BrowserWindow | null = null;
private installPromise: Promise<OpenCodeRuntimeStatus> | null = null;
private latestStatus: OpenCodeRuntimeStatus | null = null;
setMainWindow(win: BrowserWindow | null): void {
this.mainWindow = win;
}
invalidateStatusCache(): void {
this.latestStatus = null;
}
async getStatus(): Promise<OpenCodeRuntimeStatus> {
if (this.installPromise && this.latestStatus) {
return this.latestStatus;
}
const appManagedStatus = await this.getAppManagedStatus();
if (appManagedStatus.installed) {
this.latestStatus = appManagedStatus;
return appManagedStatus;
}
const pathStatus = await this.getPathStatus();
const status =
pathStatus.installed ||
appManagedStatus.source !== 'app-managed' ||
appManagedStatus.state !== 'failed'
? pathStatus
: appManagedStatus;
this.latestStatus = status;
return status;
}
async install(): Promise<OpenCodeRuntimeStatus> {
if (this.installPromise) {
return this.installPromise;
}
this.installPromise = this.installInternal().finally(() => {
this.installPromise = null;
});
return this.installPromise;
}
private publish(status: OpenCodeRuntimeStatus): void {
this.latestStatus = status;
safeSendToRenderer(this.mainWindow, CHANNEL, status);
}
private publishProgress(progress: OpenCodeRuntimeInstallProgress): void {
this.publish({
installed: false,
source: 'missing',
state: progress.phase,
progress,
});
}
private async getAppManagedStatus(): Promise<OpenCodeRuntimeStatus> {
const manifest = await readCurrentManifest();
if (!isAbsoluteExistingFile(manifest?.binaryPath)) {
return { installed: false, source: 'missing', state: 'idle' };
}
try {
const { stdout } = await execCli(manifest.binaryPath, ['--version'], {
timeout: VERSION_TIMEOUT_MS,
windowsHide: true,
});
return {
installed: true,
binaryPath: manifest.binaryPath,
version: stdout.trim() || manifest.version,
source: 'app-managed',
state: 'ready',
};
} catch (error) {
return {
installed: false,
binaryPath: manifest.binaryPath,
version: manifest.version,
source: 'app-managed',
state: 'failed',
error: getErrorMessage(error),
};
}
}
private async getPathStatus(): Promise<OpenCodeRuntimeStatus> {
const binaryPath = resolvePathOpenCodeBinary();
if (!binaryPath) {
return { installed: false, source: 'missing', state: 'idle' };
}
try {
const { stdout } = await execCli(binaryPath, ['--version'], {
timeout: VERSION_TIMEOUT_MS,
windowsHide: true,
});
return {
installed: true,
binaryPath,
version: stdout.trim() || undefined,
source: 'path',
state: 'ready',
};
} catch (error) {
return {
installed: false,
binaryPath,
source: 'path',
state: 'failed',
error: getErrorMessage(error),
};
}
}
private async installInternal(): Promise<OpenCodeRuntimeStatus> {
try {
this.publishProgress({ phase: 'checking', detail: 'Resolving latest OpenCode package...' });
const rootMetadata = await fetchPackageMetadata(ROOT_PACKAGE_NAME);
const candidates = getPlatformCandidates();
const optionalDependencies = rootMetadata.optionalDependencies ?? {};
const selected = candidates.find((candidate) => optionalDependencies[candidate.packageName]);
if (!selected) {
throw new Error(
`No OpenCode binary package is available for ${process.platform}/${process.arch}`
);
}
const platformVersion = optionalDependencies[selected.packageName] ?? rootMetadata.version!;
const normalizedVersion = platformVersion.replace(/^[~^]/, '');
const platformMetadata = await fetchPackageMetadata(selected.packageName, normalizedVersion);
this.publishProgress({
phase: 'downloading',
detail: `Downloading ${selected.packageName}@${platformMetadata.version}...`,
});
const tarball = await downloadTarball(platformMetadata.dist!.tarball!, (progress) => {
this.publishProgress(progress);
});
verifyIntegrity(tarball, platformMetadata.dist!.integrity!);
this.publishProgress({ phase: 'installing', detail: 'Extracting OpenCode binary...' });
const binary = extractBinaryFromTarball(tarball);
const runtimeRoot = getRuntimeRootPath();
const tempDir = path.join(runtimeRoot, `installing-${process.pid}-${randomUUID()}`);
const versionDir = path.join(
runtimeRoot,
'versions',
platformMetadata.version!,
selected.packageName
);
const binaryPath = path.join(versionDir, getExecutableName());
await fsp.rm(tempDir, { recursive: true, force: true });
await fsp.mkdir(tempDir, { recursive: true });
const tempBinaryPath = path.join(tempDir, getExecutableName());
await fsp.writeFile(tempBinaryPath, binary);
if (process.platform !== 'win32') {
// Required so the downloaded OpenCode platform binary can be spawned.
// eslint-disable-next-line sonarjs/file-permissions -- app-managed CLI binary must be executable
await fsp.chmod(tempBinaryPath, 0o755);
}
this.publishProgress({ phase: 'installing', detail: 'Verifying OpenCode binary...' });
const { stdout } = await execCli(tempBinaryPath, ['--version'], {
timeout: VERSION_TIMEOUT_MS,
windowsHide: true,
});
await fsp.rm(versionDir, { recursive: true, force: true });
await fsp.mkdir(path.dirname(versionDir), { recursive: true });
await fsp.rename(tempDir, versionDir);
const manifest: OpenCodeRuntimeManifest = {
schemaVersion: CURRENT_MANIFEST_SCHEMA_VERSION,
version: stdout.trim() || platformMetadata.version!,
platformPackage: selected.packageName,
binaryPath,
integrity: platformMetadata.dist!.integrity!,
installedAt: new Date().toISOString(),
};
await fsp.writeFile(
getCurrentManifestPath(),
`${JSON.stringify(manifest, null, 2)}\n`,
'utf8'
);
const status: OpenCodeRuntimeStatus = {
installed: true,
binaryPath,
version: manifest.version,
source: 'app-managed',
state: 'ready',
progress: {
phase: 'ready',
percent: 100,
detail: `Installed OpenCode ${manifest.version}`,
},
};
this.publish(status);
return status;
} catch (error) {
const status: OpenCodeRuntimeStatus = {
installed: false,
source: 'missing',
state: 'failed',
error: getErrorMessage(error),
progress: {
phase: 'failed',
detail: getErrorMessage(error),
},
};
logger.error('Failed to install OpenCode runtime:', status.error);
this.publish(status);
return status;
}
}
}

View file

@ -24,6 +24,7 @@ export * from './FileWatcher';
export * from './HttpServer'; export * from './HttpServer';
export * from './LocalFileSystemProvider'; export * from './LocalFileSystemProvider';
export * from './NotificationManager'; export * from './NotificationManager';
export * from './OpenCodeRuntimeInstallerService';
export * from './PtyTerminalService'; export * from './PtyTerminalService';
export * from './ServiceContext'; export * from './ServiceContext';
export * from './ServiceContextRegistry'; export * from './ServiceContextRegistry';

View file

@ -1,5 +1,7 @@
import { getCachedShellEnv } from '@main/utils/shellEnv'; import { getCachedShellEnv } from '@main/utils/shellEnv';
import { resolveAppManagedOpenCodeRuntimeBinaryPath } from '../infrastructure/OpenCodeRuntimeInstallerService';
import { buildRuntimeBaseEnv } from './buildRuntimeBaseEnv'; import { buildRuntimeBaseEnv } from './buildRuntimeBaseEnv';
import { providerConnectionService } from './ProviderConnectionService'; import { providerConnectionService } from './ProviderConnectionService';
@ -34,6 +36,14 @@ export async function buildProviderAwareCliEnv(
shellEnv, shellEnv,
env: options.env, env: options.env,
}); });
const appManagedOpenCodeBinary = resolveAppManagedOpenCodeRuntimeBinaryPath();
if (
appManagedOpenCodeBinary &&
!env.CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH &&
(!resolvedProviderId || resolvedProviderId === 'opencode')
) {
env.CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH = appManagedOpenCodeBinary;
}
if (options.providerId) { if (options.providerId) {
if (!resolvedProviderId) { if (!resolvedProviderId) {

View file

@ -59,6 +59,7 @@ function pickDetectedMechanism(
export class TaskBoundaryParser { export class TaskBoundaryParser {
private cache = new Map<string, BoundaryCacheEntry>(); private cache = new Map<string, BoundaryCacheEntry>();
private inFlight = new Map<string, Promise<TaskBoundariesResult>>();
private readonly cacheTtl = 60 * 1000; // 60s private readonly cacheTtl = 60 * 1000; // 60s
/** Парсинг JSONL файла для обнаружения границ задач */ /** Парсинг JSONL файла для обнаружения границ задач */
@ -77,6 +78,23 @@ export class TaskBoundaryParser {
return cached.data; return cached.data;
} }
const inFlightKey = `${filePath}:${fileStat.mtimeMs}`;
const inFlight = this.inFlight.get(inFlightKey);
if (inFlight) return inFlight;
const promise = this.parseBoundariesUncached(filePath, fileStat.mtimeMs).finally(() => {
if (this.inFlight.get(inFlightKey) === promise) {
this.inFlight.delete(inFlightKey);
}
});
this.inFlight.set(inFlightKey, promise);
return promise;
}
private async parseBoundariesUncached(
filePath: string,
fileMtimeMs: number
): Promise<TaskBoundariesResult> {
// 2. Стриминг JSONL // 2. Стриминг JSONL
const boundaries: TaskBoundary[] = []; const boundaries: TaskBoundary[] = [];
const allToolUsesByLine = new Map<number, ToolUseInfo[]>(); const allToolUsesByLine = new Map<number, ToolUseInfo[]>();
@ -151,7 +169,7 @@ export class TaskBoundaryParser {
}; };
this.cache.set(filePath, { this.cache.set(filePath, {
data: result, data: result,
mtime: fileStat.mtimeMs, mtime: fileMtimeMs,
expiresAt: Date.now() + this.cacheTtl, expiresAt: Date.now() + this.cacheTtl,
}); });
return result; return result;
@ -166,6 +184,7 @@ export class TaskBoundaryParser {
/** Очистить кеш (для тестов) */ /** Очистить кеш (для тестов) */
clearCache(): void { clearCache(): void {
this.cache.clear(); this.cache.clear();
this.inFlight.clear();
} }
// ── Приватные методы ── // ── Приватные методы ──

View file

@ -27,6 +27,11 @@ interface ParsedSnippetsCacheEntry {
expiresAt: number; expiresAt: number;
} }
interface ParsedSnippetsResult {
snippets: SnippetDiff[];
mtime: number;
}
interface LogFileRef { interface LogFileRef {
filePath: string; filePath: string;
memberName: string; memberName: string;
@ -34,7 +39,9 @@ interface LogFileRef {
export class TaskChangeComputer { export class TaskChangeComputer {
private parsedSnippetsCache = new Map<string, ParsedSnippetsCacheEntry>(); private parsedSnippetsCache = new Map<string, ParsedSnippetsCacheEntry>();
private parsedSnippetsInFlight = new Map<string, Promise<ParsedSnippetsResult>>();
private readonly parsedSnippetsCacheTtl = 20 * 1000; private readonly parsedSnippetsCacheTtl = 20 * 1000;
private readonly maxParsedSnippetsCacheEntries = 1_000;
private static readonly JSONL_PARSE_CONCURRENCY = 6; private static readonly JSONL_PARSE_CONCURRENCY = 6;
constructor( constructor(
@ -367,9 +374,7 @@ export class TaskChangeComputer {
return results; return results;
} }
private async parseJSONLFile( private async parseJSONLFile(filePath: string): Promise<ParsedSnippetsResult> {
filePath: string
): Promise<{ snippets: SnippetDiff[]; mtime: number }> {
let fileMtime = 0; let fileMtime = 0;
try { try {
const fileStat = await stat(filePath); const fileStat = await stat(filePath);
@ -383,6 +388,23 @@ export class TaskChangeComputer {
return { snippets: [], mtime: 0 }; return { snippets: [], mtime: 0 };
} }
const inFlightKey = `${filePath}:${fileMtime}`;
const inFlight = this.parsedSnippetsInFlight.get(inFlightKey);
if (inFlight) return inFlight;
const promise = this.parseJSONLFileUncached(filePath, fileMtime).finally(() => {
if (this.parsedSnippetsInFlight.get(inFlightKey) === promise) {
this.parsedSnippetsInFlight.delete(inFlightKey);
}
});
this.parsedSnippetsInFlight.set(inFlightKey, promise);
return promise;
}
private async parseJSONLFileUncached(
filePath: string,
fileMtime: number
): Promise<ParsedSnippetsResult> {
const entries: Record<string, unknown>[] = []; const entries: Record<string, unknown>[] = [];
try { try {
@ -514,6 +536,11 @@ export class TaskChangeComputer {
mtime: fileMtime, mtime: fileMtime,
expiresAt: Date.now() + this.parsedSnippetsCacheTtl, expiresAt: Date.now() + this.parsedSnippetsCacheTtl,
}); });
while (this.parsedSnippetsCache.size > this.maxParsedSnippetsCacheEntries) {
const oldestKey = this.parsedSnippetsCache.keys().next().value;
if (!oldestKey) break;
this.parsedSnippetsCache.delete(oldestKey);
}
return { snippets, mtime: fileMtime }; return { snippets, mtime: fileMtime };
} }

View file

@ -38,7 +38,7 @@ const ATTRIBUTION_SCAN_LINES = 50;
/** Grace before task creation — logs cannot reference a task before it exists. */ /** Grace before task creation — logs cannot reference a task before it exists. */
const TASK_SINCE_GRACE_MS = 2 * 60 * 1000; const TASK_SINCE_GRACE_MS = 2 * 60 * 1000;
const FILE_MENTIONS_CACHE_MAX = 10_000; const TASK_MENTION_INDEX_CACHE_MAX = 1_000;
const ATTRIBUTION_CACHE_MAX = 5_000; const ATTRIBUTION_CACHE_MAX = 5_000;
/** Max concurrent file reads during parallel scan phases. */ /** Max concurrent file reads during parallel scan phases. */
@ -93,6 +93,19 @@ interface RootSessionAttribution {
firstTimestamp: string | null; firstTimestamp: string | null;
} }
interface ProjectSessionDiscovery {
projectDir: string;
projectId: string;
config: NonNullable<Awaited<ReturnType<TeamConfigReader['getConfig']>>>;
sessionIds: string[];
knownMembers: Set<string>;
}
interface TaskMentionIndex {
exactTaskIds: Set<string>;
lowerTaskIds: Set<string>;
}
type LogCandidate = type LogCandidate =
| { | {
kind: 'subagent'; kind: 'subagent';
@ -181,7 +194,8 @@ function collectTaskFreshnessRootDirs(candidates: readonly unknown[]): string[]
} }
export class TeamMemberLogsFinder { export class TeamMemberLogsFinder {
private readonly fileMentionsCache = new Map<string, boolean>(); private readonly taskMentionIndexCache = new Map<string, TaskMentionIndex>();
private readonly taskMentionIndexInFlight = new Map<string, Promise<TaskMentionIndex>>();
private readonly attributionCache = new Map< private readonly attributionCache = new Map<
string, string,
SubagentAttribution | RootSessionAttribution | null SubagentAttribution | RootSessionAttribution | null
@ -193,6 +207,11 @@ export class TeamMemberLogsFinder {
expiresAt: number; expiresAt: number;
} }
>(); >();
private readonly discoveryInFlight = new Map<
string,
{ generation: number; promise: Promise<ProjectSessionDiscovery | null> }
>();
private readonly discoveryGenerationByTeam = new Map<string, number>();
constructor( constructor(
private readonly configReader: TeamConfigReader = new TeamConfigReader(), private readonly configReader: TeamConfigReader = new TeamConfigReader(),
@ -234,7 +253,7 @@ export class TeamMemberLogsFinder {
// ── Collect and parallel-scan subagent files ── // ── Collect and parallel-scan subagent files ──
const candidates = await this.collectLogCandidates(projectDir, sessionIds, config); const candidates = await this.collectLogCandidates(projectDir, sessionIds, config);
const settled: (MemberLogSummary | null)[] = new Array(candidates.length).fill(null); const settled = Array<MemberLogSummary | null>(candidates.length).fill(null);
let nextIdx = 0; let nextIdx = 0;
const scanWorker = async (): Promise<void> => { const scanWorker = async (): Promise<void> => {
@ -406,13 +425,11 @@ export class TeamMemberLogsFinder {
// file missing or unreadable // file missing or unreadable
} }
} }
const tLead = performance.now();
// ── Collect all subagent file candidates ── // ── Collect all subagent file candidates ──
const candidates = await this.collectLogCandidates(projectDir, sessionIds, config); const candidates = await this.collectLogCandidates(projectDir, sessionIds, config);
// ── Parallel scan with concurrency limit ── // ── Parallel scan with concurrency limit ──
const settled: (MemberLogSummary | null)[] = new Array(candidates.length).fill(null); const settled = Array<MemberLogSummary | null>(candidates.length).fill(null);
let nextIdx = 0; let nextIdx = 0;
let mentionHits = 0; let mentionHits = 0;
@ -471,8 +488,6 @@ export class TeamMemberLogsFinder {
} }
const totalFiles = candidates.length; const totalFiles = candidates.length;
const step2Count = results.length; // count before step 3 (owner fallback) const step2Count = results.length; // count before step 3 (owner fallback)
const tScan = performance.now();
const normalizedOwner = const normalizedOwner =
typeof options?.owner === 'string' ? options.owner.trim() : options?.owner; typeof options?.owner === 'string' ? options.owner.trim() : options?.owner;
const isLeadOwner = const isLeadOwner =
@ -564,8 +579,6 @@ export class TeamMemberLogsFinder {
} }
} }
} }
const tOwner = performance.now();
// Dedup cumulative subagent snapshots: keep 1 file per sessionId+memberName (largest). // Dedup cumulative subagent snapshots: keep 1 file per sessionId+memberName (largest).
// In-process teammates produce cumulative JSONL files where each successive file // In-process teammates produce cumulative JSONL files where each successive file
// contains ALL lines from the previous + a new delta. The largest file is a superset. // contains ALL lines from the previous + a new delta. The largest file is a superset.
@ -592,7 +605,7 @@ export class TeamMemberLogsFinder {
// Safety net: filterChunksByWorkIntervals on frontend still filters content by time, // Safety net: filterChunksByWorkIntervals on frontend still filters content by time,
// so even if the wrong file is picked, only task-relevant chunks are shown. // so even if the wrong file is picked, only task-relevant chunks are shown.
const sorted = results.sort( const sorted = [...results].sort(
(a, b) => new Date(b.startTime).getTime() - new Date(a.startTime).getTime() (a, b) => new Date(b.startTime).getTime() - new Date(a.startTime).getTime()
); );
const tTotal = performance.now(); const tTotal = performance.now();
@ -623,15 +636,9 @@ export class TeamMemberLogsFinder {
since?: string; since?: string;
} }
): Promise<{ filePath: string; memberName: string }[]> { ): Promise<{ filePath: string; memberName: string }[]> {
const t0 = performance.now();
const discovery = await this.discoverProjectSessions(teamName); const discovery = await this.discoverProjectSessions(teamName);
const tDiscovery = performance.now();
if (!discovery) { if (!discovery) {
// console.log(
// `[perf] findLogFileRefsForTask(${taskId}) discovery=null ${(tDiscovery - t0).toFixed(0)}ms`
// );
return []; return [];
} }
@ -679,14 +686,11 @@ export class TeamMemberLogsFinder {
// file missing or unreadable // file missing or unreadable
} }
} }
const tLead = performance.now();
// ── Collect all subagent file candidates ── // ── Collect all subagent file candidates ──
const candidates = await this.collectLogCandidates(projectDir, sessionIds, config); const candidates = await this.collectLogCandidates(projectDir, sessionIds, config);
// ── Parallel scan with concurrency limit ── // ── Parallel scan with concurrency limit ──
let nextIdx = 0; let nextIdx = 0;
let mentionHits = 0;
const scanWorker = async (): Promise<void> => { const scanWorker = async (): Promise<void> => {
while (nextIdx < candidates.length) { while (nextIdx < candidates.length) {
@ -695,7 +699,6 @@ export class TeamMemberLogsFinder {
try { try {
if (!(await this.fileMentionsTaskIdCached(c.filePath, teamName, taskId, false, sinceMs))) if (!(await this.fileMentionsTaskIdCached(c.filePath, teamName, taskId, false, sinceMs)))
continue; continue;
mentionHits++;
const attribution = const attribution =
c.kind === 'subagent' c.kind === 'subagent'
? await this.attributeSubagent(c.filePath, knownMembers) ? await this.attributeSubagent(c.filePath, knownMembers)
@ -717,8 +720,6 @@ export class TeamMemberLogsFinder {
await Promise.all( await Promise.all(
Array.from({ length: Math.min(SCAN_CONCURRENCY, candidates.length) }, () => scanWorker()) Array.from({ length: Math.min(SCAN_CONCURRENCY, candidates.length) }, () => scanWorker())
); );
const totalFiles = candidates.length;
const tScan = performance.now();
const normalizedOwner = const normalizedOwner =
typeof options?.owner === 'string' ? options.owner.trim() : options?.owner; typeof options?.owner === 'string' ? options.owner.trim() : options?.owner;
@ -802,8 +803,6 @@ export class TeamMemberLogsFinder {
); );
} }
} }
const tOwner = performance.now();
// Dedup cumulative subagent snapshots (same logic as findLogsForTask). // Dedup cumulative subagent snapshots (same logic as findLogsForTask).
{ {
const refsByKey = new Map<string, (typeof refs)[0]>(); const refsByKey = new Map<string, (typeof refs)[0]>();
@ -833,17 +832,6 @@ export class TeamMemberLogsFinder {
} }
const sortedRefs = [...refs].sort((a, b) => b.sortTime - a.sortTime); const sortedRefs = [...refs].sort((a, b) => b.sortTime - a.sortTime);
const tTotal = performance.now();
// console.log(
// `[perf] findLogFileRefsForTask(${taskId}@${teamName}) ` +
// `total=${(tTotal - t0).toFixed(0)}ms | ` +
// `discovery=${(tDiscovery - t0).toFixed(0)}ms | ` +
// `lead=${(tLead - tDiscovery).toFixed(0)}ms | ` +
// `scan=${(tScan - tLead).toFixed(0)}ms (${totalFiles} files, ${mentionHits} hits) | ` +
// `owner=${(tOwner - tScan).toFixed(0)}ms | ` +
// `sessions=${sessionIds.length} | results=${sortedRefs.length}`
// );
return sortedRefs.map(({ filePath, memberName }) => ({ filePath, memberName })); return sortedRefs.map(({ filePath, memberName }) => ({ filePath, memberName }));
} }
@ -1160,23 +1148,40 @@ export class TeamMemberLogsFinder {
private async discoverProjectSessions( private async discoverProjectSessions(
teamName: string, teamName: string,
options?: { forceRefresh?: boolean } options?: { forceRefresh?: boolean }
): Promise<{ ): Promise<ProjectSessionDiscovery | null> {
projectDir: string; let generation = this.discoveryGenerationByTeam.get(teamName) ?? 0;
projectId: string;
config: NonNullable<Awaited<ReturnType<TeamConfigReader['getConfig']>>>;
sessionIds: string[];
knownMembers: Set<string>;
} | null> {
if (options?.forceRefresh) { if (options?.forceRefresh) {
generation += 1;
this.discoveryGenerationByTeam.set(teamName, generation);
this.discoveryCache.delete(teamName); this.discoveryCache.delete(teamName);
this.discoveryInFlight.delete(teamName);
} else { } else {
// Check discovery cache — avoids re-reading config/dirs within rapid successive calls // Check discovery cache — avoids re-reading config/dirs within rapid successive calls
const cached = this.discoveryCache.get(teamName); const cached = this.discoveryCache.get(teamName);
if (cached && cached.expiresAt > Date.now()) { if (cached && cached.expiresAt > Date.now()) {
return cached.result; return cached.result;
} }
const inFlight = this.discoveryInFlight.get(teamName);
if (inFlight) {
return inFlight.promise;
}
} }
const promise = this.loadProjectSessionDiscovery(teamName, options, generation).finally(() => {
const current = this.discoveryInFlight.get(teamName);
if (current?.promise === promise) {
this.discoveryInFlight.delete(teamName);
}
});
this.discoveryInFlight.set(teamName, { generation, promise });
return promise;
}
private async loadProjectSessionDiscovery(
teamName: string,
options: { forceRefresh?: boolean } | undefined,
generation: number
): Promise<ProjectSessionDiscovery | null> {
const context = await this.projectResolver.getContext(teamName, options); const context = await this.projectResolver.getContext(teamName, options);
if (!context) { if (!context) {
logger.debug(`No transcript context for team "${teamName}"`); logger.debug(`No transcript context for team "${teamName}"`);
@ -1209,10 +1214,12 @@ export class TeamMemberLogsFinder {
} }
const discovery = { projectDir, projectId, config, sessionIds, knownMembers }; const discovery = { projectDir, projectId, config, sessionIds, knownMembers };
if ((this.discoveryGenerationByTeam.get(teamName) ?? 0) === generation) {
this.discoveryCache.set(teamName, { this.discoveryCache.set(teamName, {
result: discovery, result: discovery,
expiresAt: Date.now() + DISCOVERY_CACHE_TTL, expiresAt: Date.now() + DISCOVERY_CACHE_TTL,
}); });
}
return discovery; return discovery;
} }
@ -1347,39 +1354,71 @@ export class TeamMemberLogsFinder {
if (sinceMs != null && mtimeMs < sinceMs - TASK_SINCE_GRACE_MS) { if (sinceMs != null && mtimeMs < sinceMs - TASK_SINCE_GRACE_MS) {
return false; return false;
} }
const cacheKey = `${filePath}:${mtimeMs}:${taskId}:${teamName}:${assumeTeam}`; const cacheKey = `${filePath}:${mtimeMs}:${teamName}:${assumeTeam}`;
const cached = this.fileMentionsCache.get(cacheKey); const index = await this.getTaskMentionIndex(cacheKey, filePath, teamName, assumeTeam);
if (cached !== undefined) return cached; return this.taskMentionIndexHasTaskId(index, taskId);
const result = await this.fileMentionsTaskId(filePath, teamName, taskId, assumeTeam);
this.fileMentionsCache.set(cacheKey, result);
if (this.fileMentionsCache.size > FILE_MENTIONS_CACHE_MAX) {
const keys = [...this.fileMentionsCache.keys()];
for (let i = 0; i < Math.min(keys.length / 2, 50); i++) {
this.fileMentionsCache.delete(keys[i]);
}
}
return result;
} }
private async fileMentionsTaskId( private async getTaskMentionIndex(
cacheKey: string,
filePath: string, filePath: string,
teamName: string, teamName: string,
taskId: string, assumeTeam: boolean
assumeTeam: boolean = false ): Promise<TaskMentionIndex> {
): Promise<boolean> { const cached = this.taskMentionIndexCache.get(cacheKey);
const teamLower = teamName.trim().toLowerCase(); if (cached) return cached;
const taskIdStr = taskId.trim();
// CLI agents often use the short displayId (first 8 chars of UUID) in tool inputs, const inFlight = this.taskMentionIndexInFlight.get(cacheKey);
// while the UI passes the full UUID. Match both forms to bridge this gap. if (inFlight) return inFlight;
const promise = this.buildTaskMentionIndex(filePath, teamName, assumeTeam)
.then((index) => {
this.taskMentionIndexCache.set(cacheKey, index);
while (this.taskMentionIndexCache.size > TASK_MENTION_INDEX_CACHE_MAX) {
const oldestKey = this.taskMentionIndexCache.keys().next().value;
if (!oldestKey) break;
this.taskMentionIndexCache.delete(oldestKey);
}
return index;
})
.finally(() => {
if (this.taskMentionIndexInFlight.get(cacheKey) === promise) {
this.taskMentionIndexInFlight.delete(cacheKey);
}
});
this.taskMentionIndexInFlight.set(cacheKey, promise);
return promise;
}
private taskMentionIndexHasTaskId(index: TaskMentionIndex, taskId: string): boolean {
const taskIdStr = taskId.trim();
if (!taskIdStr) return false;
if (index.exactTaskIds.has(taskIdStr)) return true;
const taskIdDisplayForm = const taskIdDisplayForm =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(taskIdStr) /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(taskIdStr)
? taskIdStr.slice(0, 8).toLowerCase() ? taskIdStr.slice(0, 8).toLowerCase()
: null; : null;
return taskIdDisplayForm !== null && index.lowerTaskIds.has(taskIdDisplayForm);
}
const matchesTaskId = (candidate: string): boolean => private addTaskMention(index: TaskMentionIndex, rawTaskId: string): void {
candidate === taskIdStr || const taskId = rawTaskId.trim();
(taskIdDisplayForm !== null && candidate.toLowerCase() === taskIdDisplayForm); if (!taskId) return;
index.exactTaskIds.add(taskId);
index.lowerTaskIds.add(taskId.toLowerCase());
}
private async buildTaskMentionIndex(
filePath: string,
teamName: string,
assumeTeam: boolean
): Promise<TaskMentionIndex> {
const teamLower = teamName.trim().toLowerCase();
const index: TaskMentionIndex = {
exactTaskIds: new Set<string>(),
lowerTaskIds: new Set<string>(),
};
const pendingTaskIdsWithoutTeam = new Set<string>();
const extractTaskIdFromUnknown = (raw: unknown): string | null => { const extractTaskIdFromUnknown = (raw: unknown): string | null => {
if (typeof raw === 'string') return raw.trim(); if (typeof raw === 'string') return raw.trim();
@ -1430,7 +1469,13 @@ export class TeamMemberLogsFinder {
const stream = createReadStream(filePath, { encoding: 'utf8' }); const stream = createReadStream(filePath, { encoding: 'utf8' });
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity }); const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
let teamSeen = assumeTeam; let teamSeen = assumeTeam;
let taskSeenWithoutTeam = false; const acceptPendingTaskIds = (): void => {
if (!teamSeen || pendingTaskIdsWithoutTeam.size === 0) return;
for (const pendingTaskId of pendingTaskIdsWithoutTeam) {
this.addTaskMention(index, pendingTaskId);
}
pendingTaskIdsWithoutTeam.clear();
};
for await (const line of rl) { for await (const line of rl) {
const trimmed = line.trim(); const trimmed = line.trim();
if (!trimmed) continue; if (!trimmed) continue;
@ -1466,16 +1511,13 @@ export class TeamMemberLogsFinder {
matchesTeamMentionText(b.text) matchesTeamMentionText(b.text)
) { ) {
teamSeen = true; teamSeen = true;
acceptPendingTaskIds();
break; break;
} }
} }
} }
if (teamSeen && taskSeenWithoutTeam) { acceptPendingTaskIds();
rl.close();
stream.destroy();
return true;
}
for (const block of content) { for (const block of content) {
if (!block || typeof block !== 'object') continue; if (!block || typeof block !== 'object') continue;
@ -1496,32 +1538,25 @@ export class TeamMemberLogsFinder {
const inputTeam = extractTeamFromInput(input); const inputTeam = extractTeamFromInput(input);
const rawTaskId = input.taskId ?? input.task_id; const rawTaskId = input.taskId ?? input.task_id;
const inputTaskId = extractTaskIdFromUnknown(rawTaskId); const inputTaskId = extractTaskIdFromUnknown(rawTaskId);
if (inputTaskId && matchesTaskId(inputTaskId)) { if (inputTaskId) {
// If team is present in the input, require exact match. // If team is present in the input, require exact match.
if (inputTeam) { if (inputTeam) {
if (inputTeam.toLowerCase() === teamLower) { if (inputTeam.toLowerCase() === teamLower) {
rl.close(); this.addTaskMention(index, inputTaskId);
stream.destroy();
return true;
} }
} else { } else {
// Some agents use TaskUpdate without team_name (common in Solo). // Some agents use TaskUpdate without team_name (common in Solo).
// Only accept when we have a separate team marker for this file. // Only accept when we have a separate team marker for this file.
if (teamSeen) { if (teamSeen) {
rl.close(); this.addTaskMention(index, inputTaskId);
stream.destroy(); } else {
return true; pendingTaskIdsWithoutTeam.add(inputTaskId);
} }
taskSeenWithoutTeam = true;
} }
} }
} }
if (teamSeen && taskSeenWithoutTeam) { acceptPendingTaskIds();
rl.close();
stream.destroy();
return true;
}
} catch { } catch {
// ignore parse errors // ignore parse errors
} }
@ -1531,7 +1566,7 @@ export class TeamMemberLogsFinder {
} catch { } catch {
// ignore // ignore
} }
return false; return index;
} }
private extractEntryContent(entry: Record<string, unknown>): unknown[] | null { private extractEntryContent(entry: Record<string, unknown>): unknown[] | null {
@ -1899,11 +1934,12 @@ export class TeamMemberLogsFinder {
const role = this.extractRole(entry); const role = this.extractRole(entry);
const textContent = this.extractTextContent(entry); const textContent = this.extractTextContent(entry);
if (!teamMatched && textContent && textContent.toLowerCase().includes(normalizedTeam)) { const lowerTextContent = textContent?.toLowerCase();
if (!teamMatched && lowerTextContent?.includes(normalizedTeam)) {
if ( if (
textContent.toLowerCase().includes(`on team "${normalizedTeam}"`) || lowerTextContent.includes(`on team "${normalizedTeam}"`) ||
textContent.toLowerCase().includes(`on team '${normalizedTeam}'`) || lowerTextContent.includes(`on team '${normalizedTeam}'`) ||
textContent.toLowerCase().includes(`(${normalizedTeam})`) lowerTextContent.includes(`(${normalizedTeam})`)
) { ) {
teamMatched = true; teamMatched = true;
} }

View file

@ -9139,6 +9139,11 @@ export class TeamProvisioningService {
if (ledgerRecord?.createdAt === now) { if (ledgerRecord?.createdAt === now) {
this.logOpenCodePromptDeliveryEvent('opencode_prompt_delivery_ledger_created', ledgerRecord); this.logOpenCodePromptDeliveryEvent('opencode_prompt_delivery_ledger_created', ledgerRecord);
} }
const deliveryAttemptId = ledgerRecord
? [ledgerRecord.id, ledgerRecord.attempts + 1, ledgerRecord.payloadHash.slice(0, 12)].join(
':'
)
: undefined;
if (ledgerRecord && ledger && messageId) { if (ledgerRecord && ledger && messageId) {
let proof = await this.applyOpenCodeVisibleDestinationProof({ let proof = await this.applyOpenCodeVisibleDestinationProof({
@ -9412,6 +9417,7 @@ export class TeamProvisioningService {
cwd, cwd,
text: deliveryText, text: deliveryText,
messageId: input.messageId, messageId: input.messageId,
deliveryAttemptId,
fileParts: openCodeFileParts, fileParts: openCodeFileParts,
replyRecipient: input.replyRecipient, replyRecipient: input.replyRecipient,
actionMode: input.actionMode, actionMode: input.actionMode,

View file

@ -170,6 +170,8 @@ export interface OpenCodeSendMessageCommandBody {
memberName: string; memberName: string;
text: string; text: string;
messageId?: string; messageId?: string;
deliveryAttemptId?: string;
payloadHash?: string;
fileParts?: { fileParts?: {
type: 'file'; type: 'file';
mime: 'image/png' | 'image/jpeg' | 'image/webp'; mime: 'image/png' | 'image/jpeg' | 'image/webp';
@ -235,6 +237,45 @@ export interface OpenCodeSendMessageCommandData {
diagnostics: OpenCodeTeamBridgeDiagnostic[]; diagnostics: OpenCodeTeamBridgeDiagnostic[];
} }
export interface OpenCodeCommandStatusCommandBody {
originalCommand: 'opencode.sendMessage';
originalRequestId?: string;
deliveryAttemptId?: string;
teamId?: string;
teamName?: string;
laneId?: string;
memberName?: string;
messageId?: string;
payloadHash?: string;
projectPath?: string;
runId?: string;
}
export type OpenCodeCommandStatusState =
| 'unknown'
| 'received'
| 'prompt_submitting'
| 'prompt_accepted'
| 'turn_observed'
| 'reconciled'
| 'failed_before_accept'
| 'failed_after_accept'
| 'precondition_mismatch';
export interface OpenCodeCommandStatusCommandData {
status: OpenCodeCommandStatusState;
safeToRetry: boolean;
accepted: boolean;
originalRequestId?: string;
deliveryAttemptId?: string;
sessionId?: string;
runtimePid?: number;
runtimePromptMessageId?: string;
prePromptCursor?: string | null;
sendMessageData?: OpenCodeSendMessageCommandData;
diagnostics: string[];
}
export interface OpenCodeObserveMessageDeliveryCommandBody { export interface OpenCodeObserveMessageDeliveryCommandBody {
runId?: string; runId?: string;
laneId: string; laneId: string;

View file

@ -1,4 +1,7 @@
import { randomUUID } from 'crypto';
import type { OpenCodeTeamRuntimeBridgePort } from '../../runtime/OpenCodeTeamRuntimeAdapter'; import type { OpenCodeTeamRuntimeBridgePort } from '../../runtime/OpenCodeTeamRuntimeAdapter';
import { stableHash } from './OpenCodeBridgeCommandContract';
import type { import type {
OpenCodeTeamLaunchReadiness, OpenCodeTeamLaunchReadiness,
OpenCodeTeamLaunchReadinessState, OpenCodeTeamLaunchReadinessState,
@ -11,6 +14,8 @@ import type {
OpenCodeBridgeFailureKind, OpenCodeBridgeFailureKind,
OpenCodeBridgeResult, OpenCodeBridgeResult,
OpenCodeBridgeRuntimeSnapshot, OpenCodeBridgeRuntimeSnapshot,
OpenCodeCommandStatusCommandBody,
OpenCodeCommandStatusCommandData,
OpenCodeCleanupHostsCommandBody, OpenCodeCleanupHostsCommandBody,
OpenCodeCleanupHostsCommandData, OpenCodeCleanupHostsCommandData,
OpenCodeLaunchTeamCommandBody, OpenCodeLaunchTeamCommandBody,
@ -72,6 +77,16 @@ const DEFAULT_OBSERVE_TIMEOUT_MS = 20_000;
const DEFAULT_STOP_TIMEOUT_MS = 30_000; const DEFAULT_STOP_TIMEOUT_MS = 30_000;
const DEFAULT_CLEANUP_TIMEOUT_MS = 10_000; const DEFAULT_CLEANUP_TIMEOUT_MS = 10_000;
const DEFAULT_BACKFILL_TIMEOUT_MS = 45_000; const DEFAULT_BACKFILL_TIMEOUT_MS = 45_000;
const DEFAULT_COMMAND_STATUS_TIMEOUT_MS = 5_000;
function isCommandStatusRecoveryEnabled(): boolean {
return process.env.CLAUDE_TEAM_OPENCODE_COMMAND_STATUS_RECOVERY === '1';
}
function buildSendPayloadHash(input: OpenCodeSendMessageCommandBody): string {
const { payloadHash: _payloadHash, ...hashable } = input;
return stableHash(hashable);
}
export class OpenCodeReadinessBridge implements OpenCodeTeamRuntimeBridgePort { export class OpenCodeReadinessBridge implements OpenCodeTeamRuntimeBridgePort {
private readonly lastRuntimeSnapshotsByProjectPath = new Map< private readonly lastRuntimeSnapshotsByProjectPath = new Map<
@ -215,19 +230,34 @@ export class OpenCodeReadinessBridge implements OpenCodeTeamRuntimeBridgePort {
async sendOpenCodeTeamMessage( async sendOpenCodeTeamMessage(
input: OpenCodeSendMessageCommandBody input: OpenCodeSendMessageCommandBody
): Promise<OpenCodeSendMessageCommandData> { ): Promise<OpenCodeSendMessageCommandData> {
const commandRequestId = `opencode-send-${randomUUID()}`;
const body: OpenCodeSendMessageCommandBody = {
...input,
payloadHash: input.payloadHash ?? buildSendPayloadHash(input),
};
const result = await this.bridge.execute< const result = await this.bridge.execute<
OpenCodeSendMessageCommandBody, OpenCodeSendMessageCommandBody,
OpenCodeSendMessageCommandData OpenCodeSendMessageCommandData
>('opencode.sendMessage', input, { >('opencode.sendMessage', body, {
cwd: input.projectPath, cwd: body.projectPath,
timeoutMs: this.options.sendTimeoutMs ?? DEFAULT_SEND_TIMEOUT_MS, timeoutMs: this.options.sendTimeoutMs ?? DEFAULT_SEND_TIMEOUT_MS,
requestId: commandRequestId,
}); });
if (result.ok) { if (result.ok) {
return result.data; return result.data;
} }
if (result.error.kind === 'timeout' && isCommandStatusRecoveryEnabled()) {
const recovered = await this.recoverTimedOutSendMessage({
originalRequestId: commandRequestId,
body,
});
if (recovered) {
return recovered;
}
}
return { return {
accepted: false, accepted: false,
memberName: input.memberName, memberName: body.memberName,
diagnostics: [ diagnostics: [
{ {
code: result.error.kind, code: result.error.kind,
@ -243,6 +273,75 @@ export class OpenCodeReadinessBridge implements OpenCodeTeamRuntimeBridgePort {
}; };
} }
private async recoverTimedOutSendMessage(input: {
originalRequestId: string;
body: OpenCodeSendMessageCommandBody;
}): Promise<OpenCodeSendMessageCommandData | null> {
const statusBody: OpenCodeCommandStatusCommandBody = {
originalCommand: 'opencode.sendMessage',
originalRequestId: input.originalRequestId,
deliveryAttemptId: input.body.deliveryAttemptId,
teamId: input.body.teamId,
teamName: input.body.teamName,
laneId: input.body.laneId,
memberName: input.body.memberName,
messageId: input.body.messageId,
payloadHash: input.body.payloadHash,
projectPath: input.body.projectPath,
runId: input.body.runId,
};
const statusResult = await this.bridge.execute<
OpenCodeCommandStatusCommandBody,
OpenCodeCommandStatusCommandData
>('opencode.commandStatus', statusBody, {
cwd: input.body.projectPath,
timeoutMs: DEFAULT_COMMAND_STATUS_TIMEOUT_MS,
});
if (!statusResult.ok) {
return null;
}
const status = statusResult.data;
if (status.originalRequestId && status.originalRequestId !== input.originalRequestId) {
return null;
}
if (
input.body.deliveryAttemptId &&
status.deliveryAttemptId &&
status.deliveryAttemptId !== input.body.deliveryAttemptId
) {
return null;
}
if (status.status === 'precondition_mismatch' || status.accepted !== true) {
return null;
}
const diagnostics = [
{
code: 'opencode_send_recovered_after_bridge_timeout',
severity: 'warning' as const,
message: 'OpenCode bridge outcome recovered after timeout.',
},
...status.diagnostics.map((message) => ({
code: 'opencode_command_status',
severity: 'info' as const,
message,
})),
];
if (status.sendMessageData?.accepted === true) {
return {
...status.sendMessageData,
diagnostics: [...diagnostics, ...status.sendMessageData.diagnostics],
};
}
return {
accepted: true,
memberName: input.body.memberName,
sessionId: status.sessionId,
runtimePid: status.runtimePid,
prePromptCursor: status.prePromptCursor,
diagnostics,
};
}
async observeOpenCodeTeamMessageDelivery( async observeOpenCodeTeamMessageDelivery(
input: OpenCodeObserveMessageDeliveryCommandBody input: OpenCodeObserveMessageDeliveryCommandBody
): Promise<OpenCodeObserveMessageDeliveryCommandData> { ): Promise<OpenCodeObserveMessageDeliveryCommandData> {

View file

@ -62,6 +62,7 @@ export interface OpenCodeTeamRuntimeMessageInput {
cwd: string; cwd: string;
text: string; text: string;
messageId?: string; messageId?: string;
deliveryAttemptId?: string;
fileParts?: OpenCodeSendMessageCommandBody['fileParts']; fileParts?: OpenCodeSendMessageCommandBody['fileParts'];
replyRecipient?: string; replyRecipient?: string;
actionMode?: AgentActionMode; actionMode?: AgentActionMode;
@ -331,6 +332,7 @@ export class OpenCodeTeamRuntimeAdapter implements TeamLaunchRuntimeAdapter {
memberName: input.memberName, memberName: input.memberName,
text: buildOpenCodeRuntimeMessageText(input), text: buildOpenCodeRuntimeMessageText(input),
messageId: input.messageId, messageId: input.messageId,
...(input.deliveryAttemptId ? { deliveryAttemptId: input.deliveryAttemptId } : {}),
fileParts: input.fileParts, fileParts: input.fileParts,
actionMode: input.actionMode, actionMode: input.actionMode,
messageKind: input.messageKind, messageKind: input.messageKind,

View file

@ -25,6 +25,8 @@ const SECRET_VALUE_PATTERNS = [
const DISK_FULL_MESSAGE = const DISK_FULL_MESSAGE =
'Local disk is full (ENOSPC). Free disk space and retry OpenCode delivery.'; 'Local disk is full (ENOSPC). Free disk space and retry OpenCode delivery.';
const OPENCODE_BRIDGE_OUTCOME_UNKNOWN_AFTER_TIMEOUT_MESSAGE =
'OpenCode bridge outcome unknown after timeout, retrying/observing.';
const RUNTIME_DIAGNOSTIC_RULES: readonly RuntimeDiagnosticRule[] = [ const RUNTIME_DIAGNOSTIC_RULES: readonly RuntimeDiagnosticRule[] = [
{ {
@ -75,6 +77,16 @@ const RUNTIME_DIAGNOSTIC_RULES: readonly RuntimeDiagnosticRule[] = [
tokens: ['codex native exec timed out'], tokens: ['codex native exec timed out'],
priority: 80, priority: 80,
}, },
{
reasonCode: 'backend_error',
tokens: [
'opencode_prompt_acceptance_unknown_after_bridge_timeout',
'opencode bridge outcome unknown after timeout',
],
priority: 20,
generic: true,
normalizeMessage: () => OPENCODE_BRIDGE_OUTCOME_UNKNOWN_AFTER_TIMEOUT_MESSAGE,
},
{ {
reasonCode: 'backend_error', reasonCode: 'backend_error',
tokens: ['opencode bridge command timed out'], tokens: ['opencode bridge command timed out'],

View file

@ -6,7 +6,7 @@
import { getClaudeBasePath } from '@main/utils/pathDecoder'; import { getClaudeBasePath } from '@main/utils/pathDecoder';
import { getCachedShellEnv, getShellPreferredHome } from '@main/utils/shellEnv'; import { getCachedShellEnv, getShellPreferredHome } from '@main/utils/shellEnv';
import { realpathSync } from 'fs'; import { realpathSync } from 'fs';
import { join as pathJoin, posix as pathPosix, win32 as pathWin32 } from 'path'; import { posix as pathPosix, win32 as pathWin32 } from 'path';
/** /**
* Build a PATH string that prefers the CLI binary directory, then the user's * Build a PATH string that prefers the CLI binary directory, then the user's
@ -44,14 +44,23 @@ export function buildMergedCliPath(binaryPath?: string | null): string {
} else if (process.platform === 'win32') { } else if (process.platform === 'win32') {
extraDirs.push( extraDirs.push(
vendorBinDir, vendorBinDir,
pathJoin(home, 'AppData', 'Roaming', 'npm'), pathWin32.join(home, 'AppData', 'Roaming', 'npm'),
pathJoin(home, 'scoop', 'shims') pathWin32.join(home, 'scoop', 'shims'),
pathWin32.join(home, '.bun', 'bin'),
pathWin32.join(home, '.cargo', 'bin'),
pathWin32.join(home, '.volta', 'bin')
); );
if (process.env.LOCALAPPDATA) { if (process.env.LOCALAPPDATA) {
extraDirs.push(pathJoin(process.env.LOCALAPPDATA, 'Programs', 'claude')); extraDirs.push(
pathWin32.join(process.env.LOCALAPPDATA, 'Programs', 'claude'),
pathWin32.join(process.env.LOCALAPPDATA, 'pnpm')
);
} }
if (process.env.ProgramFiles) { if (process.env.ProgramFiles) {
extraDirs.push(pathJoin(process.env.ProgramFiles, 'claude')); extraDirs.push(
pathWin32.join(process.env.ProgramFiles, 'claude'),
pathWin32.join(process.env.ProgramFiles, 'nodejs')
);
} }
} else { } else {
extraDirs.push( extraDirs.push(
@ -60,8 +69,20 @@ export function buildMergedCliPath(binaryPath?: string | null): string {
pathPosix.join(home, '.local', 'bin'), pathPosix.join(home, '.local', 'bin'),
pathPosix.join(home, '.npm-global', 'bin'), pathPosix.join(home, '.npm-global', 'bin'),
pathPosix.join(home, '.npm', 'bin'), pathPosix.join(home, '.npm', 'bin'),
pathPosix.join(home, '.asdf', 'shims'),
pathPosix.join(home, '.local', 'share', 'mise', 'shims'),
pathPosix.join(home, '.volta', 'bin'),
pathPosix.join(home, 'Library', 'pnpm'),
pathPosix.join(home, '.local', 'share', 'pnpm'),
pathPosix.join(home, '.cargo', 'bin'),
pathPosix.join(home, '.nix-profile', 'bin'),
'/usr/local/bin', '/usr/local/bin',
'/opt/homebrew/bin' '/opt/homebrew/bin',
'/opt/local/bin',
'/usr/bin',
'/bin',
'/usr/sbin',
'/sbin'
); );
} }

View file

@ -16,9 +16,13 @@ import { spawn } from 'child_process';
const logger = createLogger('Utils:shellEnv'); const logger = createLogger('Utils:shellEnv');
const SHELL_ENV_TIMEOUT_MS = 12_000; const SHELL_ENV_TIMEOUT_MS = 12_000;
const SHELL_ENV_BEST_EFFORT_TIMEOUT_MS = 5_000;
const SHELL_ENV_FAILURE_COOLDOWN_MS = 60_000;
let cachedInteractiveShellEnv: NodeJS.ProcessEnv | null = null; let cachedInteractiveShellEnv: NodeJS.ProcessEnv | null = null;
let shellEnvResolvePromise: Promise<NodeJS.ProcessEnv> | null = null; let shellEnvResolvePromise: Promise<NodeJS.ProcessEnv> | null = null;
let shellEnvFailureCooldownUntil = 0;
let lastShellEnvFailureMessage: string | null = null;
export interface ShellEnvResolveProgress { export interface ShellEnvResolveProgress {
phase: string; phase: string;
@ -29,6 +33,19 @@ export interface ShellEnvResolveOptions {
onProgress?: (progress: ShellEnvResolveProgress) => void; onProgress?: (progress: ShellEnvResolveProgress) => void;
} }
export interface ShellEnvBestEffortResolveOptions extends ShellEnvResolveOptions {
/**
* Max time to wait on the critical path before returning fallbackEnv.
* The full shell resolve continues in the background and caches on success.
*/
timeoutMs?: number;
/**
* Returned when shell env is not ready quickly enough. This is intentionally
* not cached as a real shell env.
*/
fallbackEnv?: NodeJS.ProcessEnv;
}
function emitProgress( function emitProgress(
options: ShellEnvResolveOptions | undefined, options: ShellEnvResolveOptions | undefined,
phase: string, phase: string,
@ -37,6 +54,16 @@ function emitProgress(
options?.onProgress?.({ phase, message }); options?.onProgress?.({ phase, message });
} }
function rememberShellEnvFailure(message: string): void {
lastShellEnvFailureMessage = message;
shellEnvFailureCooldownUntil = Date.now() + SHELL_ENV_FAILURE_COOLDOWN_MS;
}
function clearShellEnvFailure(): void {
lastShellEnvFailureMessage = null;
shellEnvFailureCooldownUntil = 0;
}
function parseNullSeparatedEnv(content: string): NodeJS.ProcessEnv { function parseNullSeparatedEnv(content: string): NodeJS.ProcessEnv {
const parsed: NodeJS.ProcessEnv = {}; const parsed: NodeJS.ProcessEnv = {};
const lines = content.split('\0'); const lines = content.split('\0');
@ -93,12 +120,22 @@ async function readShellEnv(shellPath: string, args: string[]): Promise<NodeJS.P
reject(error); reject(error);
} }
}); });
child.once('close', () => { child.once('close', (code: number | null, signal: NodeJS.Signals | null) => {
if (timeoutHandle) { if (timeoutHandle) {
clearTimeout(timeoutHandle); clearTimeout(timeoutHandle);
} }
if (!settled) { if (!settled) {
settled = true; settled = true;
if (chunks.length === 0 && (code !== 0 || signal)) {
reject(
new Error(
signal
? `shell env command exited with signal ${signal}`
: `shell env command exited with code ${code}`
)
);
return;
}
resolve(Buffer.concat(chunks).toString('utf8')); resolve(Buffer.concat(chunks).toString('utf8'));
} }
}); });
@ -135,6 +172,7 @@ export async function resolveInteractiveShellEnv(
emitProgress(options, 'shell-env-login', 'Reading login shell environment...'); emitProgress(options, 'shell-env-login', 'Reading login shell environment...');
const loginEnv = await readShellEnv(shellPath, ['-lic', 'env -0']); const loginEnv = await readShellEnv(shellPath, ['-lic', 'env -0']);
cachedInteractiveShellEnv = loginEnv; cachedInteractiveShellEnv = loginEnv;
clearShellEnvFailure();
return loginEnv; return loginEnv;
} catch (loginError) { } catch (loginError) {
const loginMessage = loginError instanceof Error ? loginError.message : String(loginError); const loginMessage = loginError instanceof Error ? loginError.message : String(loginError);
@ -143,11 +181,13 @@ export async function resolveInteractiveShellEnv(
emitProgress(options, 'shell-env-interactive', 'Trying interactive shell environment...'); emitProgress(options, 'shell-env-interactive', 'Trying interactive shell environment...');
const interactiveEnv = await readShellEnv(shellPath, ['-ic', 'env -0']); const interactiveEnv = await readShellEnv(shellPath, ['-ic', 'env -0']);
cachedInteractiveShellEnv = interactiveEnv; cachedInteractiveShellEnv = interactiveEnv;
clearShellEnvFailure();
return interactiveEnv; return interactiveEnv;
} catch (interactiveError) { } catch (interactiveError) {
const interactiveMessage = const interactiveMessage =
interactiveError instanceof Error ? interactiveError.message : String(interactiveError); interactiveError instanceof Error ? interactiveError.message : String(interactiveError);
logger.warn(`Failed to resolve interactive shell env: ${interactiveMessage}`); logger.warn(`Failed to resolve interactive shell env: ${interactiveMessage}`);
rememberShellEnvFailure(interactiveMessage);
emitProgress(options, 'shell-env-fallback', 'Using current process environment...'); emitProgress(options, 'shell-env-fallback', 'Using current process environment...');
return {}; return {};
} }
@ -159,12 +199,80 @@ export async function resolveInteractiveShellEnv(
return shellEnvResolvePromise; return shellEnvResolvePromise;
} }
/**
* Resolve shell env without making the caller wait for slow prompt/plugin init.
*
* This is deliberately additive: fallbackEnv is returned only to the current
* caller, never cached. A successful background resolve still populates the
* normal interactive-shell cache used by buildMergedCliPath/buildEnrichedEnv.
*/
export async function resolveInteractiveShellEnvBestEffort(
options: ShellEnvBestEffortResolveOptions = {}
): Promise<NodeJS.ProcessEnv> {
if (cachedInteractiveShellEnv) {
emitProgress(options, 'shell-env-cached', 'Using cached shell environment...');
return cachedInteractiveShellEnv;
}
if (process.platform === 'win32') {
return resolveInteractiveShellEnv(options);
}
const fallbackEnv = options.fallbackEnv ?? {};
const timeoutMs = Math.max(0, options.timeoutMs ?? SHELL_ENV_BEST_EFFORT_TIMEOUT_MS);
const startedAt = Date.now();
if (!shellEnvResolvePromise && startedAt < shellEnvFailureCooldownUntil) {
const retryInMs = Math.max(0, shellEnvFailureCooldownUntil - startedAt);
emitProgress(
options,
'shell-env-failure-cooldown',
lastShellEnvFailureMessage
? `Using fallback shell environment after recent failure: ${lastShellEnvFailureMessage}`
: `Using fallback shell environment for ${retryInMs}ms after recent failure...`
);
return fallbackEnv;
}
const resolvePromise = resolveInteractiveShellEnv(options);
if (timeoutMs === 0) {
emitProgress(options, 'shell-env-best-effort-fallback', 'Using fallback shell environment...');
return fallbackEnv;
}
let timeoutHandle: NodeJS.Timeout | null = null;
const fallbackPromise = new Promise<NodeJS.ProcessEnv>((resolve) => {
timeoutHandle = setTimeout(() => {
timeoutHandle = null;
emitProgress(
options,
'shell-env-best-effort-timeout',
'Shell environment is still resolving; using fallback for now...'
);
resolve(fallbackEnv);
}, timeoutMs);
timeoutHandle.unref?.();
});
try {
const resolvedEnv = await Promise.race([resolvePromise, fallbackPromise]);
if (!cachedInteractiveShellEnv && shellEnvFailureCooldownUntil > startedAt) {
return fallbackEnv;
}
return resolvedEnv;
} finally {
if (timeoutHandle) {
clearTimeout(timeoutHandle);
}
}
}
/** /**
* Clear the cached shell environment. Useful for testing. * Clear the cached shell environment. Useful for testing.
*/ */
export function clearShellEnvCache(): void { export function clearShellEnvCache(): void {
cachedInteractiveShellEnv = null; cachedInteractiveShellEnv = null;
shellEnvResolvePromise = null; shellEnvResolvePromise = null;
clearShellEnvFailure();
} }
/** /**

View file

@ -491,6 +491,16 @@ export const CLI_INSTALLER_PROGRESS = 'cliInstaller:progress';
/** Invalidate cached CLI status (forces fresh check on next getStatus) */ /** Invalidate cached CLI status (forces fresh check on next getStatus) */
export const CLI_INSTALLER_INVALIDATE_STATUS = 'cliInstaller:invalidateStatus'; export const CLI_INSTALLER_INVALIDATE_STATUS = 'cliInstaller:invalidateStatus';
// =============================================================================
// OpenCode Runtime Installer API Channels
// =============================================================================
export const OPENCODE_RUNTIME_GET_STATUS = 'openCodeRuntime:getStatus';
export const OPENCODE_RUNTIME_INSTALL = 'openCodeRuntime:install';
export const OPENCODE_RUNTIME_PROGRESS = 'openCodeRuntime:progress';
export const OPENCODE_RUNTIME_INVALIDATE_STATUS = 'openCodeRuntime:invalidateStatus';
export { export {
TMUX_CANCEL_INSTALL, TMUX_CANCEL_INSTALL,
TMUX_GET_INSTALLER_SNAPSHOT, TMUX_GET_INSTALLER_SNAPSHOT,

View file

@ -59,6 +59,10 @@ import {
MCP_REGISTRY_INSTALL_CUSTOM, MCP_REGISTRY_INSTALL_CUSTOM,
MCP_REGISTRY_SEARCH, MCP_REGISTRY_SEARCH,
MCP_REGISTRY_UNINSTALL, MCP_REGISTRY_UNINSTALL,
OPENCODE_RUNTIME_GET_STATUS,
OPENCODE_RUNTIME_INSTALL,
OPENCODE_RUNTIME_INVALIDATE_STATUS,
OPENCODE_RUNTIME_PROGRESS,
PLUGIN_GET_ALL, PLUGIN_GET_ALL,
PLUGIN_GET_README, PLUGIN_GET_README,
PLUGIN_INSTALL, PLUGIN_INSTALL,
@ -289,6 +293,7 @@ import type {
MessagesPage, MessagesPage,
NotificationTrigger, NotificationTrigger,
OpenCodeRuntimeDeliveryStatus, OpenCodeRuntimeDeliveryStatus,
OpenCodeRuntimeStatus,
ProjectBranchChangeEvent, ProjectBranchChangeEvent,
RejectResult, RejectResult,
ReplaceMembersRequest, ReplaceMembersRequest,
@ -319,9 +324,9 @@ import type {
TeamCreateRequest, TeamCreateRequest,
TeamCreateResponse, TeamCreateResponse,
TeamGetDataOptions, TeamGetDataOptions,
TeamLaunchFailureDiagnosticsBundle,
TeamLaunchRequest, TeamLaunchRequest,
TeamLaunchResponse, TeamLaunchResponse,
TeamLaunchFailureDiagnosticsBundle,
TeamMemberActivityMeta, TeamMemberActivityMeta,
TeamMessageNotificationData, TeamMessageNotificationData,
TeamProvisioningModelVerificationMode, TeamProvisioningModelVerificationMode,
@ -1562,6 +1567,31 @@ const electronAPI: ElectronAPI = {
}, },
}, },
// ===== OpenCode Runtime Installer API =====
openCodeRuntime: {
getStatus: async (): Promise<OpenCodeRuntimeStatus> => {
return invokeIpcWithResult<OpenCodeRuntimeStatus>(OPENCODE_RUNTIME_GET_STATUS);
},
install: async (): Promise<OpenCodeRuntimeStatus> => {
return invokeIpcWithResult<OpenCodeRuntimeStatus>(OPENCODE_RUNTIME_INSTALL);
},
invalidateStatus: async (): Promise<void> => {
return invokeIpcWithResult<void>(OPENCODE_RUNTIME_INVALIDATE_STATUS);
},
onProgress: (callback: (event: unknown, data: OpenCodeRuntimeStatus) => void): (() => void) => {
ipcRenderer.on(
OPENCODE_RUNTIME_PROGRESS,
callback as (event: Electron.IpcRendererEvent, ...args: unknown[]) => void
);
return (): void => {
ipcRenderer.removeListener(
OPENCODE_RUNTIME_PROGRESS,
callback as (event: Electron.IpcRendererEvent, ...args: unknown[]) => void
);
};
},
},
tmux: createTmuxInstallerBridge({ ipcRenderer, invokeIpcWithResult }), tmux: createTmuxInstallerBridge({ ipcRenderer, invokeIpcWithResult }),
// ===== Terminal API ===== // ===== Terminal API =====

View file

@ -45,6 +45,7 @@ import type {
KanbanColumnId, KanbanColumnId,
NotificationsAPI, NotificationsAPI,
NotificationTrigger, NotificationTrigger,
OpenCodeRuntimeAPI,
OpenCodeRuntimeDeliveryStatus, OpenCodeRuntimeDeliveryStatus,
PaginatedSessionsResult, PaginatedSessionsResult,
Project, Project,
@ -1257,6 +1258,24 @@ export class HttpAPIClient implements ElectronAPI {
}, },
}; };
openCodeRuntime: OpenCodeRuntimeAPI = {
getStatus: async () => ({
installed: false,
source: 'missing',
state: 'idle',
}),
install: async () => ({
installed: false,
source: 'missing',
state: 'failed',
error: 'OpenCode runtime installer is not available in browser mode',
}),
invalidateStatus: async (): Promise<void> => {},
onProgress: (): (() => void) => {
return () => {};
},
};
runtimeProviderManagement: RuntimeProviderManagementApi = { runtimeProviderManagement: RuntimeProviderManagementApi = {
loadView: async (input) => ({ loadView: async (input) => ({
schemaVersion: 1, schemaVersion: 1,

View file

@ -73,7 +73,12 @@ import {
} from './providerDashboardRateLimits'; } from './providerDashboardRateLimits';
import type { DashboardRateLimitItem } from './providerDashboardRateLimits'; import type { DashboardRateLimitItem } from './providerDashboardRateLimits';
import type { CliProviderAuthMode, CliProviderId, CliProviderStatus } from '@shared/types'; import type {
CliProviderAuthMode,
CliProviderId,
CliProviderStatus,
OpenCodeRuntimeStatus,
} from '@shared/types';
// ============================================================================= // =============================================================================
// Border color by state // Border color by state
@ -89,8 +94,6 @@ const VARIANT_STYLES: Record<BannerVariant, { border: string; bg: string }> = {
warning: { border: '#f59e0b', bg: 'rgba(245, 158, 11, 0.06)' }, warning: { border: '#f59e0b', bg: 'rgba(245, 158, 11, 0.06)' },
}; };
const OPENCODE_DOWNLOAD_URL = 'https://opencode.ai/download';
/** Minimum banner height — prevents layout shift between states (loading → installed → checking). */ /** Minimum banner height — prevents layout shift between states (loading → installed → checking). */
const BANNER_MIN_H = 'min-h-[4.25rem]'; const BANNER_MIN_H = 'min-h-[4.25rem]';
const ANTHROPIC_LIMIT_REFRESH_INTERVAL_MS = 60 * 1000; const ANTHROPIC_LIMIT_REFRESH_INTERVAL_MS = 60 * 1000;
@ -352,8 +355,11 @@ interface InstalledBannerProps {
}; };
codexRateLimitsLoading: boolean; codexRateLimitsLoading: boolean;
anthropicRateLimitsRefreshing: boolean; anthropicRateLimitsRefreshing: boolean;
openCodeRuntimeStatus: OpenCodeRuntimeStatus | null;
openCodeRuntimeStatusLoading: boolean;
isBusy: boolean; isBusy: boolean;
onInstall: () => void; onInstall: () => void;
onOpenCodeInstall: () => void;
onRefresh: () => void; onRefresh: () => void;
onToggleProvidersCollapsed: () => void; onToggleProvidersCollapsed: () => void;
onProviderLogin: (providerId: CliProviderId) => void; onProviderLogin: (providerId: CliProviderId) => void;
@ -570,19 +576,51 @@ function hasVisibleAuthenticatedMultimodelProvider(
return visibleProviders.some((provider) => provider.authenticated); return visibleProviders.some((provider) => provider.authenticated);
} }
function shouldShowOpenCodeDownloadAction( function shouldShowOpenCodeInstallAction(
provider: CliProviderStatus, provider: CliProviderStatus,
showSkeleton: boolean showSkeleton: boolean,
openCodeRuntimeStatus: OpenCodeRuntimeStatus | null
): boolean { ): boolean {
return ( return (
provider.providerId === 'opencode' && provider.providerId === 'opencode' &&
!showSkeleton && !showSkeleton &&
!provider.supported && !provider.supported &&
!provider.authenticated && !provider.authenticated &&
provider.backend == null provider.backend == null &&
openCodeRuntimeStatus?.source !== 'path' &&
!(openCodeRuntimeStatus?.source === 'app-managed' && openCodeRuntimeStatus.state !== 'failed')
); );
} }
function isOpenCodeRuntimeInstalling(
status: OpenCodeRuntimeStatus | null,
loading: boolean
): boolean {
return (
loading ||
status?.state === 'checking' ||
status?.state === 'downloading' ||
status?.state === 'installing'
);
}
function getOpenCodeInstallLabel(status: OpenCodeRuntimeStatus | null): string {
if (status?.state === 'downloading') {
const percent = status.progress?.percent;
return typeof percent === 'number' ? `Downloading ${percent}%` : 'Downloading';
}
if (status?.state === 'installing') {
return 'Installing';
}
if (status?.state === 'checking') {
return 'Checking';
}
if (status?.state === 'failed') {
return 'Retry install';
}
return 'Install';
}
const InstalledBanner = ({ const InstalledBanner = ({
cliStatus, cliStatus,
sourceProviderMap, sourceProviderMap,
@ -594,8 +632,11 @@ const InstalledBanner = ({
providerConnectionAuthModes, providerConnectionAuthModes,
codexRateLimitsLoading, codexRateLimitsLoading,
anthropicRateLimitsRefreshing, anthropicRateLimitsRefreshing,
openCodeRuntimeStatus,
openCodeRuntimeStatusLoading,
isBusy, isBusy,
onInstall, onInstall,
onOpenCodeInstall,
onRefresh, onRefresh,
onToggleProvidersCollapsed, onToggleProvidersCollapsed,
onProviderLogin, onProviderLogin,
@ -901,19 +942,38 @@ const InstalledBanner = ({
) : null} ) : null}
</div> </div>
<div className="flex shrink-0 items-start gap-2"> <div className="flex shrink-0 items-start gap-2">
{shouldShowOpenCodeDownloadAction(provider, showSkeleton) ? ( {shouldShowOpenCodeInstallAction(
provider,
showSkeleton,
openCodeRuntimeStatus
) ? (
<button <button
type="button" type="button"
onClick={() => void api.openExternal(OPENCODE_DOWNLOAD_URL)} onClick={onOpenCodeInstall}
className="flex items-center gap-1 rounded-md border px-2 py-[3px] text-[10px] font-medium transition-colors hover:bg-white/5" disabled={isOpenCodeRuntimeInstalling(
openCodeRuntimeStatus,
openCodeRuntimeStatusLoading
)}
className="flex items-center gap-1 rounded-md border px-2 py-[3px] text-[10px] font-medium transition-colors hover:bg-white/5 disabled:opacity-50"
style={{ style={{
borderColor: 'rgba(14, 165, 233, 0.36)', borderColor: 'rgba(14, 165, 233, 0.36)',
color: '#7dd3fc', color: '#7dd3fc',
}} }}
title="Download OpenCode CLI" title={
openCodeRuntimeStatus?.error ??
openCodeRuntimeStatus?.progress?.detail ??
'Install OpenCode CLI into app data'
}
> >
{isOpenCodeRuntimeInstalling(
openCodeRuntimeStatus,
openCodeRuntimeStatusLoading
) ? (
<Loader2 className="size-3 animate-spin" />
) : (
<Download className="size-3" /> <Download className="size-3" />
Download )}
{getOpenCodeInstallLabel(openCodeRuntimeStatus)}
</button> </button>
) : null} ) : null}
<button <button
@ -1028,11 +1088,14 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
installerDetail, installerDetail,
installerRawChunks, installerRawChunks,
completedVersion, completedVersion,
openCodeRuntimeStatus,
openCodeRuntimeStatusLoading,
bootstrapCliStatus, bootstrapCliStatus,
fetchCliStatus, fetchCliStatus,
fetchCliProviderStatus, fetchCliProviderStatus,
invalidateCliStatus, invalidateCliStatus,
installCli, installCli,
installOpenCodeRuntime,
isBusy, isBusy,
} = useCliInstaller(); } = useCliInstaller();
@ -1465,8 +1528,11 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
providerConnectionAuthModes={providerConnectionAuthModes} providerConnectionAuthModes={providerConnectionAuthModes}
codexRateLimitsLoading={codexAccount.rateLimitsLoading} codexRateLimitsLoading={codexAccount.rateLimitsLoading}
anthropicRateLimitsRefreshing={anthropicRateLimitsRefreshing} anthropicRateLimitsRefreshing={anthropicRateLimitsRefreshing}
openCodeRuntimeStatus={openCodeRuntimeStatus}
openCodeRuntimeStatusLoading={openCodeRuntimeStatusLoading}
isBusy={isBusy} isBusy={isBusy}
onInstall={handleInstall} onInstall={handleInstall}
onOpenCodeInstall={() => void installOpenCodeRuntime()}
onRefresh={handleRefresh} onRefresh={handleRefresh}
onToggleProvidersCollapsed={handleToggleProvidersCollapsed} onToggleProvidersCollapsed={handleToggleProvidersCollapsed}
onProviderLogin={handleProviderLogin} onProviderLogin={handleProviderLogin}
@ -1694,8 +1760,11 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
providerConnectionAuthModes={providerConnectionAuthModes} providerConnectionAuthModes={providerConnectionAuthModes}
codexRateLimitsLoading={codexAccount.rateLimitsLoading} codexRateLimitsLoading={codexAccount.rateLimitsLoading}
anthropicRateLimitsRefreshing={anthropicRateLimitsRefreshing} anthropicRateLimitsRefreshing={anthropicRateLimitsRefreshing}
openCodeRuntimeStatus={openCodeRuntimeStatus}
openCodeRuntimeStatusLoading={openCodeRuntimeStatusLoading}
isBusy={isBusy} isBusy={isBusy}
onInstall={handleInstall} onInstall={handleInstall}
onOpenCodeInstall={() => void installOpenCodeRuntime()}
onRefresh={handleRefresh} onRefresh={handleRefresh}
onToggleProvidersCollapsed={handleToggleProvidersCollapsed} onToggleProvidersCollapsed={handleToggleProvidersCollapsed}
onProviderLogin={handleProviderLogin} onProviderLogin={handleProviderLogin}
@ -1757,8 +1826,11 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
providerConnectionAuthModes={providerConnectionAuthModes} providerConnectionAuthModes={providerConnectionAuthModes}
codexRateLimitsLoading={codexAccount.rateLimitsLoading} codexRateLimitsLoading={codexAccount.rateLimitsLoading}
anthropicRateLimitsRefreshing={anthropicRateLimitsRefreshing} anthropicRateLimitsRefreshing={anthropicRateLimitsRefreshing}
openCodeRuntimeStatus={openCodeRuntimeStatus}
openCodeRuntimeStatusLoading={openCodeRuntimeStatusLoading}
isBusy={isBusy} isBusy={isBusy}
onInstall={handleInstall} onInstall={handleInstall}
onOpenCodeInstall={() => void installOpenCodeRuntime()}
onRefresh={handleRefresh} onRefresh={handleRefresh}
onToggleProvidersCollapsed={handleToggleProvidersCollapsed} onToggleProvidersCollapsed={handleToggleProvidersCollapsed}
onProviderLogin={handleProviderLogin} onProviderLogin={handleProviderLogin}
@ -1980,8 +2052,11 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
providerConnectionAuthModes={providerConnectionAuthModes} providerConnectionAuthModes={providerConnectionAuthModes}
codexRateLimitsLoading={codexAccount.rateLimitsLoading} codexRateLimitsLoading={codexAccount.rateLimitsLoading}
anthropicRateLimitsRefreshing={anthropicRateLimitsRefreshing} anthropicRateLimitsRefreshing={anthropicRateLimitsRefreshing}
openCodeRuntimeStatus={openCodeRuntimeStatus}
openCodeRuntimeStatusLoading={openCodeRuntimeStatusLoading}
isBusy={isBusy} isBusy={isBusy}
onInstall={handleInstall} onInstall={handleInstall}
onOpenCodeInstall={() => void installOpenCodeRuntime()}
onRefresh={handleRefresh} onRefresh={handleRefresh}
onToggleProvidersCollapsed={handleToggleProvidersCollapsed} onToggleProvidersCollapsed={handleToggleProvidersCollapsed}
onProviderLogin={handleProviderLogin} onProviderLogin={handleProviderLogin}

View file

@ -46,6 +46,16 @@ function getAvailabilityChip(status: CliProviderModelAvailabilityStatus | null):
} }
} }
function getCatalogBadgeLabel(
model: string,
providerStatus: Pick<CliProviderStatus, 'modelCatalog'> | null | undefined
): string | null {
const catalogItem = providerStatus?.modelCatalog?.models.find(
(item) => item.launchModel === model || item.id === model
);
return catalogItem?.badgeLabel?.trim() || null;
}
export const ProviderModelBadges = ({ export const ProviderModelBadges = ({
providerId, providerId,
models, models,
@ -57,7 +67,10 @@ export const ProviderModelBadges = ({
readonly providerId: CliProviderId; readonly providerId: CliProviderId;
readonly models: string[]; readonly models: string[];
readonly modelAvailability?: CliProviderModelAvailability[]; readonly modelAvailability?: CliProviderModelAvailability[];
readonly providerStatus?: Pick<CliProviderStatus, 'providerId' | 'authMethod' | 'backend'> | null; readonly providerStatus?: Pick<
CliProviderStatus,
'providerId' | 'authMethod' | 'backend' | 'modelCatalog'
> | null;
readonly collapseAfter?: number; readonly collapseAfter?: number;
readonly expandedMaxHeightPx?: number; readonly expandedMaxHeightPx?: number;
}): React.JSX.Element => { }): React.JSX.Element => {
@ -89,15 +102,29 @@ export const ProviderModelBadges = ({
const availabilityStatus = getAvailabilityStatus(model, displayModelAvailability); const availabilityStatus = getAvailabilityStatus(model, displayModelAvailability);
const availabilityReason = getAvailabilityReason(model, displayModelAvailability); const availabilityReason = getAvailabilityReason(model, displayModelAvailability);
const availabilityChip = getAvailabilityChip(availabilityStatus); const availabilityChip = getAvailabilityChip(availabilityStatus);
const catalogBadgeLabel = getCatalogBadgeLabel(model, providerStatus);
const title = [
availabilityReason ?? availabilityChip,
catalogBadgeLabel === 'Free'
? 'Reported by OpenCode metadata. Availability and limits may change.'
: null,
]
.filter(Boolean)
.join(' - ');
return ( return (
<span <span
key={`${model}-${index}`} key={`${model}-${index}`}
className={badgeClassName} className={badgeClassName}
style={badgeStyle} style={badgeStyle}
title={availabilityReason ?? availabilityChip ?? undefined} title={title || undefined}
> >
<span>{formatModelBadgeLabel(providerId, model)}</span> <span>{formatModelBadgeLabel(providerId, model)}</span>
{catalogBadgeLabel ? (
<span className="rounded bg-[rgba(34,197,94,0.14)] px-1 py-0 text-[9px] font-medium uppercase tracking-[0.06em] text-[rgb(74,222,128)]">
{catalogBadgeLabel}
</span>
) : null}
{availabilityChip ? ( {availabilityChip ? (
<span <span
className={cn( className={cn(

View file

@ -92,7 +92,7 @@ import { type MemberActivityFilter, type MemberDetailTab } from './members/membe
import type { AddMemberEntry } from './dialogs/AddMemberDialog'; import type { AddMemberEntry } from './dialogs/AddMemberDialog';
import type { TeamLaunchDialogMode } from './dialogs/LaunchTeamDialog'; import type { TeamLaunchDialogMode } from './dialogs/LaunchTeamDialog';
import type { TeamMessagesPanelMode } from '@renderer/types/teamMessagesPanelMode'; import type { TeamMessagesPanelMode } from '@renderer/types/teamMessagesPanelMode';
import type { ComponentProps, CSSProperties } from 'react'; import type { ComponentProps, CSSProperties, RefObject } from 'react';
const LaunchTeamDialog = lazy(() => const LaunchTeamDialog = lazy(() =>
import('./dialogs/LaunchTeamDialog').then((m) => ({ default: m.LaunchTeamDialog })) import('./dialogs/LaunchTeamDialog').then((m) => ({ default: m.LaunchTeamDialog }))
@ -248,6 +248,327 @@ function filterKanbanTasks(tasks: TeamTaskWithKanban[], query: string): TeamTask
); );
} }
const TEAM_LOADING_MEMBER_ACCENTS = ['#46d93b', '#3b82f6', '#facc15', '#14b8a6', '#ef4444'];
const TEAM_LOADING_KANBAN_COLUMNS = [
{
title: 'TODO',
headerBg: 'rgba(59, 130, 246, 0.28)',
bodyBg: 'rgba(59, 130, 246, 0.06)',
},
{
title: 'IN PROGRESS',
headerBg: 'rgba(234, 179, 8, 0.28)',
bodyBg: 'rgba(234, 179, 8, 0.07)',
},
{
title: 'REVIEW',
headerBg: 'rgba(139, 92, 246, 0.28)',
bodyBg: 'rgba(139, 92, 246, 0.07)',
},
];
type SkeletonClassNameProps = Readonly<{ className?: string }>;
const SkeletonBlock = ({ className }: SkeletonClassNameProps): React.JSX.Element => (
<div
aria-hidden="true"
className={cn('animate-pulse rounded-md bg-[var(--color-surface-raised)]', className)}
/>
);
const SkeletonPill = ({ className }: SkeletonClassNameProps): React.JSX.Element => (
<div
aria-hidden="true"
className={cn('animate-pulse rounded-full bg-[var(--color-surface-raised)]', className)}
/>
);
const TeamLoadingSidebarSkeleton = (): React.JSX.Element => (
<aside
className="flex size-full min-h-0 flex-col overflow-hidden bg-[var(--color-surface)]"
aria-label="Loading team sidebar"
>
<div className="shrink-0 px-3 py-2">
<div className="-mx-3 flex min-h-9 items-center gap-3 bg-[var(--color-section-bg)] px-4">
<SkeletonPill className="size-4 rounded" />
<SkeletonPill className="h-4 w-16" />
<SkeletonPill className="h-5 w-8" />
<SkeletonPill className="ml-auto size-5 rounded" />
</div>
<div className="mt-3 flex items-center gap-2">
<SkeletonPill className="size-4 rounded" />
<SkeletonPill className="h-3.5 w-44" />
</div>
</div>
<div className="h-px shrink-0 bg-[var(--color-border)]" />
<div className="flex min-h-0 flex-1 flex-col overflow-hidden p-3">
<div className="mb-3 flex min-h-9 items-center gap-3">
<SkeletonPill className="size-4 rounded" />
<SkeletonPill className="h-4 w-24" />
<SkeletonPill className="h-5 w-8" />
<div className="ml-auto flex items-center gap-3">
<SkeletonPill className="size-5 rounded" />
<SkeletonPill className="size-5 rounded" />
</div>
</div>
<div className="mb-3 rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-raised)] p-3">
<SkeletonPill className="h-4 w-52" />
<SkeletonPill className="mt-2 h-4 w-40" />
<div className="mt-7 flex items-center gap-2">
<SkeletonPill className="h-6 w-12" />
<SkeletonPill className="h-6 w-16" />
<SkeletonPill className="h-6 w-20" />
<SkeletonPill className="ml-auto size-8 rounded-full" />
</div>
</div>
<div className="space-y-3 overflow-hidden">
{[0, 1, 2].map((index) => (
<div
key={index}
className="rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-sidebar)] p-3"
>
<div className="flex items-center gap-2">
<SkeletonPill className="h-5 w-12" />
<SkeletonPill className="h-3 w-16" />
<SkeletonPill className="ml-auto h-3 w-12" />
</div>
<SkeletonPill className="mt-5 h-4 w-[88%]" />
<SkeletonPill className="mt-2 h-4 w-[72%]" />
</div>
))}
</div>
</div>
</aside>
);
type TeamLoadingSectionHeaderProps = Readonly<{
icon: React.ReactNode;
titleWidth: string;
badgeWidth?: string;
actionWidth?: string;
open?: boolean;
}>;
const TeamLoadingSectionHeader = ({
icon,
titleWidth,
badgeWidth,
actionWidth,
open = true,
}: TeamLoadingSectionHeaderProps): React.JSX.Element => (
<div
className="relative flex min-h-9 items-stretch py-1.5"
style={{
marginInline: 'calc((1rem - 5px) * -1)',
width: 'calc(100% + 2rem - 10px)',
}}
>
<div
className={cn(
'absolute inset-0 z-0',
open
? 'rounded-t-xl bg-[var(--color-section-bg-open)]'
: 'rounded-xl bg-[var(--color-section-bg)]'
)}
/>
<div className="relative z-10 flex min-w-0 flex-1 items-center gap-2 pl-4">
<span
className={cn(
'size-0 border-y-[5px] border-l-[6px] border-y-transparent border-l-[var(--color-text-muted)] opacity-80',
open && 'rotate-90'
)}
/>
<span className="shrink-0 text-[var(--color-text-muted)]">{icon}</span>
<SkeletonPill className={cn('h-4', titleWidth)} />
{badgeWidth ? <SkeletonPill className={cn('h-5', badgeWidth)} /> : null}
</div>
{actionWidth ? (
<div className="relative z-10 flex shrink-0 items-center pr-3">
<SkeletonPill className={cn('h-5', actionWidth)} />
</div>
) : null}
</div>
);
type TeamContentLoadingSkeletonProps = Readonly<{
teamName: string;
contentRef: RefObject<HTMLDivElement | null>;
provisioningBannerRef: RefObject<HTMLDivElement | null>;
}>;
const TeamContentLoadingSkeleton = ({
teamName,
contentRef,
provisioningBannerRef,
}: TeamContentLoadingSkeletonProps): React.JSX.Element => (
<div
ref={contentRef}
className="size-full min-w-0 overflow-y-auto overflow-x-hidden p-4"
data-team-name={teamName}
role="status"
aria-label="Loading team"
>
<div className="relative -mx-4 -mt-4 mb-3 overflow-hidden border-b border-[var(--color-border)] bg-purple-950/20 px-4 py-3">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<SkeletonPill className="h-5 w-44" />
<SkeletonPill className="h-6 w-20 bg-emerald-500/15" />
</div>
<SkeletonPill className="mt-3 h-4 w-72" />
<div className="mt-3 flex flex-wrap items-center gap-3">
<SkeletonPill className="h-4 w-28" />
<SkeletonPill className="h-6 w-24 rounded-md" />
<SkeletonPill className="h-4 w-16" />
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<SkeletonPill className="h-7 w-16" />
<SkeletonPill className="size-7 rounded" />
<SkeletonPill className="size-7 rounded" />
</div>
</div>
<div className="mt-4 flex justify-end">
<SkeletonPill className="h-11 w-40 rounded-full border border-cyan-300/25 bg-cyan-500/10" />
</div>
</div>
<div ref={provisioningBannerRef}>
<TeamProvisioningBanner teamName={teamName} />
</div>
<section className="min-w-0">
<TeamLoadingSectionHeader
icon={<Users size={14} />}
titleWidth="w-20"
badgeWidth="w-8"
actionWidth="w-20"
/>
<div className="mt-3 grid grid-cols-1 gap-1 pb-4">
{TEAM_LOADING_MEMBER_ACCENTS.map((accent, index) => (
<div key={accent} className="flex min-h-[52px] min-w-0 items-center gap-3">
<div className="relative size-7 shrink-0">
<div
className="absolute inset-0 rounded-full border-2 bg-[var(--color-surface-raised)]"
style={{ borderColor: accent }}
/>
<div
className="absolute bottom-0 right-0 size-2 rounded-full border border-[var(--color-surface)]"
style={{ backgroundColor: accent }}
/>
</div>
<div className="min-w-0 flex-1">
<SkeletonPill
className={cn('h-4', index === 0 ? 'w-14' : index === 3 ? 'w-16' : 'w-12')}
/>
<SkeletonPill
className={cn('mt-1.5 h-2.5', index === 1 ? 'w-60' : index === 4 ? 'w-64' : 'w-52')}
/>
</div>
<div className="hidden shrink-0 items-center gap-3 sm:flex">
<SkeletonPill className="h-[18px] w-[62px]" />
<SkeletonPill className="h-[18px] w-[62px]" />
<SkeletonPill className="size-4 rounded" />
<SkeletonPill className="size-4 rounded" />
</div>
</div>
))}
</div>
</section>
<section className="min-w-0">
<TeamLoadingSectionHeader icon={<History size={14} />} titleWidth="w-24" open={false} />
</section>
<section className="mt-0 min-w-0">
<TeamLoadingSectionHeader
icon={<Columns3 size={14} />}
titleWidth="w-24"
badgeWidth="w-8"
actionWidth="w-16"
/>
<div className="mt-3 flex flex-wrap items-center justify-between gap-3">
<div className="relative h-9 min-w-[220px] max-w-sm flex-1 rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-sidebar)]">
<SkeletonPill className="absolute left-3 top-1/2 size-4 -translate-y-1/2 rounded" />
<SkeletonPill className="absolute left-10 top-1/2 h-4 w-44 -translate-y-1/2" />
</div>
<div className="flex items-center gap-2">
<SkeletonBlock className="h-9 w-20" />
<SkeletonBlock className="h-9 w-28" />
</div>
</div>
<div className="mt-4 grid gap-4 xl:grid-cols-3">
{TEAM_LOADING_KANBAN_COLUMNS.map((column) => (
<div
key={column.title}
className="min-h-44 overflow-hidden rounded-lg border border-[var(--color-border)]"
style={{ backgroundColor: column.bodyBg }}
>
<div
className="flex h-11 items-center gap-3 px-4"
style={{ backgroundColor: column.headerBg }}
>
<SkeletonPill className="size-4 rounded" />
<SkeletonPill
className={cn('h-4', column.title === 'IN PROGRESS' ? 'w-32' : 'w-20')}
/>
</div>
<div className="p-4">
<div
className="flex h-14 items-center justify-center rounded-lg border border-dashed border-[var(--color-border)]"
style={{
backgroundColor: 'color-mix(in srgb, var(--color-surface) 35%, transparent)',
}}
>
<SkeletonPill className="h-4 w-28" />
</div>
</div>
</div>
))}
</div>
</section>
</div>
);
type TeamLoadingSkeletonProps = Readonly<{
teamName: string;
isActive: boolean | undefined;
isFocused: boolean | undefined;
messagesPanelMode: TeamMessagesPanelMode;
contentRef: RefObject<HTMLDivElement | null>;
provisioningBannerRef: RefObject<HTMLDivElement | null>;
}>;
const TeamLoadingSkeleton = ({
teamName,
isActive,
isFocused,
messagesPanelMode,
contentRef,
provisioningBannerRef,
}: TeamLoadingSkeletonProps): React.JSX.Element => (
<div className="flex size-full overflow-hidden">
{messagesPanelMode === 'sidebar' ? (
<TeamSidebarHost
teamName={teamName}
surface="team"
isActive={Boolean(isActive)}
isFocused={Boolean(isFocused)}
>
<TeamLoadingSidebarSkeleton />
</TeamSidebarHost>
) : null}
<div className="relative min-h-0 min-w-0 flex-1">
<TeamContentLoadingSkeleton
teamName={teamName}
contentRef={contentRef}
provisioningBannerRef={provisioningBannerRef}
/>
</div>
</div>
);
const TeamOfflineStatusBanner = memo(function TeamOfflineStatusBanner({ const TeamOfflineStatusBanner = memo(function TeamOfflineStatusBanner({
teamName, teamName,
onLaunch, onLaunch,
@ -2120,17 +2441,14 @@ export const TeamDetailView = memo(function TeamDetailView({
const renderBody = (): React.JSX.Element => { const renderBody = (): React.JSX.Element => {
if ((loading && !data) || (data && data.teamName !== teamName)) { if ((loading && !data) || (data && data.teamName !== teamName)) {
return ( return (
<div className="size-full overflow-auto p-4"> <TeamLoadingSkeleton
<div className="mb-4 h-10 animate-pulse rounded-md bg-[var(--color-surface-raised)]" /> teamName={teamName}
<div ref={provisioningBannerRef}> isActive={isThisTabActive}
<TeamProvisioningBanner teamName={teamName} /> isFocused={isPaneFocused}
</div> messagesPanelMode={messagesPanelMode}
<div className="space-y-3"> contentRef={contentRef}
<div className="h-24 animate-pulse rounded-md bg-[var(--color-surface-raised)]" /> provisioningBannerRef={provisioningBannerRef}
<div className="h-48 animate-pulse rounded-md bg-[var(--color-surface-raised)]" /> />
<div className="h-48 animate-pulse rounded-md bg-[var(--color-surface-raised)]" />
</div>
</div>
); );
} }

View file

@ -19,6 +19,17 @@ function task(overrides: Partial<TeamTaskWithKanban> & { id: string }): TeamTask
}; };
} }
function changedTasks(count: number): TeamTaskWithKanban[] {
return Array.from({ length: count }, (_, index) =>
task({
id: `changed-${index}`,
status: 'completed',
changePresence: 'has_changes',
updatedAt: `2026-05-09T08:${String(index).padStart(2, '0')}:00.000Z`,
})
);
}
describe('buildTeamChangeRequestPlan', () => { describe('buildTeamChangeRequestPlan', () => {
it('scans unknown pending tasks only when they have work evidence', () => { it('scans unknown pending tasks only when they have work evidence', () => {
const plan = buildTeamChangeRequestPlan( const plan = buildTeamChangeRequestPlan(
@ -67,24 +78,53 @@ describe('buildTeamChangeRequestPlan', () => {
}); });
it('caps selected requests and reports deferred candidates', () => { it('caps selected requests and reports deferred candidates', () => {
const plan = buildTeamChangeRequestPlan( const plan = buildTeamChangeRequestPlan(changedTasks(TEAM_CHANGES_MAX_REQUESTS + 5), 0, false);
Array.from({ length: TEAM_CHANGES_MAX_REQUESTS + 5 }, (_, index) =>
task({
id: `changed-${index}`,
status: 'completed',
changePresence: 'has_changes',
updatedAt: `2026-05-09T08:${String(index).padStart(2, '0')}:00.000Z`,
})
),
0,
false
);
expect(plan.requests).toHaveLength(TEAM_CHANGES_MAX_REQUESTS); expect(plan.requests).toHaveLength(TEAM_CHANGES_MAX_REQUESTS);
expect(plan.eligibleCount).toBe(TEAM_CHANGES_MAX_REQUESTS + 5); expect(plan.eligibleCount).toBe(TEAM_CHANGES_MAX_REQUESTS + 5);
expect(plan.deferredCount).toBe(5); expect(plan.deferredCount).toBe(5);
}); });
it('supports smaller first-pass caps without changing eligible counts', () => {
const plan = buildTeamChangeRequestPlan(changedTasks(30), 0, false, {
maxRequests: 12,
unknownScanLimit: 6,
});
expect(plan.requests).toHaveLength(12);
expect(plan.eligibleCount).toBe(30);
expect(plan.deferredCount).toBe(18);
});
it('skips satisfied candidates without counting them as deferred', () => {
const tasks = changedTasks(30);
const satisfiedTaskIds = new Set(
Array.from({ length: 12 }, (_, index) => `changed-${29 - index}`)
);
const plan = buildTeamChangeRequestPlan(tasks, 0, false, { satisfiedTaskIds });
expect(plan.requests).toHaveLength(18);
expect(plan.requests.some((request) => satisfiedTaskIds.has(request.taskId))).toBe(false);
expect(plan.eligibleCount).toBe(30);
expect(plan.deferredCount).toBe(0);
expect([...satisfiedTaskIds].every((taskId) => plan.eligibleTaskIds.has(taskId))).toBe(true);
});
it('keeps forceFresh request options while skipping same-chain satisfied candidates', () => {
const tasks = changedTasks(30);
const satisfiedTaskIds = new Set(['changed-29', 'changed-28', 'changed-27']);
const plan = buildTeamChangeRequestPlan(tasks, 0, true, {
maxRequests: 9,
satisfiedTaskIds,
});
expect(plan.requests).toHaveLength(9);
expect(plan.requests.some((request) => satisfiedTaskIds.has(request.taskId))).toBe(false);
expect(plan.requests.every((request) => request.options?.forceFresh === true)).toBe(true);
});
it('rotates unknown scans and preserves summary-only request options', () => { it('rotates unknown scans and preserves summary-only request options', () => {
const tasks = Array.from({ length: TEAM_CHANGES_UNKNOWN_SCAN_LIMIT + 4 }, (_, index) => const tasks = Array.from({ length: TEAM_CHANGES_UNKNOWN_SCAN_LIMIT + 4 }, (_, index) =>
task({ task({

View file

@ -11,6 +11,7 @@ import { type TeamChangeSummaryState, useTeamChangesSummaries } from '../useTeam
import type { import type {
TaskChangeSetV2, TaskChangeSetV2,
TeamTaskChangeSummariesResponse, TeamTaskChangeSummariesResponse,
TeamTaskChangeSummaryRequest,
TeamTaskWithKanban, TeamTaskWithKanban,
} from '@shared/types'; } from '@shared/types';
@ -73,6 +74,17 @@ function task(overrides: Partial<TeamTaskWithKanban> = {}): TeamTaskWithKanban {
}; };
} }
function changedTasks(count: number): TeamTaskWithKanban[] {
return Array.from({ length: count }, (_, index) =>
task({
id: `changed-${index}`,
subject: `Changed ${index}`,
changePresence: 'has_changes',
updatedAt: `2026-05-10T10:${String(index).padStart(2, '0')}:00.000Z`,
})
);
}
function changeSet(taskId = 'task-1'): TaskChangeSetV2 { function changeSet(taskId = 'task-1'): TaskChangeSetV2 {
return { return {
teamName: 'team-a', teamName: 'team-a',
@ -120,6 +132,19 @@ function response(summary: TaskChangeSetV2 = changeSet()): TeamTaskChangeSummari
}; };
} }
function responseForRequests(
requests: TeamTaskChangeSummaryRequest[]
): TeamTaskChangeSummariesResponse {
return {
teamName: 'team-a',
computedAt: '2026-05-10T10:00:01.000Z',
items: requests.map((request) => ({
taskId: request.taskId,
changeSet: changeSet(request.taskId),
})),
};
}
function malformedLegacyChangeSet(): TaskChangeSetV2 { function malformedLegacyChangeSet(): TaskChangeSetV2 {
return { return {
...changeSet(), ...changeSet(),
@ -197,19 +222,22 @@ interface HookSnapshot {
error: string | null; error: string | null;
badgeCount: number | null; badgeCount: number | null;
summariesByTaskId: Record<string, TeamChangeSummaryState>; summariesByTaskId: Record<string, TeamChangeSummaryState>;
refresh: () => void;
} }
const HookHarness = ({ const HookHarness = ({
tasks, tasks,
sectionOpen = true,
onSnapshot, onSnapshot,
}: { }: {
tasks: TeamTaskWithKanban[]; tasks: TeamTaskWithKanban[];
sectionOpen?: boolean;
onSnapshot: (snapshot: HookSnapshot) => void; onSnapshot: (snapshot: HookSnapshot) => void;
}): null => { }): null => {
const state = useTeamChangesSummaries({ const state = useTeamChangesSummaries({
teamName: 'team-a', teamName: 'team-a',
tasks, tasks,
sectionOpen: true, sectionOpen,
}); });
React.useEffect(() => { React.useEffect(() => {
onSnapshot({ onSnapshot({
@ -218,12 +246,14 @@ const HookHarness = ({
error: state.error, error: state.error,
badgeCount: state.badgeCount, badgeCount: state.badgeCount,
summariesByTaskId: state.summariesByTaskId, summariesByTaskId: state.summariesByTaskId,
refresh: state.refresh,
}); });
}, [ }, [
onSnapshot, onSnapshot,
state.badgeCount, state.badgeCount,
state.error, state.error,
state.loading, state.loading,
state.refresh,
state.refreshing, state.refreshing,
state.summariesByTaskId, state.summariesByTaskId,
]); ]);
@ -699,6 +729,296 @@ describe('useTeamChangesSummaries', () => {
expect(container.textContent).not.toContain('src/app.ts'); expect(container.textContent).not.toContain('src/app.ts');
}); });
it('loads staged open batches without repeating successful tasks', async () => {
const first = createDeferred<TeamTaskChangeSummariesResponse>();
const second = createDeferred<TeamTaskChangeSummariesResponse>();
const third = createDeferred<TeamTaskChangeSummariesResponse>();
hoisted.getTeamTaskChangeSummaries
.mockReturnValueOnce(first.promise)
.mockReturnValueOnce(second.promise)
.mockReturnValueOnce(third.promise);
const tasks = changedTasks(30);
const snapshots: HookSnapshot[] = [];
const onSnapshot = (snapshot: HookSnapshot): void => {
snapshots.push(snapshot);
};
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root?.render(React.createElement(HookHarness, { tasks, onSnapshot }));
});
expect(hoisted.getTeamTaskChangeSummaries).toHaveBeenCalledTimes(1);
const firstRequests = hoisted.getTeamTaskChangeSummaries.mock
.calls[0][1] as TeamTaskChangeSummaryRequest[];
expect(firstRequests).toHaveLength(3);
expect(firstRequests[0]?.taskId).toBe('changed-29');
await act(async () => {
first.resolve(responseForRequests(firstRequests));
await first.promise;
await Promise.resolve();
});
await act(async () => {
await Promise.resolve();
});
expect(hoisted.getTeamTaskChangeSummaries).toHaveBeenCalledTimes(2);
const secondRequests = hoisted.getTeamTaskChangeSummaries.mock
.calls[1][1] as TeamTaskChangeSummaryRequest[];
expect(secondRequests).toHaveLength(9);
expect(Object.keys(snapshots.at(-1)?.summariesByTaskId ?? {})).toHaveLength(3);
expect(snapshots.at(-1)?.loading).toBe(false);
expect(snapshots.at(-1)?.refreshing).toBe(true);
expect(
secondRequests.some((request) =>
firstRequests.some((firstRequest) => firstRequest.taskId === request.taskId)
)
).toBe(false);
await act(async () => {
second.resolve(responseForRequests(secondRequests));
await second.promise;
await Promise.resolve();
});
await act(async () => {
await Promise.resolve();
});
expect(hoisted.getTeamTaskChangeSummaries).toHaveBeenCalledTimes(3);
const thirdRequests = hoisted.getTeamTaskChangeSummaries.mock
.calls[2][1] as TeamTaskChangeSummaryRequest[];
expect(thirdRequests).toHaveLength(18);
expect(
thirdRequests.some((request) =>
[...firstRequests, ...secondRequests].some(
(previousRequest) => previousRequest.taskId === request.taskId
)
)
).toBe(false);
await act(async () => {
third.resolve(responseForRequests(thirdRequests));
await third.promise;
await Promise.resolve();
});
expect(Object.keys(snapshots.at(-1)?.summariesByTaskId ?? {})).toHaveLength(30);
expect(snapshots.at(-1)?.loading).toBe(false);
});
it('does not skip failed first-pass tasks from the queued full refresh', async () => {
const first = createDeferred<TeamTaskChangeSummariesResponse>();
const second = createDeferred<TeamTaskChangeSummariesResponse>();
const third = createDeferred<TeamTaskChangeSummariesResponse>();
hoisted.getTeamTaskChangeSummaries
.mockReturnValueOnce(first.promise)
.mockReturnValueOnce(second.promise)
.mockReturnValueOnce(third.promise);
const tasks = changedTasks(30);
const snapshots: HookSnapshot[] = [];
const onSnapshot = (snapshot: HookSnapshot): void => {
snapshots.push(snapshot);
};
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root?.render(React.createElement(HookHarness, { tasks, onSnapshot }));
});
const firstRequests = hoisted.getTeamTaskChangeSummaries.mock
.calls[0][1] as TeamTaskChangeSummaryRequest[];
expect(firstRequests).toHaveLength(3);
const failedTaskId = firstRequests[0]?.taskId ?? '';
await act(async () => {
first.resolve({
teamName: 'team-a',
computedAt: '2026-05-10T10:00:01.000Z',
items: firstRequests.map((request, index) =>
index === 0
? { taskId: request.taskId, changeSet: null, error: 'first pass failed' }
: { taskId: request.taskId, changeSet: changeSet(request.taskId) }
),
});
await first.promise;
await Promise.resolve();
});
await act(async () => {
await Promise.resolve();
});
expect(hoisted.getTeamTaskChangeSummaries).toHaveBeenCalledTimes(2);
const secondRequests = hoisted.getTeamTaskChangeSummaries.mock
.calls[1][1] as TeamTaskChangeSummaryRequest[];
expect(secondRequests).toHaveLength(9);
expect(secondRequests.some((request) => request.taskId === failedTaskId)).toBe(true);
expect(secondRequests.some((request) => request.taskId === firstRequests[1]?.taskId)).toBe(
false
);
await act(async () => {
second.resolve(responseForRequests(secondRequests));
await second.promise;
await Promise.resolve();
});
await act(async () => {
await Promise.resolve();
});
expect(hoisted.getTeamTaskChangeSummaries).toHaveBeenCalledTimes(3);
const thirdRequests = hoisted.getTeamTaskChangeSummaries.mock
.calls[2][1] as TeamTaskChangeSummaryRequest[];
expect(thirdRequests).toHaveLength(19);
await act(async () => {
third.resolve(responseForRequests(thirdRequests));
await third.promise;
await Promise.resolve();
});
expect(Object.keys(snapshots.at(-1)?.summariesByTaskId ?? {})).toHaveLength(30);
});
it('does not apply staged in-flight results after the section closes', async () => {
const first = createDeferred<TeamTaskChangeSummariesResponse>();
const second = createDeferred<TeamTaskChangeSummariesResponse>();
hoisted.getTeamTaskChangeSummaries
.mockReturnValueOnce(first.promise)
.mockReturnValueOnce(second.promise);
const tasks = changedTasks(30);
const snapshots: HookSnapshot[] = [];
const onSnapshot = (snapshot: HookSnapshot): void => {
snapshots.push(snapshot);
};
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root?.render(React.createElement(HookHarness, { tasks, onSnapshot }));
});
const firstRequests = hoisted.getTeamTaskChangeSummaries.mock
.calls[0][1] as TeamTaskChangeSummaryRequest[];
await act(async () => {
first.resolve(responseForRequests(firstRequests));
await first.promise;
await Promise.resolve();
});
await act(async () => {
await Promise.resolve();
});
expect(hoisted.getTeamTaskChangeSummaries).toHaveBeenCalledTimes(2);
expect(Object.keys(snapshots.at(-1)?.summariesByTaskId ?? {})).toHaveLength(3);
const secondRequests = hoisted.getTeamTaskChangeSummaries.mock
.calls[1][1] as TeamTaskChangeSummaryRequest[];
await act(async () => {
root?.render(
React.createElement(HookHarness, {
tasks: [],
sectionOpen: false,
onSnapshot,
})
);
await Promise.resolve();
});
await act(async () => {
second.resolve(responseForRequests(secondRequests));
await second.promise;
await Promise.resolve();
});
expect(snapshots.at(-1)?.loading).toBe(false);
expect(snapshots.at(-1)?.refreshing).toBe(false);
expect(snapshots.at(-1)?.summariesByTaskId).toEqual({});
});
it('starts force refresh from the first staged batch after a completed staged load', async () => {
const first = createDeferred<TeamTaskChangeSummariesResponse>();
const second = createDeferred<TeamTaskChangeSummariesResponse>();
const third = createDeferred<TeamTaskChangeSummariesResponse>();
const fourth = createDeferred<TeamTaskChangeSummariesResponse>();
hoisted.getTeamTaskChangeSummaries
.mockReturnValueOnce(first.promise)
.mockReturnValueOnce(second.promise)
.mockReturnValueOnce(third.promise)
.mockReturnValueOnce(fourth.promise);
const tasks = changedTasks(30);
const snapshots: HookSnapshot[] = [];
const onSnapshot = (snapshot: HookSnapshot): void => {
snapshots.push(snapshot);
};
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root?.render(React.createElement(HookHarness, { tasks, onSnapshot }));
});
const firstRequests = hoisted.getTeamTaskChangeSummaries.mock
.calls[0][1] as TeamTaskChangeSummaryRequest[];
await act(async () => {
first.resolve(responseForRequests(firstRequests));
await first.promise;
await Promise.resolve();
});
await act(async () => {
await Promise.resolve();
});
const secondRequests = hoisted.getTeamTaskChangeSummaries.mock
.calls[1][1] as TeamTaskChangeSummaryRequest[];
await act(async () => {
second.resolve(responseForRequests(secondRequests));
await second.promise;
await Promise.resolve();
});
await act(async () => {
await Promise.resolve();
});
const thirdRequests = hoisted.getTeamTaskChangeSummaries.mock
.calls[2][1] as TeamTaskChangeSummaryRequest[];
await act(async () => {
third.resolve(responseForRequests(thirdRequests));
await third.promise;
await Promise.resolve();
});
expect(Object.keys(snapshots.at(-1)?.summariesByTaskId ?? {})).toHaveLength(30);
await act(async () => {
snapshots.at(-1)?.refresh();
await Promise.resolve();
});
expect(hoisted.getTeamTaskChangeSummaries).toHaveBeenCalledTimes(4);
const refreshRequests = hoisted.getTeamTaskChangeSummaries.mock
.calls[3][1] as TeamTaskChangeSummaryRequest[];
expect(refreshRequests).toHaveLength(3);
expect(refreshRequests.map((request) => request.taskId)).toEqual(
firstRequests.map((request) => request.taskId)
);
expect(refreshRequests.every((request) => request.options?.forceFresh === true)).toBe(true);
await act(async () => {
fourth.resolve(responseForRequests(refreshRequests));
await fourth.promise;
await Promise.resolve();
});
});
it('runs a queued closed counter refresh when tasks change during an active count load', async () => { it('runs a queued closed counter refresh when tasks change during an active count load', async () => {
const first = createDeferred<TeamTaskChangeSummariesResponse>(); const first = createDeferred<TeamTaskChangeSummariesResponse>();
const second = createDeferred<TeamTaskChangeSummariesResponse>(); const second = createDeferred<TeamTaskChangeSummariesResponse>();
@ -759,7 +1079,7 @@ describe('useTeamChangesSummaries', () => {
expect(container.textContent).toContain('0'); expect(container.textContent).toContain('0');
}); });
it('does not lose the full load queued by opening the section during a failed count load', async () => { it('starts the full load immediately when opening during an active count load', async () => {
const first = createDeferred<TeamTaskChangeSummariesResponse>(); const first = createDeferred<TeamTaskChangeSummariesResponse>();
const second = createDeferred<TeamTaskChangeSummariesResponse>(); const second = createDeferred<TeamTaskChangeSummariesResponse>();
hoisted.getTeamTaskChangeSummaries hoisted.getTeamTaskChangeSummaries
@ -804,13 +1124,16 @@ describe('useTeamChangesSummaries', () => {
}); });
expect(container.textContent).toContain('Loading changes...'); expect(container.textContent).toContain('Loading changes...');
expect(hoisted.getTeamTaskChangeSummaries).toHaveBeenCalledTimes(2);
await act(async () => { await act(async () => {
first.reject(new Error('silent count failed')); first.resolve(lowConfidenceFileResponse());
await first.promise.catch(() => undefined); await first.promise;
await Promise.resolve(); await Promise.resolve();
}); });
expect(container.textContent).toContain('Loading changes...');
expect(container.textContent).not.toContain('src/app.ts');
expect(hoisted.getTeamTaskChangeSummaries).toHaveBeenCalledTimes(2); expect(hoisted.getTeamTaskChangeSummaries).toHaveBeenCalledTimes(2);
await act(async () => { await act(async () => {
@ -820,7 +1143,6 @@ describe('useTeamChangesSummaries', () => {
}); });
expect(container.textContent).toContain('src/app.ts'); expect(container.textContent).toContain('src/app.ts');
expect(container.textContent).not.toContain('silent count failed');
} finally { } finally {
if (scrollIntoViewDescriptor) { if (scrollIntoViewDescriptor) {
Object.defineProperty(Element.prototype, 'scrollIntoView', scrollIntoViewDescriptor); Object.defineProperty(Element.prototype, 'scrollIntoView', scrollIntoViewDescriptor);

View file

@ -4,6 +4,7 @@ import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo
import { Checkbox } from '@renderer/components/ui/checkbox'; import { Checkbox } from '@renderer/components/ui/checkbox';
import { Input } from '@renderer/components/ui/input'; import { Input } from '@renderer/components/ui/input';
import { Label } from '@renderer/components/ui/label'; import { Label } from '@renderer/components/ui/label';
import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover';
import { Tabs, TabsList, TabsTrigger } from '@renderer/components/ui/tabs'; import { Tabs, TabsList, TabsTrigger } from '@renderer/components/ui/tabs';
import { import {
Tooltip, Tooltip,
@ -26,6 +27,7 @@ import {
isTeamProviderModelVerificationPending, isTeamProviderModelVerificationPending,
normalizeTeamModelForUi, normalizeTeamModelForUi,
TEAM_MODEL_UI_DISABLED_BADGE_LABEL, TEAM_MODEL_UI_DISABLED_BADGE_LABEL,
type TeamRuntimeModelOption,
} from '@renderer/utils/teamModelAvailability'; } from '@renderer/utils/teamModelAvailability';
import { import {
doesTeamModelCarryProviderBrand, doesTeamModelCarryProviderBrand,
@ -34,9 +36,7 @@ import {
getTeamModelLabel as getCatalogTeamModelLabel, getTeamModelLabel as getCatalogTeamModelLabel,
getTeamModelSourceBadgeLabel, getTeamModelSourceBadgeLabel,
getTeamProviderLabel as getCatalogTeamProviderLabel, getTeamProviderLabel as getCatalogTeamProviderLabel,
isAnthropicHaikuTeamModel,
} from '@renderer/utils/teamModelCatalog'; } from '@renderer/utils/teamModelCatalog';
import { extractProviderScopedBaseModel } from '@renderer/utils/teamModelContext';
import { import {
compareTeamModelRecommendations, compareTeamModelRecommendations,
getTeamModelRecommendation, getTeamModelRecommendation,
@ -44,8 +44,19 @@ import {
} from '@renderer/utils/teamModelRecommendations'; } from '@renderer/utils/teamModelRecommendations';
import { resolveAnthropicLaunchModel } from '@shared/utils/anthropicLaunchModel'; import { resolveAnthropicLaunchModel } from '@shared/utils/anthropicLaunchModel';
import { getAnthropicDefaultTeamModel } from '@shared/utils/anthropicModelDefaults'; import { getAnthropicDefaultTeamModel } from '@shared/utils/anthropicModelDefaults';
import { parseOpenCodeQualifiedModelRef } from '@shared/utils/opencodeModelRef';
import { isTeamProviderId } from '@shared/utils/teamProvider'; import { isTeamProviderId } from '@shared/utils/teamProvider';
import { AlertTriangle, CheckCircle2, Info, Search, Star } from 'lucide-react'; import { Command as CommandPrimitive } from 'cmdk';
import {
AlertTriangle,
Check,
CheckCircle2,
ChevronDown,
Filter,
Info,
Search,
Star,
} from 'lucide-react';
import type { CliProviderStatus, TeamProviderId } from '@shared/types'; import type { CliProviderStatus, TeamProviderId } from '@shared/types';
@ -59,6 +70,18 @@ interface ProviderDef {
comingSoon: boolean; comingSoon: boolean;
} }
interface OpenCodeSourceOption {
id: string;
label: string;
count: number;
}
interface OpenCodeModelGroup {
sourceId: string;
sourceLabel: string;
options: TeamRuntimeModelOption[];
}
const PROVIDERS: ProviderDef[] = [ const PROVIDERS: ProviderDef[] = [
{ id: 'anthropic', label: 'Anthropic', comingSoon: false }, { id: 'anthropic', label: 'Anthropic', comingSoon: false },
{ id: 'codex', label: 'Codex', comingSoon: false }, { id: 'codex', label: 'Codex', comingSoon: false },
@ -66,6 +89,18 @@ const PROVIDERS: ProviderDef[] = [
{ id: 'opencode', label: 'OpenCode', comingSoon: false }, { id: 'opencode', label: 'OpenCode', comingSoon: false },
]; ];
function getOpenCodeSourceInfo(model: string): { id: string; label: string } | null {
const parsed = parseOpenCodeQualifiedModelRef(model);
if (!parsed) {
return null;
}
return {
id: parsed.sourceId,
label: getTeamModelSourceBadgeLabel('opencode', model) ?? parsed.sourceId,
};
}
const OPENCODE_UI_DISABLED_REASON = 'OpenCode team launch is not ready.'; const OPENCODE_UI_DISABLED_REASON = 'OpenCode team launch is not ready.';
export const OPENCODE_ONE_SHOT_DISABLED_REASON = export const OPENCODE_ONE_SHOT_DISABLED_REASON =
'OpenCode team launch is available for normal teams, but scheduled one-shot prompts still run through claude -p. Choose Anthropic, Codex, or Gemini for one-shot schedules.'; 'OpenCode team launch is available for normal teams, but scheduled one-shot prompts still run through claude -p. Choose Anthropic, Codex, or Gemini for one-shot schedules.';
@ -175,14 +210,16 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
const multimodelEnabled = useStore((s) => s.appConfig?.general?.multimodelEnabled ?? true); const multimodelEnabled = useStore((s) => s.appConfig?.general?.multimodelEnabled ?? true);
const [recommendedOnly, setRecommendedOnly] = useState(false); const [recommendedOnly, setRecommendedOnly] = useState(false);
const [modelQuery, setModelQuery] = useState(''); const [modelQuery, setModelQuery] = useState('');
const [openCodeSourceFilterOpen, setOpenCodeSourceFilterOpen] = useState(false);
const [openCodeSourceQuery, setOpenCodeSourceQuery] = useState('');
const [selectedOpenCodeSourceIds, setSelectedOpenCodeSourceIds] = useState<Set<string>>(
() => new Set()
);
const effectiveProviderId = const effectiveProviderId =
disableGeminiOption && isGeminiUiFrozen() && providerId === 'gemini' ? 'anthropic' : providerId; disableGeminiOption && isGeminiUiFrozen() && providerId === 'gemini' ? 'anthropic' : providerId;
const { const { cliStatus: effectiveCliStatus, providerStatus: runtimeProviderStatus } =
cliStatus: effectiveCliStatus, useEffectiveCliProviderStatus(effectiveProviderId);
providerStatus: runtimeProviderStatus,
loading: effectiveCliStatusLoading,
} = useEffectiveCliProviderStatus(effectiveProviderId);
const multimodelAvailable = const multimodelAvailable =
multimodelEnabled || effectiveCliStatus?.flavor === 'agent_teams_orchestrator'; multimodelEnabled || effectiveCliStatus?.flavor === 'agent_teams_orchestrator';
const runtimeProviderStatusById = useMemo( const runtimeProviderStatusById = useMemo(
@ -324,14 +361,112 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
useEffect(() => { useEffect(() => {
if (effectiveProviderId !== 'opencode' || !hasRecommendedOpenCodeModels) { if (effectiveProviderId !== 'opencode' || !hasRecommendedOpenCodeModels) {
setRecommendedOnly(false); queueMicrotask(() => setRecommendedOnly(false));
} }
}, [effectiveProviderId, hasRecommendedOpenCodeModels]); }, [effectiveProviderId, hasRecommendedOpenCodeModels]);
useEffect(() => { useEffect(() => {
setModelQuery(''); queueMicrotask(() => setModelQuery(''));
}, [effectiveProviderId]); }, [effectiveProviderId]);
useEffect(() => {
if (effectiveProviderId !== 'opencode') {
queueMicrotask(() => {
setSelectedOpenCodeSourceIds(new Set());
setOpenCodeSourceFilterOpen(false);
});
}
}, [effectiveProviderId]);
useEffect(() => {
if (!openCodeSourceFilterOpen) {
queueMicrotask(() => setOpenCodeSourceQuery(''));
}
}, [openCodeSourceFilterOpen]);
const openCodeSourceOptions = useMemo<OpenCodeSourceOption[]>(() => {
if (effectiveProviderId !== 'opencode') {
return [];
}
const sourceOptions = new Map<string, OpenCodeSourceOption>();
for (const option of modelOptions) {
if (!option.value.trim()) {
continue;
}
if (recommendedOnly && !isTeamModelRecommended(effectiveProviderId, option.value)) {
continue;
}
const sourceInfo = getOpenCodeSourceInfo(option.value);
if (!sourceInfo) {
continue;
}
const existing = sourceOptions.get(sourceInfo.id);
sourceOptions.set(sourceInfo.id, {
id: sourceInfo.id,
label: sourceInfo.label,
count: (existing?.count ?? 0) + 1,
});
}
return Array.from(sourceOptions.values()).sort((left, right) =>
left.label.localeCompare(right.label, undefined, { sensitivity: 'base' })
);
}, [effectiveProviderId, modelOptions, recommendedOnly]);
useEffect(() => {
if (selectedOpenCodeSourceIds.size === 0) {
return;
}
const availableSourceIds = new Set(openCodeSourceOptions.map((source) => source.id));
const nextSelectedSourceIds = new Set(
Array.from(selectedOpenCodeSourceIds).filter((sourceId) => availableSourceIds.has(sourceId))
);
if (nextSelectedSourceIds.size !== selectedOpenCodeSourceIds.size) {
queueMicrotask(() => setSelectedOpenCodeSourceIds(nextSelectedSourceIds));
}
}, [openCodeSourceOptions, selectedOpenCodeSourceIds]);
const filteredOpenCodeSourceOptions = useMemo(() => {
const query = openCodeSourceQuery.trim().toLowerCase();
if (!query) {
return openCodeSourceOptions;
}
return openCodeSourceOptions.filter((source) =>
[source.id, source.label].join(' ').toLowerCase().includes(query)
);
}, [openCodeSourceOptions, openCodeSourceQuery]);
const selectedOpenCodeSourceLabels = useMemo(() => {
const labelById = new Map(openCodeSourceOptions.map((source) => [source.id, source.label]));
return Array.from(selectedOpenCodeSourceIds)
.map((sourceId) => labelById.get(sourceId))
.filter((label): label is string => Boolean(label));
}, [openCodeSourceOptions, selectedOpenCodeSourceIds]);
const openCodeSourceFilterLabel =
selectedOpenCodeSourceLabels.length === 0
? 'All OpenCode providers'
: selectedOpenCodeSourceLabels.length === 1
? selectedOpenCodeSourceLabels[0]
: `${selectedOpenCodeSourceLabels.length} OpenCode providers`;
const toggleOpenCodeSourceFilter = (sourceId: string): void => {
setSelectedOpenCodeSourceIds((previous) => {
const next = new Set(previous);
if (next.has(sourceId)) {
next.delete(sourceId);
} else {
next.add(sourceId);
}
return next;
});
};
const visibleModelOptions = useMemo(() => { const visibleModelOptions = useMemo(() => {
const normalizedModelQuery = modelQuery.trim().toLowerCase(); const normalizedModelQuery = modelQuery.trim().toLowerCase();
const matchesModelQuery = (option: (typeof modelOptions)[number]): boolean => { const matchesModelQuery = (option: (typeof modelOptions)[number]): boolean => {
@ -343,6 +478,7 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
option.value, option.value,
option.label, option.label,
option.badgeLabel ?? '', option.badgeLabel ?? '',
getOpenCodeSourceInfo(option.value)?.label ?? '',
modelRecommendation?.label ?? '', modelRecommendation?.label ?? '',
modelRecommendation?.reason ?? '', modelRecommendation?.reason ?? '',
] ]
@ -362,6 +498,13 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
({ option }) => ({ option }) =>
!recommendedOnly || isTeamModelRecommended(effectiveProviderId, option.value) !recommendedOnly || isTeamModelRecommended(effectiveProviderId, option.value)
) )
.filter(({ option }) => {
if (selectedOpenCodeSourceIds.size === 0) {
return true;
}
const sourceInfo = getOpenCodeSourceInfo(option.value);
return Boolean(sourceInfo && selectedOpenCodeSourceIds.has(sourceInfo.id));
})
.filter(({ option }) => matchesModelQuery(option)) .filter(({ option }) => matchesModelQuery(option))
.sort((left, right) => { .sort((left, right) => {
const recommendationOrder = compareTeamModelRecommendations( const recommendationOrder = compareTeamModelRecommendations(
@ -381,11 +524,201 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
...modelOptions.filter((option) => option.value.trim().length === 0), ...modelOptions.filter((option) => option.value.trim().length === 0),
...concreteOptions, ...concreteOptions,
].filter(matchesModelQuery); ].filter(matchesModelQuery);
}, [effectiveProviderId, modelOptions, modelQuery, recommendedOnly]); }, [effectiveProviderId, modelOptions, modelQuery, recommendedOnly, selectedOpenCodeSourceIds]);
const visibleOpenCodeModelGroups = useMemo<OpenCodeModelGroup[]>(() => {
if (effectiveProviderId !== 'opencode') {
return [];
}
const groups = new Map<string, OpenCodeModelGroup>();
for (const option of visibleModelOptions) {
if (!option.value.trim()) {
continue;
}
const sourceInfo = getOpenCodeSourceInfo(option.value);
if (!sourceInfo) {
continue;
}
const existingGroup = groups.get(sourceInfo.id);
if (existingGroup) {
existingGroup.options.push(option);
} else {
groups.set(sourceInfo.id, {
sourceId: sourceInfo.id,
sourceLabel: sourceInfo.label,
options: [option],
});
}
}
return Array.from(groups.values());
}, [effectiveProviderId, visibleModelOptions]);
const visibleDefaultModelOptions = visibleModelOptions.filter((option) => !option.value.trim());
const concreteModelOptionCount = modelOptions.filter((option) => option.value.trim()).length; const concreteModelOptionCount = modelOptions.filter((option) => option.value.trim()).length;
const shouldShowModelSearch = concreteModelOptionCount > 8; const shouldShowModelSearch = concreteModelOptionCount > 8;
const trimmedModelQuery = modelQuery.trim(); const trimmedModelQuery = modelQuery.trim();
const shouldConstrainModelListHeight = visibleModelOptions.length > 8; const shouldConstrainModelListHeight = visibleModelOptions.length > 8;
const renderModelOption = (opt: TeamRuntimeModelOption): React.JSX.Element => {
const modelDisabledReason = getTeamModelUiDisabledReason(
effectiveProviderId,
opt.value,
runtimeProviderStatus
);
const availabilityStatus =
opt.value === '' ? 'available' : (opt.availabilityStatus ?? 'available');
const availabilityReason = opt.value === '' ? null : (opt.availabilityReason ?? null);
const runtimeUnavailableReason =
opt.value !== '' && availabilityStatus === 'unavailable'
? (availabilityReason ?? 'Unavailable in current runtime')
: null;
const modelIssueReason =
opt.value === '' ? null : (modelIssueReasonByValue?.[opt.value] ?? null);
const modelUnavailableReason =
opt.value === ''
? null
: (modelUnavailableReasonByValue?.[opt.value] ??
getOpenCodeOpenAiRouteAuthUnavailableReason(
effectiveProviderId,
opt.value,
runtimeProviderStatus
) ??
runtimeUnavailableReason);
const hasModelIssue = Boolean(modelIssueReason || modelUnavailableReason);
const modelSelectable =
activeProviderSelectable &&
!modelUnavailableReason &&
!modelDisabledReason &&
(opt.value === '' || availabilityStatus == null || availabilityStatus === 'available');
const modelStatusMessage =
modelUnavailableReason ??
modelIssueReason ??
modelDisabledReason ??
availabilityReason ??
null;
const modelRecommendation = getTeamModelRecommendation(effectiveProviderId, opt.value);
return (
<button
key={opt.value || '__default__'}
type="button"
id={opt.value === normalizedValue ? id : undefined}
aria-disabled={!modelSelectable}
title={modelStatusMessage ?? undefined}
className={cn(
'flex min-h-[44px] items-center justify-center gap-1.5 rounded-md border bg-[var(--color-surface)] px-3 py-2 text-center text-xs font-medium transition-[background-color,border-color,color,box-shadow] duration-150',
hasModelIssue && normalizedValue === opt.value
? 'border-red-500/60 bg-red-500/10 text-red-100 shadow-sm'
: hasModelIssue
? 'border-red-500/40 bg-red-500/5 text-red-200 hover:border-red-400/60 hover:bg-red-500/10 hover:text-red-100'
: normalizedValue === opt.value
? 'border-[var(--color-border-emphasis)] bg-[var(--color-surface-raised)] text-[var(--color-text)] shadow-sm'
: modelSelectable
? 'border-[var(--color-border-subtle)] text-[var(--color-text-muted)] hover:border-[var(--color-border-emphasis)] hover:bg-[color-mix(in_srgb,var(--color-surface-raised)_62%,var(--color-surface)_38%)] hover:text-[var(--color-text-secondary)] hover:shadow-sm'
: 'border-[var(--color-border-subtle)] text-[var(--color-text-muted)]',
!modelSelectable && 'cursor-not-allowed opacity-45',
!modelDisabledReason && !activeProviderSelectable && 'pointer-events-none'
)}
onClick={() => {
if (!modelSelectable) return;
onValueChange(opt.value);
}}
>
<span className="flex flex-col items-center justify-center gap-0.5">
<span className={cn('leading-tight', opt.value === 'gpt-5.5' && 'font-bold')}>
{opt.label}
</span>
{modelRecommendation ? (
<span
className={cn(
'inline-flex items-center justify-center gap-1 rounded-full border px-2 py-0.5 text-[10px] font-semibold',
modelRecommendation.level === 'recommended'
? 'border-emerald-300/35 bg-emerald-300/10 text-emerald-200'
: modelRecommendation.level === 'recommended-with-limits'
? 'border-amber-300/35 bg-amber-300/10 text-amber-200'
: modelRecommendation.level === 'tested'
? 'border-sky-300/35 bg-sky-300/10 text-sky-200'
: modelRecommendation.level === 'tested-with-limits'
? 'border-cyan-300/30 bg-cyan-400/10 text-cyan-200'
: modelRecommendation.level === 'unavailable-in-opencode'
? 'border-slate-300/30 bg-slate-400/10 text-slate-200'
: 'border-red-300/35 bg-red-400/10 text-red-200'
)}
title={modelRecommendation.reason}
>
{modelRecommendation.level === 'not-recommended' ||
modelRecommendation.level === 'unavailable-in-opencode' ? (
<AlertTriangle className="size-3 shrink-0" />
) : modelRecommendation.level === 'tested' ||
modelRecommendation.level === 'tested-with-limits' ? (
<CheckCircle2 className="size-3 shrink-0" />
) : (
<Star className="size-3 shrink-0 fill-current" />
)}
<span>{modelRecommendation.label}</span>
</span>
) : null}
{opt.value === '' && (
<span className="flex items-center justify-center gap-1">
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild onClick={(e: React.MouseEvent) => e.stopPropagation()}>
<Info className="size-3 shrink-0 opacity-40 transition-opacity hover:opacity-70" />
</TooltipTrigger>
<TooltipContent side="top" className="max-w-[240px] text-xs">
{defaultModelTooltip.split('\n').map((line, index) => (
<React.Fragment key={line}>
{index > 0 ? <br /> : null}
{line}
</React.Fragment>
))}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</span>
)}
{hasModelIssue && (
<span
className="flex items-center justify-center gap-1 text-[10px] font-normal text-red-300"
title={modelStatusMessage ?? undefined}
>
<AlertTriangle className="size-3 shrink-0" />
<span>{modelUnavailableReason ? 'Unavailable' : 'Issue'}</span>
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild onClick={(e: React.MouseEvent) => e.stopPropagation()}>
<Info className="size-3 shrink-0 opacity-50 transition-opacity hover:opacity-80" />
</TooltipTrigger>
<TooltipContent side="top" className="max-w-[240px] text-xs">
{modelStatusMessage}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</span>
)}
{!hasModelIssue && modelDisabledReason && (
<span
className="flex items-center justify-center gap-1 text-[10px] font-normal text-[var(--color-text-muted)]"
title={modelDisabledReason}
>
<span>{TEAM_MODEL_UI_DISABLED_BADGE_LABEL}</span>
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild onClick={(e: React.MouseEvent) => e.stopPropagation()}>
<Info className="size-3 shrink-0 opacity-40 transition-opacity hover:opacity-70" />
</TooltipTrigger>
<TooltipContent side="top" className="max-w-[240px] text-xs">
{modelDisabledReason}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</span>
)}
</span>
</button>
);
};
return ( return (
<div className="mb-5"> <div className="mb-5">
@ -483,8 +816,89 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
/> />
</div> </div>
) : null} ) : null}
{(effectiveProviderId === 'opencode' && openCodeSourceOptions.length > 1) ||
hasRecommendedOpenCodeModels ? (
<div className="mb-2 flex flex-wrap items-center gap-2">
{effectiveProviderId === 'opencode' && openCodeSourceOptions.length > 1 ? (
<Popover
open={openCodeSourceFilterOpen}
onOpenChange={setOpenCodeSourceFilterOpen}
>
<PopoverTrigger asChild>
<button
type="button"
data-testid="team-model-selector-opencode-provider-filter"
className={cn(
'inline-flex h-8 max-w-full items-center gap-1.5 rounded-md border border-[var(--color-border)] bg-transparent px-2.5 text-xs text-[var(--color-text-secondary)] shadow-sm transition-colors hover:border-[var(--color-border-emphasis)] hover:bg-[var(--color-surface-raised)] hover:text-[var(--color-text)] focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-[var(--color-border-emphasis)]',
selectedOpenCodeSourceIds.size > 0 &&
'border-[var(--color-border-emphasis)] text-[var(--color-text)]'
)}
aria-label="Filter OpenCode providers"
>
<Filter className="size-3.5 shrink-0" />
<span className="min-w-0 truncate">{openCodeSourceFilterLabel}</span>
<ChevronDown className="size-3.5 shrink-0 opacity-60" />
</button>
</PopoverTrigger>
<PopoverContent align="start" className="w-72 p-0">
<CommandPrimitive
className="flex size-full flex-col overflow-hidden rounded-md bg-[var(--color-surface)]"
shouldFilter={false}
>
<div className="flex items-center border-b border-[var(--color-border)]">
<CommandPrimitive.Input
value={openCodeSourceQuery}
onValueChange={setOpenCodeSourceQuery}
placeholder="Search providers"
className="flex h-8 w-full border-0 bg-transparent px-2 py-1 text-xs text-[var(--color-text)] outline-none placeholder:text-[var(--color-text-muted)]"
/>
</div>
<CommandPrimitive.List className="max-h-72 overflow-y-auto overscroll-contain p-1">
<CommandPrimitive.Empty className="py-4 text-center text-xs text-[var(--color-text-muted)]">
No providers found.
</CommandPrimitive.Empty>
{selectedOpenCodeSourceIds.size > 0 && !openCodeSourceQuery.trim() ? (
<CommandPrimitive.Item
value="__all_opencode_providers__"
onSelect={() => setSelectedOpenCodeSourceIds(new Set())}
className="flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-xs text-[var(--color-text-muted)] outline-none data-[selected=true]:bg-[var(--color-surface-raised)] data-[selected=true]:text-[var(--color-text)]"
>
<Check className="size-3.5 shrink-0 opacity-70" />
All OpenCode providers
</CommandPrimitive.Item>
) : null}
{filteredOpenCodeSourceOptions.map((source) => {
const selected = selectedOpenCodeSourceIds.has(source.id);
return (
<CommandPrimitive.Item
key={source.id}
value={`${source.label} ${source.id}`}
onSelect={() => toggleOpenCodeSourceFilter(source.id)}
className="flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-xs outline-none data-[selected=true]:bg-[var(--color-surface-raised)] data-[selected=true]:text-[var(--color-text)]"
>
<Checkbox
checked={selected}
onCheckedChange={() => toggleOpenCodeSourceFilter(source.id)}
onClick={(event) => event.stopPropagation()}
className="size-3.5"
aria-label={`Filter ${source.label}`}
/>
<span className="min-w-0 flex-1 truncate text-[var(--color-text)]">
{source.label}
</span>
<span className="shrink-0 text-[10px] text-[var(--color-text-muted)]">
{source.count}
</span>
</CommandPrimitive.Item>
);
})}
</CommandPrimitive.List>
</CommandPrimitive>
</PopoverContent>
</Popover>
) : null}
{hasRecommendedOpenCodeModels ? ( {hasRecommendedOpenCodeModels ? (
<div className="mb-2 flex w-fit items-center gap-2"> <div className="flex w-fit items-center gap-2">
<Checkbox <Checkbox
id="opencode-team-model-recommended-only" id="opencode-team-model-recommended-only"
checked={recommendedOnly} checked={recommendedOnly}
@ -499,6 +913,47 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
</Label> </Label>
</div> </div>
) : null} ) : null}
</div>
) : null}
{effectiveProviderId === 'opencode' ? (
<div
data-testid="team-model-selector-model-grid"
className={cn(
'space-y-3 rounded-md bg-[var(--color-surface)]',
shouldConstrainModelListHeight && 'overflow-y-auto pr-1'
)}
style={{
maxHeight: shouldConstrainModelListHeight ? 400 : undefined,
}}
>
{visibleDefaultModelOptions.length > 0 ? (
<div
className="grid gap-1.5"
style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))' }}
>
{visibleDefaultModelOptions.map(renderModelOption)}
</div>
) : null}
{visibleOpenCodeModelGroups.map((group) => (
<section key={group.sourceId} data-testid="team-model-selector-opencode-group">
<div className="mb-1.5 flex items-center justify-between gap-2">
<h4 className="truncate text-[11px] font-semibold uppercase tracking-[0.08em] text-[var(--color-text-secondary)]">
{group.sourceLabel}
</h4>
<span className="shrink-0 text-[10px] text-[var(--color-text-muted)]">
{group.options.length}
</span>
</div>
<div
className="grid gap-1.5"
style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))' }}
>
{group.options.map(renderModelOption)}
</div>
</section>
))}
</div>
) : (
<div <div
data-testid="team-model-selector-model-grid" data-testid="team-model-selector-model-grid"
className={cn( className={cn(
@ -510,202 +965,9 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
maxHeight: shouldConstrainModelListHeight ? 400 : undefined, maxHeight: shouldConstrainModelListHeight ? 400 : undefined,
}} }}
> >
{visibleModelOptions.map((opt) => {visibleModelOptions.map(renderModelOption)}
(() => {
const modelDisabledReason = getTeamModelUiDisabledReason(
effectiveProviderId,
opt.value,
runtimeProviderStatus
);
const availabilityStatus =
opt.value === '' ? 'available' : (opt.availabilityStatus ?? 'available');
const availabilityReason =
opt.value === '' ? null : (opt.availabilityReason ?? null);
const runtimeUnavailableReason =
opt.value !== '' && availabilityStatus === 'unavailable'
? (availabilityReason ?? 'Unavailable in current runtime')
: null;
const modelIssueReason =
opt.value === '' ? null : (modelIssueReasonByValue?.[opt.value] ?? null);
const modelUnavailableReason =
opt.value === ''
? null
: (modelUnavailableReasonByValue?.[opt.value] ??
getOpenCodeOpenAiRouteAuthUnavailableReason(
effectiveProviderId,
opt.value,
runtimeProviderStatus
) ??
runtimeUnavailableReason);
const hasModelIssue = Boolean(modelIssueReason || modelUnavailableReason);
const modelSelectable =
activeProviderSelectable &&
!modelUnavailableReason &&
!modelDisabledReason &&
(opt.value === '' ||
availabilityStatus == null ||
availabilityStatus === 'available');
const modelStatusMessage =
modelUnavailableReason ??
modelIssueReason ??
modelDisabledReason ??
availabilityReason ??
null;
const sourceBadgeLabel =
effectiveProviderId === 'opencode' && opt.value !== ''
? opt.badgeLabel?.trim() || null
: null;
const modelRecommendation = getTeamModelRecommendation(
effectiveProviderId,
opt.value
);
return (
<button
key={opt.value || '__default__'}
type="button"
id={opt.value === normalizedValue ? id : undefined}
aria-disabled={!modelSelectable}
title={modelStatusMessage ?? undefined}
className={cn(
'flex min-h-[44px] items-center justify-center gap-1.5 rounded-md border bg-[var(--color-surface)] px-3 py-2 text-center text-xs font-medium transition-[background-color,border-color,color,box-shadow] duration-150',
hasModelIssue && normalizedValue === opt.value
? 'border-red-500/60 bg-red-500/10 text-red-100 shadow-sm'
: hasModelIssue
? 'border-red-500/40 bg-red-500/5 text-red-200 hover:border-red-400/60 hover:bg-red-500/10 hover:text-red-100'
: normalizedValue === opt.value
? 'border-[var(--color-border-emphasis)] bg-[var(--color-surface-raised)] text-[var(--color-text)] shadow-sm'
: modelSelectable
? 'border-[var(--color-border-subtle)] text-[var(--color-text-muted)] hover:border-[var(--color-border-emphasis)] hover:bg-[color-mix(in_srgb,var(--color-surface-raised)_62%,var(--color-surface)_38%)] hover:text-[var(--color-text-secondary)] hover:shadow-sm'
: 'border-[var(--color-border-subtle)] text-[var(--color-text-muted)]',
!modelSelectable && 'cursor-not-allowed opacity-45',
!modelDisabledReason && !activeProviderSelectable && 'pointer-events-none'
)}
onClick={() => {
if (!modelSelectable) return;
onValueChange(opt.value);
}}
>
<span className="flex flex-col items-center justify-center gap-0.5">
<span
className={cn('leading-tight', opt.value === 'gpt-5.5' && 'font-bold')}
>
{opt.label}
</span>
{sourceBadgeLabel ? (
<span
className="rounded-full border px-2 py-0.5 text-[10px] font-medium"
style={{
borderColor: 'var(--color-border-subtle)',
backgroundColor: 'rgba(255, 255, 255, 0.04)',
color: 'var(--color-text-secondary)',
}}
title={`Source: ${sourceBadgeLabel}`}
>
{sourceBadgeLabel}
</span>
) : null}
{modelRecommendation ? (
<span
className={cn(
'inline-flex items-center justify-center gap-1 rounded-full border px-2 py-0.5 text-[10px] font-semibold',
modelRecommendation.level === 'recommended'
? 'bg-emerald-300/12 border-emerald-300/35 text-emerald-200'
: modelRecommendation.level === 'recommended-with-limits'
? 'bg-amber-300/12 border-amber-300/35 text-amber-200'
: modelRecommendation.level === 'tested'
? 'bg-sky-300/12 border-sky-300/35 text-sky-200'
: modelRecommendation.level === 'tested-with-limits'
? 'border-cyan-300/30 bg-cyan-400/10 text-cyan-200'
: modelRecommendation.level === 'unavailable-in-opencode'
? 'border-slate-300/30 bg-slate-400/10 text-slate-200'
: 'border-red-300/35 bg-red-400/10 text-red-200'
)}
title={modelRecommendation.reason}
>
{modelRecommendation.level === 'not-recommended' ||
modelRecommendation.level === 'unavailable-in-opencode' ? (
<AlertTriangle className="size-3 shrink-0" />
) : modelRecommendation.level === 'tested' ||
modelRecommendation.level === 'tested-with-limits' ? (
<CheckCircle2 className="size-3 shrink-0" />
) : (
<Star className="size-3 shrink-0 fill-current" />
)}
<span>{modelRecommendation.label}</span>
</span>
) : null}
{opt.value === '' && (
<span className="flex items-center justify-center gap-1">
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger
asChild
onClick={(e: React.MouseEvent) => e.stopPropagation()}
>
<Info className="size-3 shrink-0 opacity-40 transition-opacity hover:opacity-70" />
</TooltipTrigger>
<TooltipContent side="top" className="max-w-[240px] text-xs">
{defaultModelTooltip.split('\n').map((line, index) => (
<React.Fragment key={line}>
{index > 0 ? <br /> : null}
{line}
</React.Fragment>
))}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</span>
)}
{hasModelIssue && (
<span
className="flex items-center justify-center gap-1 text-[10px] font-normal text-red-300"
title={modelStatusMessage ?? undefined}
>
<AlertTriangle className="size-3 shrink-0" />
<span>{modelUnavailableReason ? 'Unavailable' : 'Issue'}</span>
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger
asChild
onClick={(e: React.MouseEvent) => e.stopPropagation()}
>
<Info className="size-3 shrink-0 opacity-50 transition-opacity hover:opacity-80" />
</TooltipTrigger>
<TooltipContent side="top" className="max-w-[240px] text-xs">
{modelStatusMessage}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</span>
)}
{!hasModelIssue && modelDisabledReason && (
<span
className="flex items-center justify-center gap-1 text-[10px] font-normal text-[var(--color-text-muted)]"
title={modelDisabledReason}
>
<span>{TEAM_MODEL_UI_DISABLED_BADGE_LABEL}</span>
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger
asChild
onClick={(e: React.MouseEvent) => e.stopPropagation()}
>
<Info className="size-3 shrink-0 opacity-40 transition-opacity hover:opacity-70" />
</TooltipTrigger>
<TooltipContent side="top" className="max-w-[240px] text-xs">
{modelDisabledReason}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</span>
)}
</span>
</button>
);
})()
)}
</div> </div>
)}
{visibleModelOptions.length === 0 ? ( {visibleModelOptions.length === 0 ? (
<div className="rounded-md border border-white/10 px-3 py-2 text-xs text-[var(--color-text-muted)]"> <div className="rounded-md border border-white/10 px-3 py-2 text-xs text-[var(--color-text-muted)]">
{trimmedModelQuery {trimmedModelQuery

View file

@ -13,6 +13,12 @@ export const TEAM_CHANGES_MAX_REQUESTS = 120;
export const TEAM_CHANGES_UNKNOWN_SCAN_LIMIT = 32; export const TEAM_CHANGES_UNKNOWN_SCAN_LIMIT = 32;
export const TEAM_CHANGES_MAX_RENDERED_FILE_ROWS = 300; export const TEAM_CHANGES_MAX_RENDERED_FILE_ROWS = 300;
interface TeamChangeRequestPlanOptions {
maxRequests?: number;
unknownScanLimit?: number;
satisfiedTaskIds?: ReadonlySet<string>;
}
interface TeamChangeCandidate { interface TeamChangeCandidate {
task: TeamTaskWithKanban; task: TeamTaskWithKanban;
options: TaskChangeRequestOptions; options: TaskChangeRequestOptions;
@ -49,6 +55,13 @@ function rotateCandidates<T>(items: T[], cursor: number): T[] {
return [...items.slice(start), ...items.slice(0, start)]; return [...items.slice(start), ...items.slice(0, start)];
} }
function normalizePositiveLimit(value: number | undefined, fallback: number): number {
if (!Number.isFinite(value) || !value || value <= 0) {
return fallback;
}
return Math.max(1, Math.floor(value));
}
function hasTaskChangeScanEvidence(task: TeamTaskWithKanban): boolean { function hasTaskChangeScanEvidence(task: TeamTaskWithKanban): boolean {
if ((task.workIntervals?.length ?? 0) > 0 || (task.reviewIntervals?.length ?? 0) > 0) { if ((task.workIntervals?.length ?? 0) > 0 || (task.reviewIntervals?.length ?? 0) > 0) {
return true; return true;
@ -77,8 +90,15 @@ function getRelevantHistoryEvents(task: TeamTaskWithKanban): { type: string; tim
export function buildTeamChangeRequestPlan( export function buildTeamChangeRequestPlan(
tasks: TeamTaskWithKanban[], tasks: TeamTaskWithKanban[],
unknownScanCursor: number, unknownScanCursor: number,
forceFresh: boolean forceFresh: boolean,
options: TeamChangeRequestPlanOptions = {}
): TeamChangeRequestPlan { ): TeamChangeRequestPlan {
const maxRequests = normalizePositiveLimit(options.maxRequests, TEAM_CHANGES_MAX_REQUESTS);
const unknownScanLimit = Math.min(
normalizePositiveLimit(options.unknownScanLimit, TEAM_CHANGES_UNKNOWN_SCAN_LIMIT),
maxRequests
);
const satisfiedTaskIds = options.satisfiedTaskIds;
const primary: TeamChangeCandidate[] = []; const primary: TeamChangeCandidate[] = [];
const active: TeamChangeCandidate[] = []; const active: TeamChangeCandidate[] = [];
const unknown: TeamChangeCandidate[] = []; const unknown: TeamChangeCandidate[] = [];
@ -128,11 +148,22 @@ export function buildTeamChangeRequestPlan(
const eligibleTaskIds = new Set( const eligibleTaskIds = new Set(
[...primary, ...active, ...unknown].map((candidate) => candidate.task.id) [...primary, ...active, ...unknown].map((candidate) => candidate.task.id)
); );
const unknownWindow = rotateCandidates(unknown, unknownScanCursor).slice( const satisfiedEligibleTaskIds = new Set<string>();
const filterUnsatisfied = (candidate: TeamChangeCandidate): boolean => {
if (!satisfiedTaskIds?.has(candidate.task.id)) {
return true;
}
satisfiedEligibleTaskIds.add(candidate.task.id);
return false;
};
const requestPrimary = primary.filter(filterUnsatisfied);
const requestActive = active.filter(filterUnsatisfied);
const requestUnknown = unknown.filter(filterUnsatisfied);
const unknownWindow = rotateCandidates(requestUnknown, unknownScanCursor).slice(
0, 0,
TEAM_CHANGES_UNKNOWN_SCAN_LIMIT unknownScanLimit
); );
const selected = [...primary, ...active, ...unknownWindow].slice(0, TEAM_CHANGES_MAX_REQUESTS); const selected = [...requestPrimary, ...requestActive, ...unknownWindow].slice(0, maxRequests);
const requestOptionsByTaskId = new Map<string, TaskChangeRequestOptions>(); const requestOptionsByTaskId = new Map<string, TaskChangeRequestOptions>();
const requests = selected.map((candidate) => { const requests = selected.map((candidate) => {
const options = { const options = {
@ -148,9 +179,9 @@ export function buildTeamChangeRequestPlan(
}); });
const eligibleCount = primary.length + active.length + unknown.length; const eligibleCount = primary.length + active.length + unknown.length;
const nextUnknownScanCursor = const nextUnknownScanCursor =
unknown.length > 0 requestUnknown.length > 0
? (unknownScanCursor + Math.min(TEAM_CHANGES_UNKNOWN_SCAN_LIMIT, unknown.length)) % ? (unknownScanCursor + Math.min(unknownScanLimit, requestUnknown.length)) %
unknown.length requestUnknown.length
: 0; : 0;
return { return {
@ -159,7 +190,7 @@ export function buildTeamChangeRequestPlan(
eligibleTaskIds, eligibleTaskIds,
eligibleCount, eligibleCount,
requestedCount: requests.length, requestedCount: requests.length,
deferredCount: Math.max(0, eligibleCount - requests.length), deferredCount: Math.max(0, eligibleCount - satisfiedEligibleTaskIds.size - requests.length),
nextUnknownScanCursor, nextUnknownScanCursor,
}; };
} }

View file

@ -9,6 +9,7 @@ import { withTeamChangesLoadTimeout } from './teamChangesLoadTimeout';
import { import {
buildTeamChangeRequestPlan, buildTeamChangeRequestPlan,
buildTeamChangesTasksFingerprint, buildTeamChangesTasksFingerprint,
TEAM_CHANGES_MAX_REQUESTS,
} from './teamChangesRequestPlan'; } from './teamChangesRequestPlan';
import type { import type {
@ -20,6 +21,14 @@ import type {
const TEAM_CHANGES_AUTO_REFRESH_MS = 30_000; const TEAM_CHANGES_AUTO_REFRESH_MS = 30_000;
const TEAM_CHANGES_COUNTER_AUTO_REFRESH_MS = 60_000; const TEAM_CHANGES_COUNTER_AUTO_REFRESH_MS = 60_000;
const TEAM_CHANGES_FIRST_PAINT_REQUESTS = 3;
const TEAM_CHANGES_SECOND_PAINT_REQUESTS = 9;
const TEAM_CHANGES_FIRST_UNKNOWN_SCAN_LIMIT = 3;
const TEAM_CHANGES_SECOND_UNKNOWN_SCAN_LIMIT = 6;
const TEAM_CHANGES_INITIAL_STAGED_REFRESH_PLAN = [
TEAM_CHANGES_SECOND_PAINT_REQUESTS,
TEAM_CHANGES_MAX_REQUESTS,
] as const;
const TEAM_CHANGES_ERROR_AUTO_RETRY_COOLDOWN_MS = 120_000; const TEAM_CHANGES_ERROR_AUTO_RETRY_COOLDOWN_MS = 120_000;
export interface TeamChangeSummaryState { export interface TeamChangeSummaryState {
@ -41,6 +50,11 @@ interface TeamChangesLoadOptions {
storeSummaries?: boolean; storeSummaries?: boolean;
reportError?: boolean; reportError?: boolean;
blockAutoRetryOnError?: boolean; blockAutoRetryOnError?: boolean;
maxRequests?: number;
unknownScanLimit?: number;
queueDeferredRefresh?: boolean;
satisfiedTaskIds?: ReadonlySet<string>;
stagedRefreshPlan?: readonly number[];
} }
interface UseTeamChangesSummariesInput { interface UseTeamChangesSummariesInput {
@ -180,6 +194,40 @@ function isDocumentHidden(): boolean {
return typeof document !== 'undefined' && document.visibilityState === 'hidden'; return typeof document !== 'undefined' && document.visibilityState === 'hidden';
} }
function isSilentCounterLoad(options: TeamChangesLoadOptions | null): boolean {
return Boolean(
options &&
options.storeSummaries === false &&
options.reportError === false &&
options.showSpinner !== true
);
}
function getUnknownScanLimitForStage(maxRequests: number | undefined): number | undefined {
if (maxRequests === TEAM_CHANGES_FIRST_PAINT_REQUESTS) {
return TEAM_CHANGES_FIRST_UNKNOWN_SCAN_LIMIT;
}
if (maxRequests === TEAM_CHANGES_SECOND_PAINT_REQUESTS) {
return TEAM_CHANGES_SECOND_UNKNOWN_SCAN_LIMIT;
}
return undefined;
}
function mergeSuccessfulTaskIds(
existingTaskIds: ReadonlySet<string> | undefined,
responseItems: TeamTaskChangeSummaryItem[],
requestOptionsByTaskId: ReadonlyMap<string, unknown>
): ReadonlySet<string> | undefined {
const next = new Set(existingTaskIds);
for (const item of responseItems) {
if (item.error || item.changeSet === null || !requestOptionsByTaskId.has(item.taskId)) {
continue;
}
next.add(item.taskId);
}
return next.size > 0 ? next : undefined;
}
export function useTeamChangesSummaries({ export function useTeamChangesSummaries({
teamName, teamName,
tasks, tasks,
@ -205,6 +253,7 @@ export function useTeamChangesSummaries({
const mountedRef = useRef(true); const mountedRef = useRef(true);
const requestSeqRef = useRef(0); const requestSeqRef = useRef(0);
const activeRequestSeqRef = useRef<number | null>(null); const activeRequestSeqRef = useRef<number | null>(null);
const activeRequestOptionsRef = useRef<TeamChangesLoadOptions | null>(null);
const queuedRefreshOptionsRef = useRef<TeamChangesLoadOptions | null>(null); const queuedRefreshOptionsRef = useRef<TeamChangesLoadOptions | null>(null);
const autoRefreshBlockedUntilRef = useRef(0); const autoRefreshBlockedUntilRef = useRef(0);
const unknownScanCursorRef = useRef(0); const unknownScanCursorRef = useRef(0);
@ -218,6 +267,7 @@ export function useTeamChangesSummaries({
mountedRef.current = false; mountedRef.current = false;
requestSeqRef.current += 1; requestSeqRef.current += 1;
activeRequestSeqRef.current = null; activeRequestSeqRef.current = null;
activeRequestOptionsRef.current = null;
queuedRefreshOptionsRef.current = null; queuedRefreshOptionsRef.current = null;
autoRefreshBlockedUntilRef.current = 0; autoRefreshBlockedUntilRef.current = 0;
hasLoadedRef.current = false; hasLoadedRef.current = false;
@ -235,6 +285,11 @@ export function useTeamChangesSummaries({
storeSummaries = true, storeSummaries = true,
reportError = true, reportError = true,
blockAutoRetryOnError = true, blockAutoRetryOnError = true,
maxRequests,
unknownScanLimit,
queueDeferredRefresh = false,
satisfiedTaskIds,
stagedRefreshPlan,
}: TeamChangesLoadOptions = {}): Promise<void> => { }: TeamChangesLoadOptions = {}): Promise<void> => {
if (forceFresh) { if (forceFresh) {
autoRefreshBlockedUntilRef.current = 0; autoRefreshBlockedUntilRef.current = 0;
@ -242,6 +297,17 @@ export function useTeamChangesSummaries({
return; return;
} }
const shouldPreemptSilentCounterLoad =
activeRequestSeqRef.current !== null &&
storeSummaries &&
isSilentCounterLoad(activeRequestOptionsRef.current);
if (shouldPreemptSilentCounterLoad) {
requestSeqRef.current += 1;
activeRequestSeqRef.current = null;
activeRequestOptionsRef.current = null;
queuedRefreshOptionsRef.current = null;
}
if (activeRequestSeqRef.current !== null || queuedRefreshOptionsRef.current !== null) { if (activeRequestSeqRef.current !== null || queuedRefreshOptionsRef.current !== null) {
const previous = queuedRefreshOptionsRef.current; const previous = queuedRefreshOptionsRef.current;
queuedRefreshOptionsRef.current = { queuedRefreshOptionsRef.current = {
@ -255,6 +321,31 @@ export function useTeamChangesSummaries({
blockAutoRetryOnError: previous blockAutoRetryOnError: previous
? Boolean(previous.blockAutoRetryOnError || blockAutoRetryOnError) ? Boolean(previous.blockAutoRetryOnError || blockAutoRetryOnError)
: blockAutoRetryOnError, : blockAutoRetryOnError,
maxRequests:
maxRequests === undefined
? undefined
: previous?.maxRequests === undefined
? maxRequests
: Math.max(previous.maxRequests, maxRequests),
unknownScanLimit:
unknownScanLimit === undefined
? undefined
: previous?.unknownScanLimit === undefined
? unknownScanLimit
: Math.max(previous.unknownScanLimit, unknownScanLimit),
queueDeferredRefresh: Boolean(previous?.queueDeferredRefresh || queueDeferredRefresh),
satisfiedTaskIds:
previous?.satisfiedTaskIds && satisfiedTaskIds
? new Set(
[...previous.satisfiedTaskIds].filter((taskId) => satisfiedTaskIds.has(taskId))
)
: undefined,
stagedRefreshPlan:
stagedRefreshPlan !== undefined
? stagedRefreshPlan
: maxRequests === undefined && unknownScanLimit === undefined
? undefined
: previous?.stagedRefreshPlan,
}; };
if (showSpinner) { if (showSpinner) {
setLoading(true); setLoading(true);
@ -267,7 +358,11 @@ export function useTeamChangesSummaries({
return; return;
} }
const plan = buildTeamChangeRequestPlan(tasks, unknownScanCursorRef.current, forceFresh); const plan = buildTeamChangeRequestPlan(tasks, unknownScanCursorRef.current, forceFresh, {
maxRequests,
unknownScanLimit,
satisfiedTaskIds,
});
unknownScanCursorRef.current = plan.nextUnknownScanCursor; unknownScanCursorRef.current = plan.nextUnknownScanCursor;
const requestSeq = requestSeqRef.current + 1; const requestSeq = requestSeqRef.current + 1;
requestSeqRef.current = requestSeq; requestSeqRef.current = requestSeq;
@ -296,6 +391,19 @@ export function useTeamChangesSummaries({
setRefreshing(true); setRefreshing(true);
} }
activeRequestSeqRef.current = requestSeq; activeRequestSeqRef.current = requestSeq;
activeRequestOptionsRef.current = {
forceFresh,
showSpinner,
preserveOnError,
storeSummaries,
reportError,
blockAutoRetryOnError,
maxRequests,
unknownScanLimit,
queueDeferredRefresh,
satisfiedTaskIds,
stagedRefreshPlan,
};
try { try {
const response = await withTeamChangesLoadTimeout( const response = await withTeamChangesLoadTimeout(
@ -358,11 +466,32 @@ export function useTeamChangesSummaries({
return next; return next;
}); });
} }
if (storeSummaries && queueDeferredRefresh && plan.deferredCount > 0) {
const [nextStageMaxRequests, ...remainingStages] = stagedRefreshPlan ?? [];
const successfulTaskIds = mergeSuccessfulTaskIds(
satisfiedTaskIds,
responseItems,
plan.requestOptionsByTaskId
);
queuedRefreshOptionsRef.current = {
forceFresh,
showSpinner: false,
preserveOnError: true,
storeSummaries: true,
reportError: true,
blockAutoRetryOnError: true,
maxRequests: nextStageMaxRequests,
unknownScanLimit: getUnknownScanLimitForStage(nextStageMaxRequests),
queueDeferredRefresh: remainingStages.length > 0,
stagedRefreshPlan: remainingStages.length > 0 ? remainingStages : undefined,
satisfiedTaskIds: successfulTaskIds,
};
}
} catch (err) { } catch (err) {
if (!mountedRef.current || requestSeqRef.current !== requestSeq) { if (!mountedRef.current || requestSeqRef.current !== requestSeq) {
return; return;
} }
const queuedOptions = queuedRefreshOptionsRef.current as TeamChangesLoadOptions | null; const queuedOptions = queuedRefreshOptionsRef.current;
const shouldRunVisibleQueuedRefreshAfterSilentFailure = const shouldRunVisibleQueuedRefreshAfterSilentFailure =
!storeSummaries && !storeSummaries &&
!reportError && !reportError &&
@ -385,6 +514,7 @@ export function useTeamChangesSummaries({
const hasQueuedRefresh = queuedRefreshOptionsRef.current !== null; const hasQueuedRefresh = queuedRefreshOptionsRef.current !== null;
if (activeRequestSeqRef.current === requestSeq) { if (activeRequestSeqRef.current === requestSeq) {
activeRequestSeqRef.current = null; activeRequestSeqRef.current = null;
activeRequestOptionsRef.current = null;
} }
if (hasQueuedRefresh && activeRequestSeqRef.current === null) { if (hasQueuedRefresh && activeRequestSeqRef.current === null) {
setQueuedRefreshTick((value) => value + 1); setQueuedRefreshTick((value) => value + 1);
@ -406,6 +536,7 @@ export function useTeamChangesSummaries({
hasLoadedRef.current = false; hasLoadedRef.current = false;
requestSeqRef.current += 1; requestSeqRef.current += 1;
activeRequestSeqRef.current = null; activeRequestSeqRef.current = null;
activeRequestOptionsRef.current = null;
queuedRefreshOptionsRef.current = null; queuedRefreshOptionsRef.current = null;
autoRefreshBlockedUntilRef.current = 0; autoRefreshBlockedUntilRef.current = 0;
unknownScanCursorRef.current = 0; unknownScanCursorRef.current = 0;
@ -422,6 +553,7 @@ export function useTeamChangesSummaries({
if (!sectionOpen) { if (!sectionOpen) {
requestSeqRef.current += 1; requestSeqRef.current += 1;
activeRequestSeqRef.current = null; activeRequestSeqRef.current = null;
activeRequestOptionsRef.current = null;
queuedRefreshOptionsRef.current = null; queuedRefreshOptionsRef.current = null;
autoRefreshBlockedUntilRef.current = 0; autoRefreshBlockedUntilRef.current = 0;
hasLoadedRef.current = false; hasLoadedRef.current = false;
@ -457,7 +589,14 @@ export function useTeamChangesSummaries({
} }
hasLoadedRef.current = true; hasLoadedRef.current = true;
lastRequestedTasksFingerprintRef.current = tasksFingerprint; lastRequestedTasksFingerprintRef.current = tasksFingerprint;
void loadSummaries({ showSpinner: true, preserveOnError: false }); void loadSummaries({
showSpinner: true,
preserveOnError: false,
maxRequests: TEAM_CHANGES_FIRST_PAINT_REQUESTS,
unknownScanLimit: TEAM_CHANGES_FIRST_UNKNOWN_SCAN_LIMIT,
queueDeferredRefresh: true,
stagedRefreshPlan: TEAM_CHANGES_INITIAL_STAGED_REFRESH_PLAN,
});
}, [loadSummaries, sectionOpen, tasksFingerprint]); }, [loadSummaries, sectionOpen, tasksFingerprint]);
useEffect(() => { useEffect(() => {
@ -527,7 +666,15 @@ export function useTeamChangesSummaries({
}, [loadSummaries, sectionOpen]); }, [loadSummaries, sectionOpen]);
const refresh = useCallback(() => { const refresh = useCallback(() => {
void loadSummaries({ forceFresh: true, showSpinner: true, preserveOnError: false }); void loadSummaries({
forceFresh: true,
showSpinner: true,
preserveOnError: false,
maxRequests: TEAM_CHANGES_FIRST_PAINT_REQUESTS,
unknownScanLimit: TEAM_CHANGES_FIRST_UNKNOWN_SCAN_LIMIT,
queueDeferredRefresh: true,
stagedRefreshPlan: TEAM_CHANGES_INITIAL_STAGED_REFRESH_PLAN,
});
}, [loadSummaries]); }, [loadSummaries]);
return { return {

View file

@ -8,7 +8,7 @@
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { useShallow } from 'zustand/react/shallow'; import { useShallow } from 'zustand/react/shallow';
import type { CliInstallationStatus, CliProviderId } from '@shared/types'; import type { CliInstallationStatus, CliProviderId, OpenCodeRuntimeStatus } from '@shared/types';
export function useCliInstaller(): { export function useCliInstaller(): {
cliStatus: CliInstallationStatus | null; cliStatus: CliInstallationStatus | null;
@ -30,6 +30,9 @@ export function useCliInstaller(): {
installerDetail: string | null; installerDetail: string | null;
installerRawChunks: string[]; installerRawChunks: string[];
completedVersion: string | null; completedVersion: string | null;
openCodeRuntimeStatus: OpenCodeRuntimeStatus | null;
openCodeRuntimeStatusLoading: boolean;
openCodeRuntimeError: string | null;
bootstrapCliStatus: (options?: { multimodelEnabled?: boolean }) => Promise<void>; bootstrapCliStatus: (options?: { multimodelEnabled?: boolean }) => Promise<void>;
fetchCliStatus: () => Promise<void>; fetchCliStatus: () => Promise<void>;
fetchCliProviderStatus: ( fetchCliProviderStatus: (
@ -38,6 +41,9 @@ export function useCliInstaller(): {
) => Promise<void>; ) => Promise<void>;
invalidateCliStatus: () => Promise<void>; invalidateCliStatus: () => Promise<void>;
installCli: () => void; installCli: () => void;
fetchOpenCodeRuntimeStatus: () => Promise<void>;
installOpenCodeRuntime: () => Promise<void>;
invalidateOpenCodeRuntimeStatus: () => Promise<void>;
isBusy: boolean; isBusy: boolean;
} { } {
const { const {
@ -53,11 +59,17 @@ export function useCliInstaller(): {
installerDetail, installerDetail,
installerRawChunks, installerRawChunks,
completedVersion, completedVersion,
openCodeRuntimeStatus,
openCodeRuntimeStatusLoading,
openCodeRuntimeError,
bootstrapCliStatus, bootstrapCliStatus,
fetchCliStatus, fetchCliStatus,
fetchCliProviderStatus, fetchCliProviderStatus,
invalidateCliStatus, invalidateCliStatus,
installCli, installCli,
fetchOpenCodeRuntimeStatus,
installOpenCodeRuntime,
invalidateOpenCodeRuntimeStatus,
} = useStore( } = useStore(
useShallow((s) => ({ useShallow((s) => ({
cliStatus: s.cliStatus, cliStatus: s.cliStatus,
@ -72,11 +84,17 @@ export function useCliInstaller(): {
installerDetail: s.cliInstallerDetail, installerDetail: s.cliInstallerDetail,
installerRawChunks: s.cliInstallerRawChunks, installerRawChunks: s.cliInstallerRawChunks,
completedVersion: s.cliCompletedVersion, completedVersion: s.cliCompletedVersion,
openCodeRuntimeStatus: s.openCodeRuntimeStatus,
openCodeRuntimeStatusLoading: s.openCodeRuntimeStatusLoading,
openCodeRuntimeError: s.openCodeRuntimeError,
bootstrapCliStatus: s.bootstrapCliStatus, bootstrapCliStatus: s.bootstrapCliStatus,
fetchCliStatus: s.fetchCliStatus, fetchCliStatus: s.fetchCliStatus,
fetchCliProviderStatus: s.fetchCliProviderStatus, fetchCliProviderStatus: s.fetchCliProviderStatus,
invalidateCliStatus: s.invalidateCliStatus, invalidateCliStatus: s.invalidateCliStatus,
installCli: s.installCli, installCli: s.installCli,
fetchOpenCodeRuntimeStatus: s.fetchOpenCodeRuntimeStatus,
installOpenCodeRuntime: s.installOpenCodeRuntime,
invalidateOpenCodeRuntimeStatus: s.invalidateOpenCodeRuntimeStatus,
})) }))
); );
@ -96,11 +114,17 @@ export function useCliInstaller(): {
installerDetail, installerDetail,
installerRawChunks, installerRawChunks,
completedVersion, completedVersion,
openCodeRuntimeStatus,
openCodeRuntimeStatusLoading,
openCodeRuntimeError,
bootstrapCliStatus, bootstrapCliStatus,
fetchCliStatus, fetchCliStatus,
fetchCliProviderStatus, fetchCliProviderStatus,
invalidateCliStatus, invalidateCliStatus,
installCli, installCli,
fetchOpenCodeRuntimeStatus,
installOpenCodeRuntime,
invalidateOpenCodeRuntimeStatus,
isBusy, isBusy,
}; };
} }

View file

@ -70,6 +70,7 @@ import type {
CliInstallerProgress, CliInstallerProgress,
CliProviderId, CliProviderId,
LeadContextUsage, LeadContextUsage,
OpenCodeRuntimeStatus,
ScheduleChangeEvent, ScheduleChangeEvent,
TeamChangeEvent, TeamChangeEvent,
TeamProvisioningProgress, TeamProvisioningProgress,
@ -244,6 +245,9 @@ export function initializeNotificationListeners(): () => void {
cliStatusTimer = null; cliStatusTimer = null;
}, delayMs); }, delayMs);
} }
if (api.openCodeRuntime) {
void useStore.getState().fetchOpenCodeRuntimeStatus();
}
// Remaining fetches have no data dependency on each other — run in parallel // Remaining fetches have no data dependency on each other — run in parallel
// to avoid blocking teams/notifications behind a slow repository scan. // to avoid blocking teams/notifications behind a slow repository scan.
@ -2300,6 +2304,31 @@ export function initializeNotificationListeners(): () => void {
} }
} }
if (api.openCodeRuntime?.onProgress) {
const cleanup = api.openCodeRuntime.onProgress((_event: unknown, data: unknown) => {
const status = data as OpenCodeRuntimeStatus;
useStore.setState({
openCodeRuntimeStatus: status,
openCodeRuntimeError: status.error ?? null,
openCodeRuntimeStatusLoading:
status.state === 'checking' ||
status.state === 'downloading' ||
status.state === 'installing',
});
if (status.installed && status.state === 'ready') {
void (async () => {
await api.cliInstaller?.invalidateStatus();
await useStore.getState().fetchCliProviderStatus('opencode', {
silent: false,
});
})();
}
});
if (typeof cleanup === 'function') {
cleanupFns.push(cleanup);
}
}
// Listen for updater status events from main process // Listen for updater status events from main process
if (api.updater?.onStatus) { if (api.updater?.onStatus) {
const cleanup = api.updater.onStatus((_event: unknown, status: unknown) => { const cleanup = api.updater.onStatus((_event: unknown, status: unknown) => {

View file

@ -7,7 +7,12 @@ import { createLogger } from '@shared/utils/logger';
import { createDefaultCliExtensionCapabilities } from '@shared/utils/providerExtensionCapabilities'; import { createDefaultCliExtensionCapabilities } from '@shared/utils/providerExtensionCapabilities';
import type { AppState } from '../types'; import type { AppState } from '../types';
import type { CliInstallationStatus, CliProviderId, CliProviderStatus } from '@shared/types'; import type {
CliInstallationStatus,
CliProviderId,
CliProviderStatus,
OpenCodeRuntimeStatus,
} from '@shared/types';
import type { StateCreator } from 'zustand'; import type { StateCreator } from 'zustand';
const logger = createLogger('Store:cliInstaller'); const logger = createLogger('Store:cliInstaller');
@ -283,6 +288,9 @@ export interface CliInstallerSlice {
cliInstallerLogs: string[]; cliInstallerLogs: string[];
cliInstallerRawChunks: string[]; cliInstallerRawChunks: string[];
cliCompletedVersion: string | null; cliCompletedVersion: string | null;
openCodeRuntimeStatus: OpenCodeRuntimeStatus | null;
openCodeRuntimeStatusLoading: boolean;
openCodeRuntimeError: string | null;
// Actions // Actions
bootstrapCliStatus: (options?: { multimodelEnabled?: boolean }) => Promise<void>; bootstrapCliStatus: (options?: { multimodelEnabled?: boolean }) => Promise<void>;
@ -293,12 +301,16 @@ export interface CliInstallerSlice {
) => Promise<void>; ) => Promise<void>;
invalidateCliStatus: () => Promise<void>; invalidateCliStatus: () => Promise<void>;
installCli: () => void; installCli: () => void;
fetchOpenCodeRuntimeStatus: () => Promise<void>;
installOpenCodeRuntime: () => Promise<void>;
invalidateOpenCodeRuntimeStatus: () => Promise<void>;
} }
let cliStatusInFlight: Promise<void> | null = null; let cliStatusInFlight: Promise<void> | null = null;
const cliProviderStatusInFlight = new Map<string, Promise<void>>(); const cliProviderStatusInFlight = new Map<string, Promise<void>>();
let cliStatusEpoch = 0; let cliStatusEpoch = 0;
const cliProviderStatusSeq = new Map<CliProviderId, number>(); const cliProviderStatusSeq = new Map<CliProviderId, number>();
let openCodeRuntimeStatusInFlight: Promise<void> | null = null;
// ============================================================================= // =============================================================================
// Slice Creator // Slice Creator
@ -322,6 +334,9 @@ export const createCliInstallerSlice: StateCreator<AppState, [], [], CliInstalle
cliInstallerLogs: [], cliInstallerLogs: [],
cliInstallerRawChunks: [], cliInstallerRawChunks: [],
cliCompletedVersion: null, cliCompletedVersion: null,
openCodeRuntimeStatus: null,
openCodeRuntimeStatusLoading: false,
openCodeRuntimeError: null,
bootstrapCliStatus: async (options) => { bootstrapCliStatus: async (options) => {
if (!api.cliInstaller) return; if (!api.cliInstaller) return;
@ -690,4 +705,65 @@ export const createCliInstallerSlice: StateCreator<AppState, [], [], CliInstalle
logger.error('Failed to install CLI:', error); logger.error('Failed to install CLI:', error);
}); });
}, },
fetchOpenCodeRuntimeStatus: async () => {
if (!api.openCodeRuntime) return;
if (openCodeRuntimeStatusInFlight) return openCodeRuntimeStatusInFlight;
openCodeRuntimeStatusInFlight = (async () => {
set({ openCodeRuntimeStatusLoading: true, openCodeRuntimeError: null });
try {
const status = await api.openCodeRuntime.getStatus();
set({ openCodeRuntimeStatus: status, openCodeRuntimeError: status.error ?? null });
} catch (error) {
const message =
error instanceof Error ? error.message : 'Failed to check OpenCode runtime status';
logger.error('Failed to fetch OpenCode runtime status:', error);
set({ openCodeRuntimeError: message });
} finally {
set({ openCodeRuntimeStatusLoading: false });
openCodeRuntimeStatusInFlight = null;
}
})();
return openCodeRuntimeStatusInFlight;
},
installOpenCodeRuntime: async () => {
if (!api.openCodeRuntime) return;
set({
openCodeRuntimeStatusLoading: true,
openCodeRuntimeError: null,
openCodeRuntimeStatus: {
installed: false,
source: 'missing',
state: 'checking',
progress: {
phase: 'checking',
detail: 'Resolving latest OpenCode package...',
},
},
});
try {
const status = await api.openCodeRuntime.install();
set({ openCodeRuntimeStatus: status, openCodeRuntimeError: status.error ?? null });
if (status.installed) {
await api.openCodeRuntime.invalidateStatus();
await api.cliInstaller?.invalidateStatus();
const epoch = ++cliStatusEpoch;
await get().fetchCliProviderStatus('opencode', { silent: false, epoch });
}
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to install OpenCode runtime';
logger.error('Failed to install OpenCode runtime:', error);
set({ openCodeRuntimeError: message });
} finally {
set({ openCodeRuntimeStatusLoading: false });
}
},
invalidateOpenCodeRuntimeStatus: async () => {
await api.openCodeRuntime?.invalidateStatus();
set({ openCodeRuntimeStatus: null });
},
}); });

View file

@ -1870,6 +1870,7 @@ const resolvedMembersSelectorCache = new Map<
{ {
snapshotRef: TeamViewSnapshot['members']; snapshotRef: TeamViewSnapshot['members'];
configMembersRef: TeamViewSnapshot['config']['members'] | undefined; configMembersRef: TeamViewSnapshot['config']['members'] | undefined;
summaryRef: TeamSummary | undefined;
tasksRef: TeamViewSnapshot['tasks'] | undefined; tasksRef: TeamViewSnapshot['tasks'] | undefined;
metaMembersRef: TeamMemberActivityMeta['members'] | undefined; metaMembersRef: TeamMemberActivityMeta['members'] | undefined;
result: ResolvedTeamMember[]; result: ResolvedTeamMember[];
@ -1982,10 +1983,196 @@ function buildConfigFallbackMemberSnapshots(snapshot: TeamViewSnapshot): TeamMem
return fallbackMembers; return fallbackMembers;
} }
function getResolvableMemberSnapshots(snapshot: TeamViewSnapshot): readonly TeamMemberSnapshot[] { function buildSummaryFallbackMemberSnapshots(
return snapshot.members.length > 0 snapshot: TeamViewSnapshot,
? snapshot.members summary: TeamSummary | undefined
: buildConfigFallbackMemberSnapshots(snapshot); ): TeamMemberSnapshot[] {
if (!summary) {
return [];
}
const summaryMembers = summary.members ?? [];
if (summaryMembers.length === 0 || summary.memberCount <= 0) {
return [];
}
const seenNames = new Set<string>();
const buildSnapshot = (
name: string,
source?: { agentId?: string; role?: string; color?: string },
lead = false
): TeamMemberSnapshot | null => {
const trimmed = name.trim();
if (!trimmed) return null;
const key = trimmed.toLowerCase();
if (seenNames.has(key)) return null;
seenNames.add(key);
const ownedTasks = snapshot.tasks.filter((task) => task.owner === trimmed);
const currentTask = ownedTasks.find(isDisplayableFallbackCurrentTask);
return {
name: trimmed,
agentId: source?.agentId,
currentTaskId: currentTask?.id ?? null,
taskCount: ownedTasks.length,
color: source?.color ?? getMemberColorByName(trimmed),
agentType: lead ? 'team-lead' : undefined,
role: source?.role ?? (lead ? 'Team Lead' : undefined),
};
};
const teammates = summaryMembers.flatMap((member) => {
const name = member.name?.trim();
if (!name || name === 'user' || isLeadMember(member)) {
return [];
}
const item = buildSnapshot(name, member);
return item ? [item] : [];
});
if (teammates.length === 0) {
return [];
}
const existingLead = snapshot.members.find((member) => !member.removedAt && isLeadMember(member));
if (existingLead) {
return [existingLead, ...teammates];
}
const configuredLead = snapshot.config.members?.find(
(member) => !member.removedAt && isLeadMember(member)
);
const leadName = configuredLead?.name?.trim() || summary.leadName?.trim();
const lead = leadName
? buildSnapshot(
leadName,
{
agentId: configuredLead?.agentId,
role: configuredLead?.role,
color: configuredLead?.color ?? summary.leadColor,
},
true
)
: null;
return lead ? [lead, ...teammates] : teammates;
}
function getResolvableMemberSnapshots(
snapshot: TeamViewSnapshot,
summary?: TeamSummary
): readonly TeamMemberSnapshot[] {
if (
snapshot.members.length > 0 &&
(hasActiveRawTeammateRoster(snapshot) || hasRemovedRawMemberRoster(snapshot))
) {
return snapshot.members;
}
const configFallbackMembers = buildConfigFallbackMemberSnapshots(snapshot);
if (configFallbackMembers.length > 0) {
return configFallbackMembers;
}
const summaryFallbackMembers = buildSummaryFallbackMemberSnapshots(snapshot, summary);
if (summaryFallbackMembers.length > 0) {
return summaryFallbackMembers;
}
return snapshot.members;
}
function getActiveRawTeammateNameKeys(snapshot: TeamViewSnapshot | null | undefined): string[] {
if (!snapshot) {
return [];
}
const names = new Set<string>();
for (const member of snapshot.members) {
const name = member.name.trim();
if (!name || name === 'user' || member.removedAt || isLeadMember(member)) {
continue;
}
names.add(name.toLowerCase());
}
return Array.from(names).sort((left, right) => left.localeCompare(right));
}
function hasActiveRawTeammateRoster(snapshot: TeamViewSnapshot | null | undefined): boolean {
return getActiveRawTeammateNameKeys(snapshot).length > 0;
}
function hasRemovedRawMemberRoster(snapshot: TeamViewSnapshot | null | undefined): boolean {
return Boolean(snapshot?.members.some((member) => member.removedAt));
}
function hasConfigTeammateRoster(snapshot: TeamViewSnapshot | null | undefined): boolean {
return Boolean(
snapshot?.config.members?.some((member) => {
const name = member.name?.trim();
return Boolean(name) && !member.removedAt && !isLeadMember(member);
})
);
}
function getSummaryTeammateNameKeys(summary: TeamSummary): string[] {
const names = new Set<string>();
for (const member of summary.members ?? []) {
const name = member.name?.trim();
if (!name || name === 'user' || isLeadMember(member)) {
continue;
}
names.add(name.toLowerCase());
}
return Array.from(names).sort((left, right) => left.localeCompare(right));
}
function areNameKeyListsEqual(left: readonly string[], right: readonly string[]): boolean {
return left.length === right.length && left.every((name, index) => name === right[index]);
}
function summaryConfirmsActiveTeammateRoster(
current: TeamViewSnapshot,
summary: TeamSummary
): boolean {
if (summary.memberCount <= 0) {
return false;
}
const currentNames = getActiveRawTeammateNameKeys(current);
const summaryNames = getSummaryTeammateNameKeys(summary);
if (summaryNames.length === 0 || summaryNames.length !== currentNames.length) {
return false;
}
return areNameKeyListsEqual(summaryNames, currentNames);
}
function shouldPreserveSelectedTeamSnapshot(
current: TeamViewSnapshot | null,
baseline: TeamViewSnapshot | null | undefined,
incoming: TeamViewSnapshot,
summary: TeamSummary | undefined
): boolean {
if (!current || !hasActiveRawTeammateRoster(current)) {
return false;
}
if (
hasActiveRawTeammateRoster(incoming) ||
hasRemovedRawMemberRoster(incoming) ||
hasConfigTeammateRoster(incoming)
) {
return false;
}
const currentNames = getActiveRawTeammateNameKeys(current);
if (
current !== baseline &&
!areNameKeyListsEqual(currentNames, getActiveRawTeammateNameKeys(baseline))
) {
return true;
}
if (summary) {
return summaryConfirmsActiveTeammateRoster(current, summary);
}
return false;
} }
function buildResolvedMember( function buildResolvedMember(
@ -2042,8 +2229,13 @@ function structurallyShareMemberActivityFacts(
return changed ? shared : previous; return changed ? shared : previous;
} }
type TeamDataSelectorState = Pick<
TeamSlice,
'teamDataCacheByName' | 'selectedTeamName' | 'selectedTeamData'
>;
export function selectTeamDataForName( export function selectTeamDataForName(
state: Pick<TeamSlice, 'teamDataCacheByName' | 'selectedTeamName' | 'selectedTeamData'>, state: TeamDataSelectorState,
teamName: string | null | undefined teamName: string | null | undefined
): TeamViewSnapshot | null { ): TeamViewSnapshot | null {
if (!teamName) { if (!teamName) {
@ -2058,6 +2250,12 @@ export function selectTeamDataForName(
); );
} }
type ResolvedMemberSelectorState = Pick<
TeamSlice,
'teamDataCacheByName' | 'selectedTeamName' | 'selectedTeamData' | 'memberActivityMetaByTeam'
> &
Partial<Pick<TeamSlice, 'teamByName'>>;
function migrateStableSlotAssignmentsForMembers( function migrateStableSlotAssignmentsForMembers(
assignments: TeamGraphSlotAssignments | undefined, assignments: TeamGraphSlotAssignments | undefined,
members: readonly TeamGraphMemberSeedInput[] members: readonly TeamGraphMemberSeedInput[]
@ -2088,10 +2286,7 @@ function migrateStableSlotAssignmentsForMembers(
} }
export function selectResolvedMembersForTeamName( export function selectResolvedMembersForTeamName(
state: Pick< state: ResolvedMemberSelectorState,
TeamSlice,
'teamDataCacheByName' | 'selectedTeamName' | 'selectedTeamData' | 'memberActivityMetaByTeam'
>,
teamName: string | null | undefined teamName: string | null | undefined
): ResolvedTeamMember[] { ): ResolvedTeamMember[] {
const snapshot = selectTeamDataForName(state, teamName); const snapshot = selectTeamDataForName(state, teamName);
@ -2101,23 +2296,28 @@ export function selectResolvedMembersForTeamName(
const meta = state.memberActivityMetaByTeam[teamName]; const meta = state.memberActivityMetaByTeam[teamName];
const metaMembers = meta?.members; const metaMembers = meta?.members;
const shouldUseConfigFallback = snapshot.members.length === 0; const shouldUseMemberFallback =
const configMembersRef = shouldUseConfigFallback ? snapshot.config.members : undefined; snapshot.members.length === 0 ||
const tasksRef = shouldUseConfigFallback ? snapshot.tasks : undefined; (!hasActiveRawTeammateRoster(snapshot) && !hasRemovedRawMemberRoster(snapshot));
const configMembersRef = shouldUseMemberFallback ? snapshot.config.members : undefined;
const summaryRef = shouldUseMemberFallback ? state.teamByName?.[teamName] : undefined;
const tasksRef = shouldUseMemberFallback ? snapshot.tasks : undefined;
const cached = resolvedMembersSelectorCache.get(teamName); const cached = resolvedMembersSelectorCache.get(teamName);
if ( if (
cached?.snapshotRef === snapshot.members && cached?.snapshotRef === snapshot.members &&
cached.configMembersRef === configMembersRef && cached.configMembersRef === configMembersRef &&
cached.summaryRef === summaryRef &&
cached.tasksRef === tasksRef && cached.tasksRef === tasksRef &&
cached.metaMembersRef === metaMembers cached.metaMembersRef === metaMembers
) { ) {
return cached.result; return cached.result;
} }
const result = buildResolvedMembers(getResolvableMemberSnapshots(snapshot), meta); const result = buildResolvedMembers(getResolvableMemberSnapshots(snapshot, summaryRef), meta);
resolvedMembersSelectorCache.set(teamName, { resolvedMembersSelectorCache.set(teamName, {
snapshotRef: snapshot.members, snapshotRef: snapshot.members,
configMembersRef, configMembersRef,
summaryRef,
tasksRef, tasksRef,
metaMembersRef: metaMembers, metaMembersRef: metaMembers,
result, result,
@ -2126,10 +2326,7 @@ export function selectResolvedMembersForTeamName(
} }
export function selectResolvedMemberForTeamName( export function selectResolvedMemberForTeamName(
state: Pick< state: ResolvedMemberSelectorState,
TeamSlice,
'teamDataCacheByName' | 'selectedTeamName' | 'selectedTeamData' | 'memberActivityMetaByTeam'
>,
teamName: string | null | undefined, teamName: string | null | undefined,
memberName: string | null | undefined memberName: string | null | undefined
): ResolvedTeamMember | null { ): ResolvedTeamMember | null {
@ -2138,7 +2335,7 @@ export function selectResolvedMemberForTeamName(
return null; return null;
} }
const snapshotMember = getResolvableMemberSnapshots(snapshot).find( const snapshotMember = getResolvableMemberSnapshots(snapshot, state.teamByName?.[teamName]).find(
(member) => member.name === memberName (member) => member.name === memberName
); );
if (!snapshotMember) { if (!snapshotMember) {
@ -2162,21 +2359,21 @@ export function selectResolvedMemberForTeamName(
} }
export function selectTeamMemberSnapshotsForName( export function selectTeamMemberSnapshotsForName(
state: Pick<TeamSlice, 'teamDataCacheByName' | 'selectedTeamName' | 'selectedTeamData'>, state: TeamDataSelectorState,
teamName: string | null | undefined teamName: string | null | undefined
): TeamViewSnapshot['members'] { ): TeamViewSnapshot['members'] {
return selectTeamDataForName(state, teamName)?.members ?? EMPTY_TEAM_MEMBER_SNAPSHOTS; return selectTeamDataForName(state, teamName)?.members ?? EMPTY_TEAM_MEMBER_SNAPSHOTS;
} }
export function selectTeamTasksForName( export function selectTeamTasksForName(
state: Pick<TeamSlice, 'teamDataCacheByName' | 'selectedTeamName' | 'selectedTeamData'>, state: TeamDataSelectorState,
teamName: string | null | undefined teamName: string | null | undefined
): TeamViewSnapshot['tasks'] { ): TeamViewSnapshot['tasks'] {
return selectTeamDataForName(state, teamName)?.tasks ?? EMPTY_TEAM_TASKS; return selectTeamDataForName(state, teamName)?.tasks ?? EMPTY_TEAM_TASKS;
} }
export function selectTeamIsAliveForName( export function selectTeamIsAliveForName(
state: Pick<TeamSlice, 'teamDataCacheByName' | 'selectedTeamName' | 'selectedTeamData'>, state: TeamDataSelectorState,
teamName: string | null | undefined teamName: string | null | undefined
): boolean | undefined { ): boolean | undefined {
return selectTeamDataForName(state, teamName)?.isAlive; return selectTeamDataForName(state, teamName)?.isAlive;
@ -3856,14 +4053,52 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
set({ teamByName: { ...prevByName, [teamName]: patched } }); set({ teamByName: { ...prevByName, [teamName]: patched } });
} }
const projectedTeamData = previousData let committedTeamData: TeamViewSnapshot = data;
set((state) => {
if (
state.selectedTeamName === teamName &&
shouldPreserveSelectedTeamSnapshot(
state.selectedTeamData,
previousData,
data,
state.teamByName[teamName]
)
) {
const preservedTeamData = state.selectedTeamData;
committedTeamData = preservedTeamData ?? data;
const nextCache =
preservedTeamData && state.teamDataCacheByName[teamName] !== preservedTeamData
? {
...state.teamDataCacheByName,
[teamName]: preservedTeamData,
}
: state.teamDataCacheByName;
return {
selectedTeamName: teamName,
selectedTeamData: preservedTeamData,
teamDataCacheByName: nextCache,
selectedTeamLoading: false,
selectedTeamError: null,
};
}
const previousForProjection = selectTeamDataForName(state, teamName) ?? previousData;
const projectedTeamData = previousForProjection
? { ? {
...data, ...data,
tasks: preserveKnownTaskChangePresence(teamName, previousData.tasks, data.tasks), tasks: preserveKnownTaskChangePresence(
teamName,
previousForProjection.tasks,
data.tasks
),
} }
: data; : data;
const nextTeamData = structurallyShareTeamSnapshot(previousData, projectedTeamData); const nextTeamData = structurallyShareTeamSnapshot(
set((state) => { previousForProjection,
projectedTeamData
);
committedTeamData = nextTeamData;
const nextCache = const nextCache =
state.teamDataCacheByName[teamName] === nextTeamData state.teamDataCacheByName[teamName] === nextTeamData
? state.teamDataCacheByName ? state.teamDataCacheByName
@ -3884,7 +4119,11 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
try { try {
const invalidationState = previousData const invalidationState = previousData
? collectTaskChangeInvalidationState(teamName, previousData.tasks, data.tasks) ? collectTaskChangeInvalidationState(
teamName,
previousData.tasks,
committedTeamData.tasks
)
: { cacheKeys: [], taskIds: [] }; : { cacheKeys: [], taskIds: [] };
if (invalidationState.cacheKeys.length > 0) { if (invalidationState.cacheKeys.length > 0) {
get().invalidateTaskChangePresence(invalidationState.cacheKeys); get().invalidateTaskChangePresence(invalidationState.cacheKeys);
@ -3896,7 +4135,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
} }
// Sync tab label with the team's display name from config. // Sync tab label with the team's display name from config.
const displayName = data.config.name || teamName; const displayName = committedTeamData.config.name || teamName;
const allTabs = get().getAllPaneTabs(); const allTabs = get().getAllPaneTabs();
const relatedTabs = allTabs.filter( const relatedTabs = allTabs.filter(
(tab) => (tab.type === 'team' || tab.type === 'graph') && tab.teamName === teamName (tab) => (tab.type === 'team' || tab.type === 'graph') && tab.teamName === teamName
@ -3911,7 +4150,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
// Auto-select the project associated with this team's cwd/projectPath. // Auto-select the project associated with this team's cwd/projectPath.
// Must search both flat projects and grouped repositoryGroups/worktrees // Must search both flat projects and grouped repositoryGroups/worktrees
// because the default viewMode is 'grouped' and flat projects may be empty. // because the default viewMode is 'grouped' and flat projects may be empty.
const projectPath = data.config.projectPath; const projectPath = committedTeamData.config.projectPath;
if ( if (
!opts?.skipProjectAutoSelect && !opts?.skipProjectAutoSelect &&
projectPath && projectPath &&

View file

@ -134,6 +134,8 @@ export const SPAWN_PRESENCE_LABELS: Record<MemberSpawnStatus, string> = {
const OPENCODE_RUNTIME_CANDIDATE_RELAUNCH_GRACE_MS = 5 * 60 * 1000; const OPENCODE_RUNTIME_CANDIDATE_RELAUNCH_GRACE_MS = 5 * 60 * 1000;
export const MEMBER_STARTING_STALE_AFTER_MS = 2 * 60 * 1000; export const MEMBER_STARTING_STALE_AFTER_MS = 2 * 60 * 1000;
const OPENCODE_BRIDGE_OUTCOME_UNKNOWN_AFTER_TIMEOUT_MESSAGE =
'OpenCode bridge outcome unknown after timeout, retrying/observing.';
function isLaunchStillStarting( function isLaunchStillStarting(
spawnStatus: MemberSpawnStatus | undefined, spawnStatus: MemberSpawnStatus | undefined,
@ -332,6 +334,7 @@ function isOpenCodeRuntimeDeliveryAdvisoryMessage(message: string | undefined):
displayMessage.startsWith('OpenCode runtime delivery') || displayMessage.startsWith('OpenCode runtime delivery') ||
displayMessage.startsWith('OpenCode returned an empty assistant turn') || displayMessage.startsWith('OpenCode returned an empty assistant turn') ||
displayMessage.startsWith('OpenCode accepted the prompt') || displayMessage.startsWith('OpenCode accepted the prompt') ||
displayMessage.startsWith('OpenCode bridge outcome unknown after timeout') ||
displayMessage.startsWith('OpenCode responded, but did not create') || displayMessage.startsWith('OpenCode responded, but did not create') ||
displayMessage.startsWith('OpenCode created a reply without') || displayMessage.startsWith('OpenCode created a reply without') ||
displayMessage.startsWith('OpenCode used tools, but did not create') displayMessage.startsWith('OpenCode used tools, but did not create')
@ -349,6 +352,9 @@ function formatRuntimeAdvisoryDisplayMessage(message: string | undefined): strin
if (trimmed === 'prompt_delivered_no_assistant_message') { if (trimmed === 'prompt_delivered_no_assistant_message') {
return 'OpenCode accepted the prompt, but no assistant turn was recorded.'; return 'OpenCode accepted the prompt, but no assistant turn was recorded.';
} }
if (trimmed === 'opencode_prompt_acceptance_unknown_after_bridge_timeout') {
return OPENCODE_BRIDGE_OUTCOME_UNKNOWN_AFTER_TIMEOUT_MESSAGE;
}
if ( if (
trimmed === 'visible_reply_still_required' || trimmed === 'visible_reply_still_required' ||
trimmed === 'visible_reply_ack_only_still_requires_answer' || trimmed === 'visible_reply_ack_only_still_requires_answer' ||

View file

@ -33,6 +33,8 @@ const FAILED_WARNING =
'OpenCode runtime delivery failed. Message was saved to inbox, but live delivery did not complete.'; 'OpenCode runtime delivery failed. Message was saved to inbox, but live delivery did not complete.';
const ATTACHMENT_FAILED_WARNING = const ATTACHMENT_FAILED_WARNING =
'OpenCode attachment was not sent. Message was saved to inbox, but live delivery cannot include this attachment.'; 'OpenCode attachment was not sent. Message was saved to inbox, but live delivery cannot include this attachment.';
const OPENCODE_BRIDGE_OUTCOME_UNKNOWN_AFTER_TIMEOUT_MESSAGE =
'OpenCode bridge outcome unknown after timeout, retrying/observing.';
function isOpenCodeAttachmentDeliveryFailureReason(reason: string | null | undefined): boolean { function isOpenCodeAttachmentDeliveryFailureReason(reason: string | null | undefined): boolean {
const normalized = reason?.trim().toLowerCase(); const normalized = reason?.trim().toLowerCase();
@ -55,6 +57,9 @@ function formatOpenCodeRuntimeDeliveryFailureReason(reason: string | null | unde
if (normalizedLower === 'prompt_delivered_no_assistant_message') { if (normalizedLower === 'prompt_delivered_no_assistant_message') {
return 'OpenCode accepted the prompt, but no assistant turn was recorded.'; return 'OpenCode accepted the prompt, but no assistant turn was recorded.';
} }
if (normalizedLower === 'opencode_prompt_acceptance_unknown_after_bridge_timeout') {
return OPENCODE_BRIDGE_OUTCOME_UNKNOWN_AFTER_TIMEOUT_MESSAGE;
}
if ( if (
normalizedLower === 'visible_reply_still_required' || normalizedLower === 'visible_reply_still_required' ||
normalizedLower === 'visible_reply_ack_only_still_requires_answer' || normalizedLower === 'visible_reply_ack_only_still_requires_answer' ||

View file

@ -8,7 +8,7 @@
*/ */
import type { CliArgsValidationResult } from '../utils/cliArgsParser'; import type { CliArgsValidationResult } from '../utils/cliArgsParser';
import type { CliInstallerAPI } from './cliInstaller'; import type { CliInstallerAPI, OpenCodeRuntimeAPI } from './cliInstaller';
import type { EditorAPI, EditorFileChangeEvent, ProjectAPI } from './editor'; import type { EditorAPI, EditorFileChangeEvent, ProjectAPI } from './editor';
import type { ApiKeysAPI, McpCatalogAPI, PluginCatalogAPI, SkillsCatalogAPI } from './extensions'; import type { ApiKeysAPI, McpCatalogAPI, PluginCatalogAPI, SkillsCatalogAPI } from './extensions';
import type { import type {
@ -79,9 +79,9 @@ import type {
TeamCreateRequest, TeamCreateRequest,
TeamCreateResponse, TeamCreateResponse,
TeamGetDataOptions, TeamGetDataOptions,
TeamLaunchFailureDiagnosticsBundle,
TeamLaunchRequest, TeamLaunchRequest,
TeamLaunchResponse, TeamLaunchResponse,
TeamLaunchFailureDiagnosticsBundle,
TeamMemberActivityMeta, TeamMemberActivityMeta,
TeamMessageNotificationData, TeamMessageNotificationData,
TeamProvisioningModelVerificationMode, TeamProvisioningModelVerificationMode,
@ -927,6 +927,9 @@ export interface ElectronAPI extends RecentProjectsElectronApi, CodexAccountElec
// CLI Installer API // CLI Installer API
cliInstaller: CliInstallerAPI; cliInstaller: CliInstallerAPI;
// OpenCode app-managed runtime installer API
openCodeRuntime: OpenCodeRuntimeAPI;
// Runtime nested provider management API // Runtime nested provider management API
runtimeProviderManagement: RuntimeProviderManagementApi; runtimeProviderManagement: RuntimeProviderManagementApi;

View file

@ -327,3 +327,42 @@ export interface CliInstallerAPI {
/** Subscribe to progress events. Returns cleanup function. */ /** Subscribe to progress events. Returns cleanup function. */
onProgress: (cb: (event: unknown, data: CliInstallerProgress) => void) => () => void; onProgress: (cb: (event: unknown, data: CliInstallerProgress) => void) => () => void;
} }
// =============================================================================
// OpenCode Runtime Installer
// =============================================================================
export type OpenCodeRuntimeSource = 'app-managed' | 'path' | 'missing';
export type OpenCodeRuntimeInstallerState =
| 'idle'
| 'checking'
| 'downloading'
| 'installing'
| 'ready'
| 'failed';
export interface OpenCodeRuntimeInstallProgress {
phase: OpenCodeRuntimeInstallerState;
downloadedBytes?: number;
totalBytes?: number;
percent?: number;
detail?: string | null;
}
export interface OpenCodeRuntimeStatus {
installed: boolean;
binaryPath?: string;
version?: string;
source: OpenCodeRuntimeSource;
state: OpenCodeRuntimeInstallerState;
progress?: OpenCodeRuntimeInstallProgress;
error?: string;
}
export interface OpenCodeRuntimeAPI {
getStatus: () => Promise<OpenCodeRuntimeStatus>;
install: () => Promise<OpenCodeRuntimeStatus>;
invalidateStatus: () => Promise<void>;
onProgress: (cb: (event: unknown, data: OpenCodeRuntimeStatus) => void) => () => void;
}

View file

@ -0,0 +1,74 @@
import { mkdir, mkdtemp, rm, writeFile } from 'fs/promises';
import os from 'os';
import path from 'path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { resolveAppManagedOpenCodeRuntimeBinaryPath } from '@main/services/infrastructure/OpenCodeRuntimeInstallerService';
import { setAppDataBasePath } from '@main/utils/pathDecoder';
let tempRoot: string | null = null;
describe('OpenCodeRuntimeInstallerService resolver', () => {
beforeEach(async () => {
tempRoot = await mkdtemp(path.join(os.tmpdir(), 'opencode-runtime-resolver-'));
setAppDataBasePath(tempRoot);
});
afterEach(async () => {
setAppDataBasePath(null);
if (tempRoot) {
await rm(tempRoot, { recursive: true, force: true });
tempRoot = null;
}
});
it('returns the current app-managed OpenCode binary path only when manifest and binary exist', async () => {
const binaryPath = path.join(
tempRoot!,
'data',
'runtimes',
'opencode',
'versions',
'1.0.0',
'opencode-test',
'opencode'
);
const manifestPath = path.join(tempRoot!, 'data', 'runtimes', 'opencode', 'current.json');
await mkdir(path.dirname(binaryPath), { recursive: true });
await mkdir(path.dirname(manifestPath), { recursive: true });
await writeFile(binaryPath, 'binary', { mode: 0o755 });
await writeFile(
manifestPath,
`${JSON.stringify({
schemaVersion: 1,
version: '1.0.0',
platformPackage: 'opencode-test',
binaryPath,
integrity: 'sha512-test',
installedAt: '2026-05-12T00:00:00.000Z',
})}\n`,
'utf8'
);
expect(resolveAppManagedOpenCodeRuntimeBinaryPath()).toBe(binaryPath);
});
it('ignores a manifest whose binary path is missing', async () => {
const manifestPath = path.join(tempRoot!, 'data', 'runtimes', 'opencode', 'current.json');
await mkdir(path.dirname(manifestPath), { recursive: true });
await writeFile(
manifestPath,
`${JSON.stringify({
schemaVersion: 1,
version: '1.0.0',
platformPackage: 'opencode-test',
binaryPath: path.join(tempRoot!, 'missing-opencode'),
integrity: 'sha512-test',
installedAt: '2026-05-12T00:00:00.000Z',
})}\n`,
'utf8'
);
expect(resolveAppManagedOpenCodeRuntimeBinaryPath()).toBeNull();
});
});

View file

@ -262,6 +262,267 @@ describe('OpenCodeReadinessBridge', () => {
); );
}); });
it('does not query commandStatus on successful OpenCode sendMessage', async () => {
const executor = fakeExecutor(
bridgeCommandSuccess({
command: 'opencode.sendMessage',
requestId: 'send-req-1',
data: {
accepted: true,
memberName: 'bob',
sessionId: 'session-bob',
diagnostics: [],
},
})
);
const bridge = new OpenCodeReadinessBridge(executor);
await expect(
bridge.sendOpenCodeTeamMessage({
teamId: 'team-a',
teamName: 'team-a',
laneId: 'secondary:opencode:bob',
projectPath: '/repo',
memberName: 'bob',
text: 'hello',
messageId: 'message-1',
deliveryAttemptId: 'ledger-1:1:payload',
})
).resolves.toMatchObject({
accepted: true,
sessionId: 'session-bob',
});
expect(executor.execute).toHaveBeenCalledOnce();
expect(executor.execute).toHaveBeenCalledWith(
'opencode.sendMessage',
expect.objectContaining({
deliveryAttemptId: 'ledger-1:1:payload',
payloadHash: expect.any(String),
}),
expect.objectContaining({
cwd: '/repo',
timeoutMs: 45_000,
requestId: expect.stringMatching(/^opencode-send-/),
})
);
});
it('recovers accepted OpenCode sendMessage after bridge timeout through commandStatus when enabled', async () => {
const previous = process.env.CLAUDE_TEAM_OPENCODE_COMMAND_STATUS_RECOVERY;
process.env.CLAUDE_TEAM_OPENCODE_COMMAND_STATUS_RECOVERY = '1';
const executor = fakeSequenceExecutor([
bridgeFailure('timeout', 'OpenCode bridge command timed out', []),
bridgeCommandSuccess({
command: 'opencode.commandStatus',
requestId: 'status-req-1',
data: {
status: 'prompt_accepted',
safeToRetry: false,
accepted: true,
sessionId: 'session-bob',
runtimePromptMessageId: 'msg_prompt_1',
diagnostics: ['OpenCode prompt acceptance recovered from offline_sqlite.'],
},
}),
]);
const bridge = new OpenCodeReadinessBridge(executor);
try {
await expect(
bridge.sendOpenCodeTeamMessage({
teamId: 'team-a',
teamName: 'team-a',
laneId: 'secondary:opencode:bob',
projectPath: '/repo',
memberName: 'bob',
text: 'hello',
messageId: 'message-1',
deliveryAttemptId: 'ledger-1:1:payload',
})
).resolves.toMatchObject({
accepted: true,
sessionId: 'session-bob',
diagnostics: expect.arrayContaining([
expect.objectContaining({
code: 'opencode_send_recovered_after_bridge_timeout',
}),
]),
});
} finally {
if (previous === undefined) {
delete process.env.CLAUDE_TEAM_OPENCODE_COMMAND_STATUS_RECOVERY;
} else {
process.env.CLAUDE_TEAM_OPENCODE_COMMAND_STATUS_RECOVERY = previous;
}
}
expect(executor.execute).toHaveBeenCalledTimes(2);
const sendOptions = executor.execute.mock.calls[0]?.[2] as { requestId?: string } | undefined;
expect(executor.execute.mock.calls[1]).toEqual([
'opencode.commandStatus',
expect.objectContaining({
originalCommand: 'opencode.sendMessage',
originalRequestId: sendOptions?.requestId,
deliveryAttemptId: 'ledger-1:1:payload',
payloadHash: expect.any(String),
}),
{
cwd: '/repo',
timeoutMs: 5_000,
},
]);
});
it('does not query commandStatus for non-timeout OpenCode sendMessage failures', async () => {
await withCommandStatusRecoveryEnabled(async () => {
const executor = fakeExecutor(
bridgeFailure('provider_error', 'OpenCode send failed', [])
);
const bridge = new OpenCodeReadinessBridge(executor);
await expect(
bridge.sendOpenCodeTeamMessage({
teamId: 'team-a',
teamName: 'team-a',
laneId: 'secondary:opencode:bob',
projectPath: '/repo',
memberName: 'bob',
text: 'hello',
messageId: 'message-1',
deliveryAttemptId: 'ledger-1:1:payload',
})
).resolves.toMatchObject({
accepted: false,
memberName: 'bob',
diagnostics: [
expect.objectContaining({
code: 'provider_error',
}),
],
});
expect(executor.execute).toHaveBeenCalledOnce();
});
});
it('keeps the old send failure path when timeout commandStatus is unknown', async () => {
await withCommandStatusRecoveryEnabled(async () => {
const executor = fakeSequenceExecutor([
bridgeFailure('timeout', 'OpenCode bridge command timed out', []),
bridgeCommandSuccess({
command: 'opencode.commandStatus',
requestId: 'status-req-1',
data: {
status: 'unknown',
safeToRetry: false,
accepted: false,
diagnostics: ['No orchestrator-side command outcome record matched the requested OpenCode command.'],
},
}),
]);
const bridge = new OpenCodeReadinessBridge(executor);
await expect(
bridge.sendOpenCodeTeamMessage({
teamId: 'team-a',
teamName: 'team-a',
laneId: 'secondary:opencode:bob',
projectPath: '/repo',
memberName: 'bob',
text: 'hello',
messageId: 'message-1',
deliveryAttemptId: 'ledger-1:1:payload',
})
).resolves.toMatchObject({
accepted: false,
memberName: 'bob',
diagnostics: [
expect.objectContaining({
code: 'timeout',
}),
],
});
expect(executor.execute).toHaveBeenCalledTimes(2);
});
});
it('keeps the old send failure path when timeout commandStatus is unavailable', async () => {
await withCommandStatusRecoveryEnabled(async () => {
const executor = fakeSequenceExecutor([
bridgeFailure('timeout', 'OpenCode bridge command timed out', []),
bridgeFailure('timeout', 'OpenCode commandStatus timed out', []),
]);
const bridge = new OpenCodeReadinessBridge(executor);
await expect(
bridge.sendOpenCodeTeamMessage({
teamId: 'team-a',
teamName: 'team-a',
laneId: 'secondary:opencode:bob',
projectPath: '/repo',
memberName: 'bob',
text: 'hello',
messageId: 'message-1',
deliveryAttemptId: 'ledger-1:1:payload',
})
).resolves.toMatchObject({
accepted: false,
memberName: 'bob',
diagnostics: [
expect.objectContaining({
code: 'timeout',
}),
],
});
expect(executor.execute).toHaveBeenCalledTimes(2);
});
});
it('keeps the old send failure path when timeout commandStatus reports precondition mismatch', async () => {
await withCommandStatusRecoveryEnabled(async () => {
const executor = fakeSequenceExecutor([
bridgeFailure('timeout', 'OpenCode bridge command timed out', []),
bridgeCommandSuccess({
command: 'opencode.commandStatus',
requestId: 'status-req-1',
data: {
status: 'precondition_mismatch',
safeToRetry: false,
accepted: false,
diagnostics: ['OpenCode command status payloadHash mismatch.'],
},
}),
]);
const bridge = new OpenCodeReadinessBridge(executor);
await expect(
bridge.sendOpenCodeTeamMessage({
teamId: 'team-a',
teamName: 'team-a',
laneId: 'secondary:opencode:bob',
projectPath: '/repo',
memberName: 'bob',
text: 'hello',
messageId: 'message-1',
deliveryAttemptId: 'ledger-1:1:payload',
})
).resolves.toMatchObject({
accepted: false,
memberName: 'bob',
diagnostics: [
expect.objectContaining({
code: 'timeout',
}),
],
});
expect(executor.execute).toHaveBeenCalledTimes(2);
});
});
it('routes state-changing launch commands through the guarded command service when configured', async () => { it('routes state-changing launch commands through the guarded command service when configured', async () => {
const executor = fakeExecutor( const executor = fakeExecutor(
bridgeFailure('internal_error', 'direct bridge must not run', []) bridgeFailure('internal_error', 'direct bridge must not run', [])
@ -331,6 +592,38 @@ function fakeExecutor(
}; };
} }
function fakeSequenceExecutor(
results: OpenCodeBridgeResult<unknown>[]
): OpenCodeReadinessBridgeCommandExecutor & {
execute: ReturnType<typeof vi.fn>;
} {
const execute = vi.fn(async () => {
const next = results.shift();
if (!next) {
throw new Error('No fake bridge result queued');
}
return next;
});
return {
execute: execute as unknown as OpenCodeReadinessBridgeCommandExecutor['execute'] &
ReturnType<typeof vi.fn>,
};
}
async function withCommandStatusRecoveryEnabled<T>(callback: () => Promise<T>): Promise<T> {
const previous = process.env.CLAUDE_TEAM_OPENCODE_COMMAND_STATUS_RECOVERY;
process.env.CLAUDE_TEAM_OPENCODE_COMMAND_STATUS_RECOVERY = '1';
try {
return await callback();
} finally {
if (previous === undefined) {
delete process.env.CLAUDE_TEAM_OPENCODE_COMMAND_STATUS_RECOVERY;
} else {
process.env.CLAUDE_TEAM_OPENCODE_COMMAND_STATUS_RECOVERY = previous;
}
}
}
function bridgeSuccess( function bridgeSuccess(
data: OpenCodeTeamLaunchReadiness data: OpenCodeTeamLaunchReadiness
): OpenCodeBridgeSuccess<OpenCodeTeamLaunchReadiness> { ): OpenCodeBridgeSuccess<OpenCodeTeamLaunchReadiness> {

View file

@ -48,6 +48,17 @@ describe('RuntimeDiagnosticClassifier', () => {
}); });
}); });
it('classifies OpenCode bridge outcome timeouts as backend delivery state', () => {
expect(
classifyRuntimeDiagnostic('opencode_prompt_acceptance_unknown_after_bridge_timeout')
).toMatchObject({
reasonCode: 'backend_error',
normalizedMessage: 'OpenCode bridge outcome unknown after timeout, retrying/observing.',
generic: true,
actionRequired: false,
});
});
it('keeps pure empty assistant turns as generic backend fallback', () => { it('keeps pure empty assistant turns as generic backend fallback', () => {
expect(classifyRuntimeDiagnostic('empty_assistant_turn')).toMatchObject({ expect(classifyRuntimeDiagnostic('empty_assistant_turn')).toMatchObject({
reasonCode: 'backend_error', reasonCode: 'backend_error',

View file

@ -64,6 +64,65 @@ describe('TaskBoundaryParser', () => {
expect(result.boundaries.every((entry) => entry.mechanism === 'mcp')).toBe(true); expect(result.boundaries.every((entry) => entry.mechanism === 'mcp')).toBe(true);
}); });
it('dedupes concurrent boundary parsing and invalidates when the file changes', async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'task-boundary-parser-'));
const jsonlPath = path.join(tmpDir, 'concurrent.jsonl');
await fs.writeFile(
jsonlPath,
JSON.stringify({
timestamp: '2026-03-01T10:00:00.000Z',
type: 'assistant',
message: {
role: 'assistant',
content: [
{
type: 'tool_use',
id: 'tool-1',
name: 'task_start',
input: { taskId: 'task-123' },
},
],
},
}) + '\n',
'utf8'
);
const parser = new TaskBoundaryParser();
const [first, second, third] = await Promise.all([
parser.parseBoundaries(jsonlPath),
parser.parseBoundaries(jsonlPath),
parser.parseBoundaries(jsonlPath),
]);
expect(first.boundaries).toHaveLength(1);
expect(second).toEqual(first);
expect(third).toEqual(first);
await new Promise((resolve) => setTimeout(resolve, 20));
await fs.appendFile(
jsonlPath,
JSON.stringify({
timestamp: '2026-03-01T10:10:00.000Z',
type: 'assistant',
message: {
role: 'assistant',
content: [
{
type: 'tool_use',
id: 'tool-2',
name: 'task_complete',
input: { taskId: 'task-123' },
},
],
},
}) + '\n',
'utf8'
);
const afterChange = await parser.parseBoundaries(jsonlPath);
expect(afterChange.boundaries).toHaveLength(2);
});
it('detects fully-qualified agent-teams MCP task boundaries', async () => { it('detects fully-qualified agent-teams MCP task boundaries', async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'task-boundary-parser-')); tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'task-boundary-parser-'));
const jsonlPath = path.join(tmpDir, 'mcp-qualified.jsonl'); const jsonlPath = path.join(tmpDir, 'mcp-qualified.jsonl');

View file

@ -0,0 +1,93 @@
import * as os from 'os';
import * as path from 'path';
import { afterEach, describe, expect, it } from 'vitest';
import * as fs from 'fs/promises';
import { TaskChangeComputer } from '../../../../src/main/services/team/TaskChangeComputer';
async function writeJsonl(filePath: string, entries: object[]): Promise<void> {
await fs.writeFile(
filePath,
entries.map((entry) => JSON.stringify(entry)).join('\n') + '\n',
'utf8'
);
}
function writeToolUse(toolUseId: string, filePath: string, content: string): object {
return {
timestamp: '2026-03-01T10:00:00.000Z',
type: 'assistant',
message: {
role: 'assistant',
content: [
{
type: 'tool_use',
id: toolUseId,
name: 'Write',
input: { file_path: filePath, content },
},
],
},
};
}
describe('TaskChangeComputer', () => {
let tmpDir: string | null = null;
afterEach(async () => {
if (tmpDir) {
await fs.rm(tmpDir, { recursive: true, force: true });
tmpDir = null;
}
});
it('shares concurrent JSONL parsing and invalidates when the file changes', async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'task-change-computer-'));
const logPath = path.join(tmpDir, 'agent.jsonl');
await writeJsonl(logPath, [writeToolUse('tool-1', '/repo/src/a.ts', 'export const a = 1;\n')]);
const logsFinder = {
findLogFileRefsForTask: () => Promise.resolve([{ filePath: logPath, memberName: 'alice' }]),
};
const boundaryParser = {
parseBoundaries: () =>
Promise.resolve({
boundaries: [],
scopes: [],
isSingleTaskSession: true,
detectedMechanism: 'none' as const,
}),
};
const computer = new TaskChangeComputer(logsFinder as never, boundaryParser as never);
const input = {
teamName: 'team-a',
taskId: 'task-1',
taskMeta: null,
effectiveOptions: {},
projectPath: '/repo',
includeDetails: false,
};
const [first, second] = await Promise.all([
computer.computeTaskChanges(input),
computer.computeTaskChanges(input),
]);
expect(first.files.map((file) => file.relativePath)).toEqual(['src/a.ts']);
expect(second.files).toEqual(first.files);
await new Promise((resolve) => setTimeout(resolve, 20));
await writeJsonl(logPath, [
writeToolUse('tool-1', '/repo/src/a.ts', 'export const a = 1;\n'),
writeToolUse('tool-2', '/repo/src/b.ts', 'export const b = 2;\n'),
]);
const afterChange = await computer.computeTaskChanges(input);
expect(
afterChange.files
.map((file) => file.relativePath)
.sort((left, right) => left.localeCompare(right))
).toEqual(['src/a.ts', 'src/b.ts']);
});
});

View file

@ -109,6 +109,104 @@ describe('TeamMemberLogsFinder', () => {
]); ]);
}); });
it('dedupes concurrent log source discovery for the same team', async () => {
const teamName = 'dedupe-context-team';
let resolveContext!: (value: unknown) => void;
const contextPromise = new Promise((resolve) => {
resolveContext = resolve;
});
const projectResolver = {
getContext: vi.fn(() => contextPromise),
getLiveBaseContext: vi.fn(),
};
const inboxReader = { listInboxNames: vi.fn(async () => []) };
const membersMetaStore = { getMembers: vi.fn(async () => []) };
const finder = new TeamMemberLogsFinder(
undefined,
inboxReader as never,
membersMetaStore as never,
projectResolver as never
);
const first = finder.getLogSourceWatchContext(teamName);
const second = finder.getLogSourceWatchContext(teamName);
await Promise.resolve();
expect(projectResolver.getContext).toHaveBeenCalledTimes(1);
resolveContext({
projectDir: '/tmp/project',
projectId: 'project',
sessionIds: ['session-1'],
config: { name: teamName, projectPath: '/repo', members: [] },
});
await expect(Promise.all([first, second])).resolves.toEqual([
{
projectDir: '/tmp/project',
projectPath: '/repo',
leadSessionId: undefined,
sessionIds: ['session-1'],
},
{
projectDir: '/tmp/project',
projectPath: '/repo',
leadSessionId: undefined,
sessionIds: ['session-1'],
},
]);
await finder.getLogSourceWatchContext(teamName);
expect(projectResolver.getContext).toHaveBeenCalledTimes(1);
});
it('honors forceRefresh after cached log source discovery', async () => {
const teamName = 'force-refresh-context-team';
const contexts = [
{
projectDir: '/tmp/project-old',
projectId: 'project-old',
sessionIds: ['old-session'],
config: { name: teamName, projectPath: '/repo-old', members: [] },
},
{
projectDir: '/tmp/project-new',
projectId: 'project-new',
sessionIds: ['new-session'],
config: { name: teamName, projectPath: '/repo-new', members: [] },
},
];
const projectResolver = {
getContext: vi.fn(async () => contexts.shift() ?? contexts[0]),
getLiveBaseContext: vi.fn(),
};
const inboxReader = { listInboxNames: vi.fn(async () => []) };
const membersMetaStore = { getMembers: vi.fn(async () => []) };
const finder = new TeamMemberLogsFinder(
undefined,
inboxReader as never,
membersMetaStore as never,
projectResolver as never
);
await expect(finder.getLogSourceWatchContext(teamName)).resolves.toMatchObject({
projectDir: '/tmp/project-old',
sessionIds: ['old-session'],
});
await finder.getLogSourceWatchContext(teamName);
expect(projectResolver.getContext).toHaveBeenCalledTimes(1);
await expect(
finder.getLogSourceWatchContext(teamName, { forceRefresh: true })
).resolves.toMatchObject({
projectDir: '/tmp/project-new',
sessionIds: ['new-session'],
});
expect(projectResolver.getContext).toHaveBeenCalledTimes(2);
await finder.getLogSourceWatchContext(teamName);
expect(projectResolver.getContext).toHaveBeenCalledTimes(2);
});
it('returns subagent logs for a member and lead session for team-lead', async () => { it('returns subagent logs for a member and lead session for team-lead', async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-logs-')); tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-logs-'));
setClaudeBasePathOverride(tmpDir); setClaudeBasePathOverride(tmpDir);
@ -1535,6 +1633,97 @@ describe('TeamMemberLogsFinder', () => {
expect(refs[0].filePath).toContain('agent-ref1.jsonl'); expect(refs[0].filePath).toContain('agent-ref1.jsonl');
}); });
it('indexes task mentions without changing matching semantics', async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-refs-index-'));
setClaudeBasePathOverride(tmpDir);
const teamName = 'mention-index-team';
const projectPath = '/Users/test/mention-index-proj';
const projectId = '-Users-test-mention-index-proj';
const sessionId = 'six';
const fullTaskId = 'abcdef12-1111-4222-8333-444444444444';
await fs.mkdir(path.join(tmpDir, 'teams', teamName), { recursive: true });
await fs.writeFile(
path.join(tmpDir, 'teams', teamName, 'config.json'),
JSON.stringify({
name: teamName,
projectPath,
members: [
{ name: 'team-lead', agentType: 'team-lead' },
{ name: 'dev', agentType: 'general-purpose' },
],
}),
'utf8'
);
const projectRoot = path.join(tmpDir, 'projects', projectId);
await fs.mkdir(path.join(projectRoot, sessionId, 'subagents'), { recursive: true });
await fs.writeFile(
path.join(projectRoot, sessionId, 'subagents', 'agent-indexed.jsonl'),
[
JSON.stringify({
timestamp: '2026-01-01T00:00:01.000Z',
type: 'user',
message: {
role: 'user',
content: `You are dev, a developer on team "${teamName}" (${teamName}).`,
},
}),
JSON.stringify({
timestamp: '2026-01-01T00:00:02.000Z',
type: 'assistant',
message: {
role: 'assistant',
content: [
{ type: 'tool_use', name: 'TaskGet', input: { taskId: 'ignored-task' } },
{
type: 'tool_use',
name: 'TaskUpdate',
input: { taskId: fullTaskId.slice(0, 8), status: 'completed' },
},
{
type: 'tool_use',
name: 'TaskUpdate',
input: { taskId: 'wrong-team-task', teamName: 'other-team', status: 'completed' },
},
],
},
}),
].join('\n') + '\n',
'utf8'
);
await fs.writeFile(
path.join(projectRoot, sessionId, 'subagents', 'agent-no-team.jsonl'),
[
JSON.stringify({
timestamp: '2026-01-01T00:00:03.000Z',
type: 'assistant',
message: {
role: 'assistant',
content: [
{
type: 'tool_use',
name: 'TaskUpdate',
input: { taskId: 'no-team-task', status: 'completed' },
},
],
},
}),
].join('\n') + '\n',
'utf8'
);
const finder = new TeamMemberLogsFinder();
await expect(finder.findLogFileRefsForTask(teamName, fullTaskId)).resolves.toHaveLength(1);
await expect(finder.findLogFileRefsForTask(teamName, 'ignored-task')).resolves.toHaveLength(0);
await expect(finder.findLogFileRefsForTask(teamName, 'wrong-team-task')).resolves.toHaveLength(
0
);
await expect(finder.findLogFileRefsForTask(teamName, 'no-team-task')).resolves.toHaveLength(0);
});
it('findLogFileRefsForTask does not mix tasks across teams sharing a projectPath', async () => { it('findLogFileRefsForTask does not mix tasks across teams sharing a projectPath', async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-refs-cross-')); tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-refs-cross-'));
setClaudeBasePathOverride(tmpDir); setClaudeBasePathOverride(tmpDir);

View file

@ -16,6 +16,9 @@ vi.mock('@main/utils/pathDecoder', () => ({
describe('buildMergedCliPath', () => { describe('buildMergedCliPath', () => {
let buildMergedCliPath: typeof import('@main/utils/cliPathMerge').buildMergedCliPath; let buildMergedCliPath: typeof import('@main/utils/cliPathMerge').buildMergedCliPath;
const originalPlatform = process.platform; const originalPlatform = process.platform;
const originalLocalAppData = process.env.LOCALAPPDATA;
const originalProgramFiles = process.env.ProgramFiles;
const originalPath = process.env.PATH;
beforeEach(async () => { beforeEach(async () => {
vi.resetModules(); vi.resetModules();
@ -28,6 +31,21 @@ describe('buildMergedCliPath', () => {
afterEach(() => { afterEach(() => {
Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true }); Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true });
if (originalLocalAppData === undefined) {
delete process.env.LOCALAPPDATA;
} else {
process.env.LOCALAPPDATA = originalLocalAppData;
}
if (originalProgramFiles === undefined) {
delete process.env.ProgramFiles;
} else {
process.env.ProgramFiles = originalProgramFiles;
}
if (originalPath === undefined) {
delete process.env.PATH;
} else {
process.env.PATH = originalPath;
}
}); });
it('on darwin/linux with cold shell cache prepends standard user bin dirs before process PATH', () => { it('on darwin/linux with cold shell cache prepends standard user bin dirs before process PATH', () => {
@ -40,12 +58,24 @@ describe('buildMergedCliPath', () => {
'/home/testuser/.local/bin', '/home/testuser/.local/bin',
'/home/testuser/.npm-global/bin', '/home/testuser/.npm-global/bin',
'/home/testuser/.npm/bin', '/home/testuser/.npm/bin',
'/home/testuser/.asdf/shims',
'/home/testuser/.local/share/mise/shims',
'/home/testuser/.volta/bin',
'/home/testuser/Library/pnpm',
'/home/testuser/.local/share/pnpm',
'/home/testuser/.cargo/bin',
'/home/testuser/.nix-profile/bin',
'/usr/local/bin', '/usr/local/bin',
'/opt/homebrew/bin', '/opt/homebrew/bin',
'/opt/local/bin',
'/usr/bin', '/usr/bin',
'/bin',
'/usr/sbin',
'/sbin',
]) ])
); );
expect(p.startsWith('/home/testuser/.claude/local/node_modules/.bin')).toBe(true); expect(p.startsWith('/home/testuser/.claude/local/node_modules/.bin')).toBe(true);
expect(p.split(':').filter((part) => part === '/usr/bin')).toHaveLength(1);
}); });
it('on win32 with cold shell cache uses semicolon and npm-style dirs', () => { it('on win32 with cold shell cache uses semicolon and npm-style dirs', () => {
@ -57,6 +87,9 @@ describe('buildMergedCliPath', () => {
const parts = p.split(';'); const parts = p.split(';');
expect(parts.some((x) => /Roaming[/\\]npm/i.test(x))).toBe(true); expect(parts.some((x) => /Roaming[/\\]npm/i.test(x))).toBe(true);
expect(parts.some((x) => /Programs[/\\]claude/i.test(x))).toBe(true); expect(parts.some((x) => /Programs[/\\]claude/i.test(x))).toBe(true);
expect(parts.some((x) => /AppData[/\\]Local[/\\]pnpm/i.test(x))).toBe(true);
expect(parts.some((x) => /[.]volta[/\\]bin/i.test(x))).toBe(true);
expect(parts.some((x) => /Program Files[/\\]nodejs/i.test(x))).toBe(true);
expect(parts[parts.length - 1]).toBe('/usr/bin'); expect(parts[parts.length - 1]).toBe('/usr/bin');
}); });

View file

@ -0,0 +1,206 @@
// @vitest-environment node
import { chmod, mkdtemp, readFile, rm, writeFile } from 'fs/promises';
import { tmpdir } from 'os';
import path from 'path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
clearShellEnvCache,
getCachedShellEnv,
resolveInteractiveShellEnv,
resolveInteractiveShellEnvBestEffort,
} from '@main/utils/shellEnv';
const describePosix = process.platform === 'win32' ? describe.skip : describe;
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function waitForCachedEnv(timeoutMs = 2_000): Promise<NodeJS.ProcessEnv | null> {
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
const cached = getCachedShellEnv();
if (cached) {
return cached;
}
await sleep(25);
}
return getCachedShellEnv();
}
async function createFakeShell(tempDir: string, name: string, source: string): Promise<string> {
const shellPath = path.join(tempDir, name);
await writeFile(shellPath, `#!/usr/bin/env node\n${source}\n`, 'utf8');
await chmod(shellPath, 0o755);
return shellPath;
}
function envWriterSource(envExpression: string): string {
return `
function writeEnv(env) {
process.stdout.write(Object.entries(env).map(([key, value]) => key + '=' + value).join('\\0') + '\\0');
}
${envExpression}
`;
}
describePosix('shellEnv real child-process integration', () => {
const originalShell = process.env.SHELL;
const originalInvocationFile = process.env.FAKE_SHELL_INVOCATIONS;
let tempDir = '';
beforeEach(async () => {
clearShellEnvCache();
tempDir = await mkdtemp(path.join(tmpdir(), 'agent-teams-shell-env-'));
delete process.env.FAKE_SHELL_INVOCATIONS;
});
afterEach(async () => {
clearShellEnvCache();
if (originalShell === undefined) {
delete process.env.SHELL;
} else {
process.env.SHELL = originalShell;
}
if (originalInvocationFile === undefined) {
delete process.env.FAKE_SHELL_INVOCATIONS;
} else {
process.env.FAKE_SHELL_INVOCATIONS = originalInvocationFile;
}
await rm(tempDir, { recursive: true, force: true });
});
it('returns a real shell env from an executable shell path before best-effort timeout', async () => {
const fakeShell = await createFakeShell(
tempDir,
'fast-shell.js',
envWriterSource(`
writeEnv({
PATH: '/fake-fast/bin:/usr/bin',
HOME: '/fake-home',
SHELL: process.argv[1],
});
`)
);
process.env.SHELL = fakeShell;
const env = await resolveInteractiveShellEnvBestEffort({
timeoutMs: 1_000,
fallbackEnv: { PATH: 'FALLBACK_PATH', HOME: 'FALLBACK_HOME' },
});
expect(env).toMatchObject({
PATH: '/fake-fast/bin:/usr/bin',
HOME: '/fake-home',
SHELL: fakeShell,
});
expect(getCachedShellEnv()).toMatchObject({
PATH: '/fake-fast/bin:/usr/bin',
HOME: '/fake-home',
});
});
it('returns fallback quickly while a slow shell warms the cache in the background', async () => {
const fakeShell = await createFakeShell(
tempDir,
'slow-shell.js',
envWriterSource(`
setTimeout(() => {
writeEnv({
PATH: '/slow-real/bin:/usr/bin',
HOME: '/slow-home',
});
}, 200);
`)
);
process.env.SHELL = fakeShell;
const startedAt = Date.now();
const env = await resolveInteractiveShellEnvBestEffort({
timeoutMs: 25,
fallbackEnv: { PATH: 'FALLBACK_PATH', HOME: 'FALLBACK_HOME' },
});
const elapsedMs = Date.now() - startedAt;
expect(elapsedMs).toBeLessThan(150);
expect(env).toMatchObject({ PATH: 'FALLBACK_PATH', HOME: 'FALLBACK_HOME' });
expect(getCachedShellEnv()).toBeNull();
await expect(waitForCachedEnv()).resolves.toMatchObject({
PATH: '/slow-real/bin:/usr/bin',
HOME: '/slow-home',
});
});
it('falls back from a failed login shell process to a successful interactive shell process', async () => {
const fakeShell = await createFakeShell(
tempDir,
'login-fails-shell.js',
envWriterSource(`
if ((process.argv[2] || '').includes('l')) {
process.exit(42);
}
writeEnv({
PATH: '/interactive-real/bin:/usr/bin',
HOME: '/interactive-home',
});
`)
);
process.env.SHELL = fakeShell;
await expect(resolveInteractiveShellEnv()).resolves.toMatchObject({
PATH: '/interactive-real/bin:/usr/bin',
HOME: '/interactive-home',
});
expect(console.warn).toHaveBeenCalledWith(
'[Utils:shellEnv]',
'Failed to resolve login shell env: shell env command exited with code 42'
);
vi.mocked(console.warn).mockClear();
expect(getCachedShellEnv()).toMatchObject({
PATH: '/interactive-real/bin:/usr/bin',
HOME: '/interactive-home',
});
});
it('coalesces concurrent best-effort calls into one real shell process', async () => {
const invocationFile = path.join(tempDir, 'invocations.log');
process.env.FAKE_SHELL_INVOCATIONS = invocationFile;
const fakeShell = await createFakeShell(
tempDir,
'coalesced-shell.js',
envWriterSource(`
const fs = require('fs');
fs.appendFileSync(process.env.FAKE_SHELL_INVOCATIONS, 'spawned\\n');
setTimeout(() => {
writeEnv({
PATH: '/coalesced-real/bin:/usr/bin',
HOME: '/coalesced-home',
});
}, 200);
`)
);
process.env.SHELL = fakeShell;
const results = await Promise.all(
Array.from({ length: 10 }, async (_, index) =>
resolveInteractiveShellEnvBestEffort({
timeoutMs: 25,
fallbackEnv: { PATH: `FALLBACK_${index}`, HOME: `FALLBACK_HOME_${index}` },
})
)
);
expect(results).toHaveLength(10);
expect(results.every((env, index) => env.PATH === `FALLBACK_${index}`)).toBe(true);
await expect(waitForCachedEnv()).resolves.toMatchObject({
PATH: '/coalesced-real/bin:/usr/bin',
HOME: '/coalesced-home',
});
const invocations = await readFile(invocationFile, 'utf8');
expect(invocations.trim().split('\n')).toHaveLength(1);
});
});

View file

@ -0,0 +1,549 @@
// @vitest-environment node
import { EventEmitter } from 'events';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const hoisted = vi.hoisted(() => ({
spawn: vi.fn(),
loggerWarn: vi.fn(),
}));
vi.mock('child_process', () => ({
spawn: hoisted.spawn,
}));
vi.mock('@main/utils/pathDecoder', () => ({
getHomeDir: () => '/Users/tester',
}));
vi.mock('@shared/utils/logger', () => ({
createLogger: () => ({
warn: hoisted.loggerWarn,
error: vi.fn(),
info: vi.fn(),
debug: vi.fn(),
}),
}));
class MockChildProcess extends EventEmitter {
stdout = new EventEmitter();
kill = vi.fn();
}
function createChild(): MockChildProcess {
return new MockChildProcess();
}
function emitEnv(child: MockChildProcess, env: Record<string, string>): void {
const dump = `${Object.entries(env)
.map(([key, value]) => `${key}=${value}`)
.join('\0')}\0`;
child.stdout.emit('data', Buffer.from(dump));
child.emit('close', 0);
}
function emitEnvChunks(child: MockChildProcess, chunks: string[]): void {
for (const chunk of chunks) {
child.stdout.emit('data', Buffer.from(chunk));
}
child.emit('close', 0);
}
function emitError(child: MockChildProcess, message: string): void {
child.emit('error', new Error(message));
}
function emitClose(child: MockChildProcess, code: number | null, signal: NodeJS.Signals | null): void {
child.emit('close', code, signal);
}
async function importShellEnv(): Promise<typeof import('@main/utils/shellEnv')> {
return import('@main/utils/shellEnv');
}
describe('shellEnv', () => {
const originalPlatform = process.platform;
const originalShell = process.env.SHELL;
beforeEach(() => {
vi.useFakeTimers();
vi.resetModules();
vi.clearAllMocks();
process.env.SHELL = '/bin/zsh';
Object.defineProperty(process, 'platform', {
value: 'darwin',
configurable: true,
writable: true,
});
});
afterEach(() => {
vi.useRealTimers();
Object.defineProperty(process, 'platform', {
value: originalPlatform,
configurable: true,
writable: true,
});
if (originalShell === undefined) {
delete process.env.SHELL;
} else {
process.env.SHELL = originalShell;
}
});
it('keeps the strict resolver login then interactive fallback order', async () => {
const children: MockChildProcess[] = [];
hoisted.spawn.mockImplementation(() => {
const child = createChild();
children.push(child);
queueMicrotask(() => {
if (children.length === 1) {
emitError(child, 'login failed');
} else {
emitEnv(child, { PATH: '/interactive/bin', HOME: '/Users/tester' });
}
});
return child;
});
const shellEnv = await importShellEnv();
await expect(shellEnv.resolveInteractiveShellEnv()).resolves.toMatchObject({
PATH: '/interactive/bin',
HOME: '/Users/tester',
});
expect(hoisted.spawn).toHaveBeenCalledTimes(2);
expect(hoisted.spawn).toHaveBeenNthCalledWith(
1,
'/bin/zsh',
['-lic', 'env -0'],
expect.any(Object)
);
expect(hoisted.spawn).toHaveBeenNthCalledWith(
2,
'/bin/zsh',
['-ic', 'env -0'],
expect.any(Object)
);
});
it('returns fallback on soft timeout without caching it, then caches background success', async () => {
const children: MockChildProcess[] = [];
hoisted.spawn.mockImplementation(() => {
const child = createChild();
children.push(child);
return child;
});
const shellEnv = await importShellEnv();
const fallbackEnv = { PATH: '/fallback/bin', HOME: '/fallback' };
const result = shellEnv.resolveInteractiveShellEnvBestEffort({
timeoutMs: 10,
fallbackEnv,
});
await vi.advanceTimersByTimeAsync(10);
await expect(result).resolves.toBe(fallbackEnv);
expect(shellEnv.getCachedShellEnv()).toBeNull();
expect(hoisted.spawn).toHaveBeenCalledTimes(1);
emitEnv(children[0], { PATH: '/real/bin', HOME: '/Users/tester' });
await Promise.resolve();
await Promise.resolve();
expect(shellEnv.getCachedShellEnv()).toMatchObject({
PATH: '/real/bin',
HOME: '/Users/tester',
});
});
it('returns real env when shell resolves before the soft timeout', async () => {
const child = createChild();
hoisted.spawn.mockReturnValueOnce(child);
const shellEnv = await importShellEnv();
const result = shellEnv.resolveInteractiveShellEnvBestEffort({
timeoutMs: 100,
fallbackEnv: { PATH: '/fallback/should-not-win' },
});
emitEnv(child, { PATH: '/fast/bin', HOME: '/Users/tester' });
await expect(result).resolves.toMatchObject({
PATH: '/fast/bin',
HOME: '/Users/tester',
});
expect(shellEnv.getCachedShellEnv()).toMatchObject({
PATH: '/fast/bin',
HOME: '/Users/tester',
});
});
it('does not let a soft fallback override getShellPreferredHome before cache warms', async () => {
const child = createChild();
hoisted.spawn.mockReturnValueOnce(child);
const shellEnv = await importShellEnv();
const result = shellEnv.resolveInteractiveShellEnvBestEffort({
timeoutMs: 5,
fallbackEnv: { PATH: '/fallback/bin', HOME: '/fallback-home' },
});
await vi.advanceTimersByTimeAsync(5);
await expect(result).resolves.toMatchObject({ HOME: '/fallback-home' });
expect(shellEnv.getCachedShellEnv()).toBeNull();
expect(shellEnv.getShellPreferredHome()).toBe('/Users/tester');
emitEnv(child, { PATH: '/real/bin', HOME: '/real-home' });
await Promise.resolve();
await Promise.resolve();
expect(shellEnv.getShellPreferredHome()).toBe('/real-home');
});
it('parses chunked env output and ignores malformed records', async () => {
const child = createChild();
hoisted.spawn.mockReturnValueOnce(child);
const shellEnv = await importShellEnv();
const result = shellEnv.resolveInteractiveShellEnv();
emitEnvChunks(child, [
'PATH=/chunk',
'ed/bin\0',
'MALFORMED\0',
'=bad\0',
'EMPTY=\0',
'HOME=/Users/tester\0',
]);
await expect(result).resolves.toMatchObject({
PATH: '/chunked/bin',
EMPTY: '',
HOME: '/Users/tester',
});
expect(shellEnv.getCachedShellEnv()).toMatchObject({
PATH: '/chunked/bin',
HOME: '/Users/tester',
});
});
it('starts background resolution even with a zero soft timeout', async () => {
const children: MockChildProcess[] = [];
hoisted.spawn.mockImplementation(() => {
const child = createChild();
children.push(child);
return child;
});
const shellEnv = await importShellEnv();
const fallbackEnv = { PATH: '/fallback/zero' };
await expect(
shellEnv.resolveInteractiveShellEnvBestEffort({
timeoutMs: 0,
fallbackEnv,
})
).resolves.toBe(fallbackEnv);
expect(hoisted.spawn).toHaveBeenCalledTimes(1);
expect(shellEnv.getCachedShellEnv()).toBeNull();
emitEnv(children[0], { PATH: '/real/zero', HOME: '/Users/tester' });
await Promise.resolve();
await Promise.resolve();
expect(shellEnv.getCachedShellEnv()).toMatchObject({
PATH: '/real/zero',
HOME: '/Users/tester',
});
});
it('keeps resolving in the background through the strict interactive fallback', async () => {
const children: MockChildProcess[] = [];
hoisted.spawn.mockImplementation(() => {
const child = createChild();
children.push(child);
return child;
});
const shellEnv = await importShellEnv();
const fallbackEnv = { PATH: '/fallback/login-timeout' };
const result = shellEnv.resolveInteractiveShellEnvBestEffort({
timeoutMs: 10,
fallbackEnv,
});
await vi.advanceTimersByTimeAsync(10);
await expect(result).resolves.toBe(fallbackEnv);
expect(children).toHaveLength(1);
emitError(children[0], 'login failed');
await Promise.resolve();
await Promise.resolve();
expect(children).toHaveLength(2);
emitEnv(children[1], { PATH: '/interactive/bin', HOME: '/Users/tester' });
await Promise.resolve();
await Promise.resolve();
expect(shellEnv.getCachedShellEnv()).toMatchObject({
PATH: '/interactive/bin',
HOME: '/Users/tester',
});
});
it('treats non-zero shell exit with no env output as a failed probe', async () => {
const children: MockChildProcess[] = [];
hoisted.spawn.mockImplementation(() => {
const child = createChild();
children.push(child);
queueMicrotask(() => {
if (children.length === 1) {
emitClose(child, 42, null);
} else {
emitEnv(child, { PATH: '/interactive-after-exit/bin', HOME: '/Users/tester' });
}
});
return child;
});
const shellEnv = await importShellEnv();
await expect(shellEnv.resolveInteractiveShellEnv()).resolves.toMatchObject({
PATH: '/interactive-after-exit/bin',
HOME: '/Users/tester',
});
expect(hoisted.spawn).toHaveBeenCalledTimes(2);
expect(hoisted.loggerWarn).toHaveBeenCalledWith(
'Failed to resolve login shell env: shell env command exited with code 42'
);
});
it('coalesces concurrent best-effort calls behind one shell process', async () => {
const children: MockChildProcess[] = [];
hoisted.spawn.mockImplementation(() => {
const child = createChild();
children.push(child);
return child;
});
const shellEnv = await importShellEnv();
const firstFallback = { PATH: '/fallback/one' };
const secondFallback = { PATH: '/fallback/two' };
const first = shellEnv.resolveInteractiveShellEnvBestEffort({
timeoutMs: 5,
fallbackEnv: firstFallback,
});
const second = shellEnv.resolveInteractiveShellEnvBestEffort({
timeoutMs: 5,
fallbackEnv: secondFallback,
});
await vi.advanceTimersByTimeAsync(5);
await expect(first).resolves.toBe(firstFallback);
await expect(second).resolves.toBe(secondFallback);
expect(hoisted.spawn).toHaveBeenCalledTimes(1);
emitEnv(children[0], { PATH: '/real/bin' });
await Promise.resolve();
await Promise.resolve();
});
it('uses failure cooldown after a hard shell failure and avoids respawning immediately', async () => {
hoisted.spawn.mockImplementation(() => {
const child = createChild();
const callNumber = hoisted.spawn.mock.calls.length;
queueMicrotask(() => emitError(child, `failure ${callNumber}`));
return child;
});
const shellEnv = await importShellEnv();
const firstFallback = { PATH: '/fallback/first' };
const secondFallback = { PATH: '/fallback/second' };
await expect(
shellEnv.resolveInteractiveShellEnvBestEffort({
timeoutMs: 1_000,
fallbackEnv: firstFallback,
})
).resolves.toBe(firstFallback);
expect(hoisted.spawn).toHaveBeenCalledTimes(2);
await expect(
shellEnv.resolveInteractiveShellEnvBestEffort({
timeoutMs: 1_000,
fallbackEnv: secondFallback,
})
).resolves.toBe(secondFallback);
expect(hoisted.spawn).toHaveBeenCalledTimes(2);
});
it('expires failure cooldown so a later best-effort call can retry shell resolution', async () => {
hoisted.spawn.mockImplementation(() => {
const child = createChild();
queueMicrotask(() => emitError(child, 'blocked'));
return child;
});
const shellEnv = await importShellEnv();
await expect(
shellEnv.resolveInteractiveShellEnvBestEffort({
timeoutMs: 1_000,
fallbackEnv: { PATH: '/fallback/first' },
})
).resolves.toMatchObject({ PATH: '/fallback/first' });
expect(hoisted.spawn).toHaveBeenCalledTimes(2);
await expect(
shellEnv.resolveInteractiveShellEnvBestEffort({
timeoutMs: 1_000,
fallbackEnv: { PATH: '/fallback/cooldown' },
})
).resolves.toMatchObject({ PATH: '/fallback/cooldown' });
expect(hoisted.spawn).toHaveBeenCalledTimes(2);
await vi.advanceTimersByTimeAsync(60_001);
await expect(
shellEnv.resolveInteractiveShellEnvBestEffort({
timeoutMs: 1_000,
fallbackEnv: { PATH: '/fallback/retry' },
})
).resolves.toMatchObject({ PATH: '/fallback/retry' });
expect(hoisted.spawn).toHaveBeenCalledTimes(4);
});
it('terminates stuck login and interactive shell probes before returning fallback', async () => {
const children: MockChildProcess[] = [];
hoisted.spawn.mockImplementation(() => {
const child = createChild();
children.push(child);
return child;
});
const shellEnv = await importShellEnv();
const result = shellEnv.resolveInteractiveShellEnvBestEffort({
timeoutMs: 30_000,
fallbackEnv: { PATH: '/fallback/stuck' },
});
await vi.advanceTimersByTimeAsync(12_000);
await Promise.resolve();
await Promise.resolve();
expect(children).toHaveLength(2);
expect(children[0].kill).toHaveBeenCalledWith();
await vi.advanceTimersByTimeAsync(12_000);
await expect(result).resolves.toMatchObject({ PATH: '/fallback/stuck' });
expect(children[1].kill).toHaveBeenCalledWith();
expect(shellEnv.getCachedShellEnv()).toBeNull();
await vi.advanceTimersByTimeAsync(3_000);
expect(children[0].kill).toHaveBeenCalledWith('SIGKILL');
expect(children[1].kill).toHaveBeenCalledWith('SIGKILL');
});
it('clears failure cooldown when the shell env cache is cleared', async () => {
let fail = true;
hoisted.spawn.mockImplementation(() => {
const child = createChild();
queueMicrotask(() => {
if (fail) {
emitError(child, 'blocked');
} else {
emitEnv(child, { PATH: '/recovered/bin', HOME: '/Users/tester' });
}
});
return child;
});
const shellEnv = await importShellEnv();
await expect(
shellEnv.resolveInteractiveShellEnvBestEffort({
timeoutMs: 1_000,
fallbackEnv: { PATH: '/fallback/blocked' },
})
).resolves.toMatchObject({ PATH: '/fallback/blocked' });
expect(hoisted.spawn).toHaveBeenCalledTimes(2);
fail = false;
shellEnv.clearShellEnvCache();
await expect(
shellEnv.resolveInteractiveShellEnvBestEffort({
timeoutMs: 1_000,
fallbackEnv: { PATH: '/fallback/recovered' },
})
).resolves.toMatchObject({
PATH: '/recovered/bin',
HOME: '/Users/tester',
});
expect(hoisted.spawn).toHaveBeenCalledTimes(3);
});
it('uses cached shell env immediately without spawning or returning fallback', async () => {
const firstChild = createChild();
hoisted.spawn.mockReturnValueOnce(firstChild);
const shellEnv = await importShellEnv();
const strictResult = shellEnv.resolveInteractiveShellEnv();
emitEnv(firstChild, { PATH: '/cached/bin', HOME: '/Users/tester' });
await expect(strictResult).resolves.toMatchObject({ PATH: '/cached/bin' });
hoisted.spawn.mockClear();
await expect(
shellEnv.resolveInteractiveShellEnvBestEffort({
timeoutMs: 1,
fallbackEnv: { PATH: '/fallback/should-not-win' },
})
).resolves.toMatchObject({
PATH: '/cached/bin',
HOME: '/Users/tester',
});
expect(hoisted.spawn).not.toHaveBeenCalled();
});
it('strict resolver also returns cached shell env without spawning again', async () => {
const firstChild = createChild();
hoisted.spawn.mockReturnValueOnce(firstChild);
const shellEnv = await importShellEnv();
const first = shellEnv.resolveInteractiveShellEnv();
emitEnv(firstChild, { PATH: '/strict-cached/bin', HOME: '/Users/tester' });
await expect(first).resolves.toMatchObject({ PATH: '/strict-cached/bin' });
hoisted.spawn.mockClear();
await expect(shellEnv.resolveInteractiveShellEnv()).resolves.toMatchObject({
PATH: '/strict-cached/bin',
HOME: '/Users/tester',
});
expect(hoisted.spawn).not.toHaveBeenCalled();
});
it('best-effort on win32 preserves the strict no-spawn behavior', async () => {
Object.defineProperty(process, 'platform', {
value: 'win32',
configurable: true,
writable: true,
});
const shellEnv = await importShellEnv();
await expect(
shellEnv.resolveInteractiveShellEnvBestEffort({
timeoutMs: 1,
fallbackEnv: { PATH: '/fallback/win32' },
})
).resolves.toEqual({});
expect(hoisted.spawn).not.toHaveBeenCalled();
expect(shellEnv.getCachedShellEnv()).toEqual({});
});
});

View file

@ -24,11 +24,17 @@ interface StoreState {
cliInstallerDetail: string | null; cliInstallerDetail: string | null;
cliInstallerRawChunks: string[]; cliInstallerRawChunks: string[];
cliCompletedVersion: string | null; cliCompletedVersion: string | null;
openCodeRuntimeStatus: Record<string, unknown> | null;
openCodeRuntimeStatusLoading: boolean;
openCodeRuntimeError: string | null;
bootstrapCliStatus: ReturnType<typeof vi.fn>; bootstrapCliStatus: ReturnType<typeof vi.fn>;
fetchCliStatus: ReturnType<typeof vi.fn>; fetchCliStatus: ReturnType<typeof vi.fn>;
fetchCliProviderStatus: ReturnType<typeof vi.fn>; fetchCliProviderStatus: ReturnType<typeof vi.fn>;
invalidateCliStatus: ReturnType<typeof vi.fn>; invalidateCliStatus: ReturnType<typeof vi.fn>;
installCli: ReturnType<typeof vi.fn>; installCli: ReturnType<typeof vi.fn>;
fetchOpenCodeRuntimeStatus: ReturnType<typeof vi.fn>;
installOpenCodeRuntime: ReturnType<typeof vi.fn>;
invalidateOpenCodeRuntimeStatus: ReturnType<typeof vi.fn>;
appConfig: { appConfig: {
general: { general: {
multimodelEnabled: boolean; multimodelEnabled: boolean;
@ -319,11 +325,17 @@ describe('CLI status visibility during completed install state', () => {
storeState.cliInstallerDetail = null; storeState.cliInstallerDetail = null;
storeState.cliInstallerRawChunks = []; storeState.cliInstallerRawChunks = [];
storeState.cliCompletedVersion = '2.1.100'; storeState.cliCompletedVersion = '2.1.100';
storeState.openCodeRuntimeStatus = null;
storeState.openCodeRuntimeStatusLoading = false;
storeState.openCodeRuntimeError = null;
storeState.bootstrapCliStatus = vi.fn().mockResolvedValue(undefined); storeState.bootstrapCliStatus = vi.fn().mockResolvedValue(undefined);
storeState.fetchCliStatus = vi.fn().mockResolvedValue(undefined); storeState.fetchCliStatus = vi.fn().mockResolvedValue(undefined);
storeState.fetchCliProviderStatus = vi.fn().mockResolvedValue(undefined); storeState.fetchCliProviderStatus = vi.fn().mockResolvedValue(undefined);
storeState.invalidateCliStatus = vi.fn().mockResolvedValue(undefined); storeState.invalidateCliStatus = vi.fn().mockResolvedValue(undefined);
storeState.installCli = vi.fn(); storeState.installCli = vi.fn();
storeState.fetchOpenCodeRuntimeStatus = vi.fn().mockResolvedValue(undefined);
storeState.installOpenCodeRuntime = vi.fn().mockResolvedValue(undefined);
storeState.invalidateOpenCodeRuntimeStatus = vi.fn().mockResolvedValue(undefined);
storeState.appConfig = { storeState.appConfig = {
general: { general: {
multimodelEnabled: true, multimodelEnabled: true,
@ -1011,9 +1023,9 @@ describe('CLI status visibility during completed install state', () => {
expect(host.textContent).toContain('Providers: 1/1 connected'); expect(host.textContent).toContain('Providers: 1/1 connected');
expect(host.textContent).toContain('Anthropic'); expect(host.textContent).toContain('Anthropic');
const collapseButton = host.querySelector( const collapseButton = host.querySelector<HTMLButtonElement>(
'button[aria-label="Collapse provider details"]' 'button[aria-label="Collapse provider details"]'
) as HTMLButtonElement | null; );
expect(collapseButton).not.toBeNull(); expect(collapseButton).not.toBeNull();
await act(async () => { await act(async () => {
@ -1106,9 +1118,9 @@ describe('CLI status visibility during completed install state', () => {
await Promise.resolve(); await Promise.resolve();
}); });
const collapseButton = firstHost.querySelector( const collapseButton = firstHost.querySelector<HTMLButtonElement>(
'button[aria-label="Collapse provider details"]' 'button[aria-label="Collapse provider details"]'
) as HTMLButtonElement | null; );
expect(collapseButton).not.toBeNull(); expect(collapseButton).not.toBeNull();
await act(async () => { await act(async () => {

View file

@ -62,6 +62,53 @@ describe('ProviderModelBadges', () => {
expect(host.textContent).toContain('Check failed'); expect(host.textContent).toContain('Check failed');
}); });
it('renders catalog badges from verbose provider metadata', () => {
const host = render(
<ProviderModelBadges
providerId="opencode"
models={['opencode/big-pickle']}
providerStatus={{
providerId: 'opencode',
authMethod: 'opencode_managed',
backend: { kind: 'opencode-cli', label: 'OpenCode CLI' },
modelCatalog: {
schemaVersion: 1,
providerId: 'opencode',
source: 'app-server',
status: 'ready',
fetchedAt: '2026-05-12T00:00:00.000Z',
staleAt: '2026-05-12T00:10:00.000Z',
defaultModelId: 'opencode/big-pickle',
defaultLaunchModel: 'opencode/big-pickle',
models: [
{
id: 'opencode/big-pickle',
launchModel: 'opencode/big-pickle',
displayName: 'opencode/big-pickle',
hidden: false,
supportedReasoningEfforts: [],
defaultReasoningEffort: null,
inputModalities: ['text'],
supportsPersonality: true,
isDefault: true,
upgrade: false,
source: 'app-server',
badgeLabel: 'Free',
},
],
diagnostics: {
configReadState: 'ready',
appServerState: 'healthy',
},
},
}}
/>
);
expect(host.textContent).toContain('big-pickle');
expect(host.textContent).toContain('Free');
});
it('collapses long model lists and expands them into a bounded scroll area', () => { it('collapses long model lists and expands them into a bounded scroll area', () => {
const models = Array.from( const models = Array.from(
{ length: 18 }, { length: 18 },

View file

@ -292,6 +292,11 @@ describe('TeamModelSelector disabled Codex models', () => {
expect(host.textContent).toContain('Unavailable in OpenCode'); expect(host.textContent).toContain('Unavailable in OpenCode');
expect(host.textContent).toContain('openai/gpt-oss-20b:free'); expect(host.textContent).toContain('openai/gpt-oss-20b:free');
expect(host.textContent).toContain('Not recommended'); expect(host.textContent).toContain('Not recommended');
const groupLabels = Array.from(
host.querySelectorAll('[data-testid="team-model-selector-opencode-group"] h4')
).map((heading) => heading.textContent ?? '');
expect(groupLabels).toContain('OpenCode');
expect(groupLabels).toContain('OpenRouter');
const buttonTexts = Array.from(host.querySelectorAll('button')).map( const buttonTexts = Array.from(host.querySelectorAll('button')).map(
(button) => button.textContent ?? '' (button) => button.textContent ?? ''
@ -579,16 +584,16 @@ describe('TeamModelSelector disabled Codex models', () => {
await Promise.resolve(); await Promise.resolve();
}); });
const modelGrid = host.querySelector( const modelGrid = host.querySelector<HTMLElement>(
'[data-testid="team-model-selector-model-grid"]' '[data-testid="team-model-selector-model-grid"]'
) as HTMLElement | null; );
expect(modelGrid).toBeTruthy(); expect(modelGrid).toBeTruthy();
expect(modelGrid?.style.maxHeight).toBe('400px'); expect(modelGrid?.style.maxHeight).toBe('400px');
expect(modelGrid?.className).toContain('overflow-y-auto'); expect(modelGrid?.className).toContain('overflow-y-auto');
const searchInput = host.querySelector( const searchInput = host.querySelector<HTMLInputElement>(
'[data-testid="team-model-selector-model-search"]' '[data-testid="team-model-selector-model-search"]'
) as HTMLInputElement | null; );
expect(searchInput).toBeTruthy(); expect(searchInput).toBeTruthy();
await act(async () => { await act(async () => {
@ -1318,7 +1323,7 @@ describe('TeamModelSelector disabled Codex models', () => {
}); });
}); });
it('renders OpenCode source badges and keeps raw model ids on selection', async () => { it('renders OpenCode source groups and keeps raw model ids on selection', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
storeState.cliStatus = { storeState.cliStatus = {
providers: [ providers: [
@ -1359,7 +1364,7 @@ describe('TeamModelSelector disabled Codex models', () => {
expect(host.textContent).toContain('OpenRouter'); expect(host.textContent).toContain('OpenRouter');
const openRouterButton = Array.from(host.querySelectorAll('button')).find((button) => const openRouterButton = Array.from(host.querySelectorAll('button')).find((button) =>
button.textContent?.includes('OpenRouter') button.textContent?.includes('moonshotai/kimi-k2')
); );
expect(openRouterButton).toBeTruthy(); expect(openRouterButton).toBeTruthy();
@ -1376,4 +1381,70 @@ describe('TeamModelSelector disabled Codex models', () => {
await Promise.resolve(); await Promise.resolve();
}); });
}); });
it('filters OpenCode model groups by selected source providers', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
storeState.cliStatus = {
providers: [
{
providerId: 'opencode',
supported: true,
authenticated: true,
detailMessage: null,
statusMessage: null,
capabilities: {
teamLaunch: true,
},
models: ['openai/gpt-5.4', 'openrouter/moonshotai/kimi-k2', 'opencode/big-pickle'],
},
],
};
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(
React.createElement(TeamModelSelector, {
providerId: 'opencode',
onProviderChange: () => undefined,
value: '',
onValueChange: () => undefined,
})
);
await Promise.resolve();
});
const filterButton = host.querySelector(
'[data-testid="team-model-selector-opencode-provider-filter"]'
);
expect(filterButton).toBeTruthy();
await act(async () => {
filterButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
await Promise.resolve();
});
const openRouterCheckbox = document.body.querySelector<HTMLElement>(
'[aria-label="Filter OpenRouter"]'
);
expect(openRouterCheckbox).toBeTruthy();
await act(async () => {
openRouterCheckbox?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
await Promise.resolve();
});
expect(host.textContent).toContain('moonshotai/kimi-k2');
expect(host.textContent).toContain('OpenRouter');
expect(host.textContent).not.toContain('GPT-5.4');
expect(host.textContent).not.toContain('OpenAI');
expect(host.textContent).not.toContain('big-pickle');
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
}); });

View file

@ -16,10 +16,7 @@ const teamState = {
], ],
tasks: [], tasks: [],
}, },
teamDataCacheByName: new Map< teamDataCacheByName: new Map<string, { members: Record<string, unknown>[]; tasks: unknown[] }>([
string,
{ members: Record<string, unknown>[]; tasks: unknown[] }
>([
[ [
'demo-team', 'demo-team',
{ {
@ -106,7 +103,10 @@ describe('GraphActivityHud', () => {
beforeEach(() => { beforeEach(() => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
buildInlineActivityEntries.mockReset(); buildInlineActivityEntries.mockReset();
vi.stubGlobal('requestAnimationFrame', vi.fn(() => 1)); vi.stubGlobal(
'requestAnimationFrame',
vi.fn(() => 1)
);
vi.stubGlobal('cancelAnimationFrame', vi.fn()); vi.stubGlobal('cancelAnimationFrame', vi.fn());
Object.defineProperty(HTMLElement.prototype, 'offsetWidth', { Object.defineProperty(HTMLElement.prototype, 'offsetWidth', {
configurable: true, configurable: true,
@ -124,6 +124,7 @@ describe('GraphActivityHud', () => {
afterEach(() => { afterEach(() => {
document.body.innerHTML = ''; document.body.innerHTML = '';
vi.useRealTimers();
vi.unstubAllGlobals(); vi.unstubAllGlobals();
if (originalOffsetWidthDescriptor) { if (originalOffsetWidthDescriptor) {
Object.defineProperty(HTMLElement.prototype, 'offsetWidth', originalOffsetWidthDescriptor); Object.defineProperty(HTMLElement.prototype, 'offsetWidth', originalOffsetWidthDescriptor);
@ -356,4 +357,138 @@ describe('GraphActivityHud', () => {
await Promise.resolve(); await Promise.resolve();
}); });
}); });
it('briefly highlights newly appeared activity cards', async () => {
vi.useFakeTimers();
const firstMessage: InboxMessage = {
from: 'team-lead',
to: 'jack',
text: 'Initial activity',
summary: 'Initial activity',
timestamp: '2026-04-13T13:36:00.000Z',
read: false,
messageId: 'msg-initial',
};
const newMessage: InboxMessage = {
from: 'team-lead',
to: 'jack',
text: 'New activity',
summary: 'New activity',
timestamp: '2026-04-13T13:37:00.000Z',
read: false,
messageId: 'msg-new',
};
const buildEntries = (items: { id: string; message: InboxMessage }[]): Map<string, unknown[]> =>
new Map([
[
'member:demo-team:jack',
items.map(({ id, message }) => ({
ownerNodeId: 'member:demo-team:jack',
graphItem: {
id,
kind: 'inbox_message',
timestamp: message.timestamp,
title: message.summary ?? '',
},
message,
})),
],
]);
buildInlineActivityEntries.mockReturnValue(
buildEntries([{ id: 'item-initial', message: firstMessage }])
);
const baseNode: GraphNode = {
id: 'member:demo-team:jack',
kind: 'member',
label: 'jack',
state: 'active',
domainRef: { kind: 'member', teamName: 'demo-team', memberName: 'jack' },
activityItems: [
{
id: 'item-initial',
kind: 'inbox_message',
timestamp: firstMessage.timestamp,
title: 'Initial activity',
},
],
activityOverflowCount: 0,
};
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
const renderHud = (node: GraphNode): void => {
root.render(
React.createElement(GraphActivityHud, {
teamName: 'demo-team',
nodes: [node],
getActivityWorldRect: () => ({
left: 40,
top: 80,
right: 336,
bottom: 372,
width: 296,
height: 292,
}),
getCameraZoom: () => 1,
worldToScreen: (x: number, y: number) => ({ x, y }),
getNodeWorldPosition: () => ({ x: 120, y: 40 }),
getViewportSize: () => ({ width: 1200, height: 800 }),
focusNodeIds: null,
})
);
};
await act(async () => {
renderHud(baseNode);
await Promise.resolve();
});
expect(host.querySelector('[data-activity-entry-id="item-initial"]')?.className).not.toContain(
'border-sky-300/70'
);
buildInlineActivityEntries.mockReturnValue(
buildEntries([
{ id: 'item-new', message: newMessage },
{ id: 'item-initial', message: firstMessage },
])
);
const updatedNode: GraphNode = {
...baseNode,
activityItems: [
{
id: 'item-new',
kind: 'inbox_message',
timestamp: newMessage.timestamp,
title: 'New activity',
},
...baseNode.activityItems!,
],
};
await act(async () => {
renderHud(updatedNode);
await Promise.resolve();
});
const newRow = host.querySelector('[data-activity-entry-id="item-new"]');
expect(newRow?.className).toContain('border-sky-300/70');
await act(async () => {
vi.advanceTimersByTime(1_000);
await Promise.resolve();
});
expect(newRow?.className).not.toContain('border-sky-300/70');
await act(async () => {
root.unmount();
await Promise.resolve();
});
vi.useRealTimers();
});
}); });

View file

@ -649,7 +649,7 @@ describe('GraphMemberLogPreviewHud', () => {
}); });
}); });
it('shows loading for empty previews while preserving unsupported provider text', async () => { it('keeps loaded empty previews honest during background loading', async () => {
const codexNode: GraphNode = { const codexNode: GraphNode = {
id: 'member:alpha-team:codex-dev', id: 'member:alpha-team:codex-dev',
kind: 'member', kind: 'member',
@ -724,8 +724,56 @@ describe('GraphMemberLogPreviewHud', () => {
}); });
expect(host.textContent).toContain('Unsupported provider'); expect(host.textContent).toContain('Unsupported provider');
expect(host.textContent).toContain('No recent logs');
expect(host.textContent).not.toContain('Loading logs');
act(() => {
root.unmount();
});
});
it('shows loading only before a member preview has been loaded', async () => {
const quietNode: GraphNode = {
id: 'member:alpha-team:quiet-dev',
kind: 'member',
label: 'quiet-dev',
state: 'idle',
domainRef: { kind: 'member', teamName: 'alpha-team', memberName: 'quiet-dev' },
};
mockedLoading = true;
mockedPreviewsByMember = new Map<string, MemberLogPreviewMember>();
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(
<GraphMemberLogPreviewHud
teamName="alpha-team"
nodes={[quietNode]}
getLogWorldRect={() => ({
left: 40,
top: 80,
right: 300,
bottom: 372,
width: 260,
height: 292,
})}
getCameraZoom={() => 1}
worldToScreen={(x, y) => ({ x, y })}
getViewportSize={() => ({ width: 1200, height: 800 })}
focusNodeIds={null}
/>
);
await Promise.resolve();
});
expect(host.textContent).toContain('Loading logs'); expect(host.textContent).toContain('Loading logs');
expect(host.textContent).not.toContain('No recent logs'); expect(host.textContent).not.toContain('No recent logs');
const loadingButton = host.querySelector('button[aria-busy="true"]');
expect(loadingButton?.className).toContain('flex-1');
expect(loadingButton?.querySelectorAll('.animate-pulse')).toHaveLength(3);
act(() => { act(() => {
root.unmount(); root.unmount();

View file

@ -1,5 +1,9 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import {
KANBAN_ZONE,
TASK_PILL,
} from '../../../../packages/agent-graph/src/constants/canvas-constants';
import { KanbanLayoutEngine } from '../../../../packages/agent-graph/src/layout/kanbanLayout'; import { KanbanLayoutEngine } from '../../../../packages/agent-graph/src/layout/kanbanLayout';
import type { GraphNode } from '@claude-teams/agent-graph'; import type { GraphNode } from '@claude-teams/agent-graph';
@ -37,17 +41,17 @@ describe('KanbanLayoutEngine', () => {
KanbanLayoutEngine.layout([lead, orphanTask], { KanbanLayoutEngine.layout([lead, orphanTask], {
unassignedTaskRect: { unassignedTaskRect: {
left: -80, left: -TASK_PILL.width / 2,
top: 120, top: 120,
right: 80, right: TASK_PILL.width / 2,
bottom: 540, bottom: 540,
width: 160, width: TASK_PILL.width,
height: 420, height: 420,
}, },
}); });
expect(orphanTask.x).toBe(0); expect(orphanTask.x).toBe(0);
expect(orphanTask.y).toBe(120); expect(orphanTask.y).toBe(120 + KANBAN_ZONE.headerHeight);
expect(KanbanLayoutEngine.zones.some((zone) => zone.ownerId === '__unassigned__')).toBe(true); expect(KanbanLayoutEngine.zones.some((zone) => zone.ownerId === '__unassigned__')).toBe(true);
}); });
}); });

View file

@ -11,6 +11,12 @@ vi.mock('@renderer/api', () => ({
install: vi.fn(), install: vi.fn(),
onProgress: vi.fn(() => vi.fn()), onProgress: vi.fn(() => vi.fn()),
}, },
openCodeRuntime: {
getStatus: vi.fn(),
install: vi.fn(),
invalidateStatus: vi.fn(),
onProgress: vi.fn(() => vi.fn()),
},
// Minimal stubs for other api methods referenced by store slices // Minimal stubs for other api methods referenced by store slices
getProjects: vi.fn(() => Promise.resolve([])), getProjects: vi.fn(() => Promise.resolve([])),
getSessions: vi.fn(() => Promise.resolve([])), getSessions: vi.fn(() => Promise.resolve([])),
@ -137,6 +143,9 @@ describe('cliInstallerSlice', () => {
cliDownloadTotal: 0, cliDownloadTotal: 0,
cliInstallerError: null, cliInstallerError: null,
cliCompletedVersion: null, cliCompletedVersion: null,
openCodeRuntimeStatus: null,
openCodeRuntimeStatusLoading: false,
openCodeRuntimeError: null,
}); });
}); });
@ -688,9 +697,10 @@ describe('cliInstallerSlice', () => {
], ],
}; };
vi.mocked(api.cliInstaller.getStatus).mockResolvedValue(mockStatus); vi.mocked(api.cliInstaller.getStatus).mockResolvedValue(mockStatus);
vi.mocked(api.cliInstaller.getProviderStatus).mockImplementation(async (providerId) => { vi.mocked(api.cliInstaller.getProviderStatus).mockImplementation((providerId) => {
if (providerId === 'opencode') { if (providerId === 'opencode') {
return createMultimodelProvider({ return Promise.resolve(
createMultimodelProvider({
providerId: 'opencode', providerId: 'opencode',
displayName: 'OpenCode', displayName: 'OpenCode',
authenticated: true, authenticated: true,
@ -699,9 +709,10 @@ describe('cliInstallerSlice', () => {
models: ['opencode/minimax-m2.5-free'], models: ['opencode/minimax-m2.5-free'],
canLoginFromUi: false, canLoginFromUi: false,
backend: { kind: 'opencode-cli', label: 'OpenCode CLI' }, backend: { kind: 'opencode-cli', label: 'OpenCode CLI' },
}); })
);
} }
throw new Error(`Unexpected provider status request for ${providerId}`); return Promise.reject(new Error(`Unexpected provider status request for ${providerId}`));
}); });
await useStore.getState().bootstrapCliStatus({ multimodelEnabled: true }); await useStore.getState().bootstrapCliStatus({ multimodelEnabled: true });

View file

@ -1491,6 +1491,446 @@ describe('teamSlice actions', () => {
expect(store.getState().selectedTeamData).toEqual(fullSnapshot); expect(store.getState().selectedTeamData).toEqual(fullSnapshot);
}); });
it('does not let a late thin selectTeam snapshot clear members loaded by an earlier full refresh', async () => {
const store = createSliceStore();
const thinRequest = createDeferredPromise<ReturnType<typeof createTeamSnapshot>>();
const fullRequest = createDeferredPromise<ReturnType<typeof createTeamSnapshot>>();
const thinSnapshot = createTeamSnapshot({
config: { name: 'Thin Team' },
members: [],
});
const fullSnapshot = createTeamSnapshot({
config: { name: 'Full Team' },
members: [{ name: 'alice', role: 'developer', currentTaskId: null, gitBranch: 'feature/a' }],
});
store.setState({
teamByName: {
'my-team': {
teamName: 'my-team',
displayName: 'My Team',
description: '',
memberCount: 1,
members: [{ name: 'alice', role: 'developer' }],
taskCount: 0,
lastActivity: null,
},
},
});
hoisted.getData
.mockImplementationOnce(() => thinRequest.promise)
.mockImplementationOnce(() => fullRequest.promise);
const selectPromise = store.getState().selectTeam('my-team');
await flushMicrotasks();
const fullPromise = store.getState().refreshTeamData('my-team', { withDedup: false });
fullRequest.resolve(fullSnapshot);
await fullPromise;
expect(store.getState().selectedTeamData).toEqual(fullSnapshot);
thinRequest.resolve(thinSnapshot);
await selectPromise;
expect(store.getState().selectedTeamData).toEqual(fullSnapshot);
expect(store.getState().teamDataCacheByName['my-team']).toEqual(fullSnapshot);
expect(selectResolvedMembersForTeamName(store.getState(), 'my-team')).toHaveLength(1);
});
it('preserves an earlier full refresh even when the cached baseline had the same member names', async () => {
const store = createSliceStore();
const thinRequest = createDeferredPromise<ReturnType<typeof createTeamSnapshot>>();
const fullRequest = createDeferredPromise<ReturnType<typeof createTeamSnapshot>>();
const cachedSnapshot = createTeamSnapshot({
config: { name: 'Cached Team' },
members: [{ name: 'alice', role: 'developer', currentTaskId: null }],
});
const thinSnapshot = createTeamSnapshot({
config: { name: 'Thin Team' },
members: [],
});
const fullSnapshot = createTeamSnapshot({
config: { name: 'Full Team' },
members: [{ name: 'alice', role: 'developer', currentTaskId: null, gitBranch: 'feature/a' }],
});
store.setState({
teamByName: {
'my-team': {
teamName: 'my-team',
displayName: 'My Team',
description: '',
memberCount: 1,
members: [{ name: 'alice', role: 'developer' }],
taskCount: 0,
lastActivity: null,
},
},
teamDataCacheByName: {
'my-team': cachedSnapshot,
},
});
hoisted.getData
.mockImplementationOnce(() => thinRequest.promise)
.mockImplementationOnce(() => fullRequest.promise);
const selectPromise = store.getState().selectTeam('my-team');
await flushMicrotasks();
const fullPromise = store.getState().refreshTeamData('my-team', { withDedup: false });
fullRequest.resolve(fullSnapshot);
await fullPromise;
expect(store.getState().selectedTeamData).toEqual(fullSnapshot);
thinRequest.resolve(thinSnapshot);
await selectPromise;
expect(store.getState().selectedTeamData).toEqual(fullSnapshot);
expect(store.getState().teamDataCacheByName['my-team']).toEqual(fullSnapshot);
});
it('does not let an empty selectTeam snapshot clear an already cached member roster', async () => {
const store = createSliceStore();
const cachedSnapshot = createTeamSnapshot({
config: { name: 'Cached Team' },
members: [{ name: 'alice', role: 'developer', currentTaskId: null, gitBranch: 'feature/a' }],
});
const thinSnapshot = createTeamSnapshot({
config: { name: 'Thin Team' },
members: [],
});
store.setState({
teamByName: {
'my-team': {
teamName: 'my-team',
displayName: 'My Team',
description: '',
memberCount: 1,
members: [{ name: 'alice', role: 'developer' }],
taskCount: 0,
lastActivity: null,
},
},
teamDataCacheByName: {
'my-team': cachedSnapshot,
},
});
hoisted.getData.mockResolvedValueOnce(thinSnapshot);
await store.getState().selectTeam('my-team');
expect(store.getState().selectedTeamData).toEqual(cachedSnapshot);
expect(store.getState().teamDataCacheByName['my-team']).toEqual(cachedSnapshot);
expect(selectResolvedMembersForTeamName(store.getState(), 'my-team')).toHaveLength(1);
});
it('does not treat a lead-only selectTeam snapshot as a confirmed teammate roster', async () => {
const store = createSliceStore();
const cachedSnapshot = createTeamSnapshot({
config: { name: 'Cached Team' },
members: [
{ name: 'team-lead', agentType: 'team-lead', currentTaskId: null },
{ name: 'alice', role: 'developer', currentTaskId: null },
],
});
const leadOnlySnapshot = createTeamSnapshot({
config: { name: 'Lead Only Thin Team' },
members: [{ name: 'team-lead', agentType: 'team-lead', currentTaskId: null }],
});
store.setState({
teamByName: {
'my-team': {
teamName: 'my-team',
displayName: 'My Team',
description: '',
memberCount: 1,
members: [{ name: 'alice', role: 'developer' }],
taskCount: 0,
lastActivity: null,
},
},
teamDataCacheByName: {
'my-team': cachedSnapshot,
},
});
hoisted.getData.mockResolvedValueOnce(leadOnlySnapshot);
await store.getState().selectTeam('my-team');
expect(store.getState().selectedTeamData).toEqual(cachedSnapshot);
expect(
selectResolvedMembersForTeamName(store.getState(), 'my-team').map((m) => m.name)
).toEqual(['team-lead', 'alice']);
});
it('uses summary fallback instead of a stale cached roster when names no longer match', async () => {
const store = createSliceStore();
const cachedSnapshot = createTeamSnapshot({
config: { name: 'Cached Team' },
members: [{ name: 'alice', role: 'developer', currentTaskId: null }],
});
const emptySnapshot = createTeamSnapshot({
config: { name: 'Thin Team' },
members: [],
});
store.setState({
teamByName: {
'my-team': {
teamName: 'my-team',
displayName: 'My Team',
description: '',
memberCount: 1,
members: [{ name: 'bob', role: 'reviewer' }],
taskCount: 0,
lastActivity: null,
},
},
teamDataCacheByName: {
'my-team': cachedSnapshot,
},
});
hoisted.getData.mockResolvedValueOnce(emptySnapshot);
await store.getState().selectTeam('my-team');
expect(store.getState().selectedTeamData).toEqual(emptySnapshot);
expect(selectResolvedMembersForTeamName(store.getState(), 'my-team')).toMatchObject([
{ name: 'bob', role: 'reviewer' },
]);
});
it('commits an empty selectTeam snapshot when the team summary is already solo', async () => {
const store = createSliceStore();
const cachedSnapshot = createTeamSnapshot({
config: { name: 'Cached Team' },
members: [{ name: 'alice', role: 'developer', currentTaskId: null }],
});
const soloSnapshot = createTeamSnapshot({
config: { name: 'Solo Team' },
members: [],
});
store.setState({
teamByName: {
'my-team': {
teamName: 'my-team',
displayName: 'Solo Team',
description: '',
memberCount: 0,
taskCount: 0,
lastActivity: null,
},
},
teamDataCacheByName: {
'my-team': cachedSnapshot,
},
});
hoisted.getData.mockResolvedValueOnce(soloSnapshot);
await store.getState().selectTeam('my-team');
expect(store.getState().selectedTeamData).toEqual(soloSnapshot);
expect(store.getState().teamDataCacheByName['my-team']).toEqual(soloSnapshot);
expect(selectResolvedMembersForTeamName(store.getState(), 'my-team')).toHaveLength(0);
});
it('commits an empty cached-team selectTeam snapshot when no summary confirms teammates', async () => {
const store = createSliceStore();
const cachedSnapshot = createTeamSnapshot({
config: { name: 'Cached Team' },
members: [{ name: 'alice', role: 'developer', currentTaskId: null }],
});
const emptySnapshot = createTeamSnapshot({
config: { name: 'Empty Team' },
members: [],
});
store.setState({
teamDataCacheByName: {
'my-team': cachedSnapshot,
},
teamByName: {},
});
hoisted.getData.mockResolvedValueOnce(emptySnapshot);
await store.getState().selectTeam('my-team');
expect(store.getState().selectedTeamData).toEqual(emptySnapshot);
expect(store.getState().teamDataCacheByName['my-team']).toEqual(emptySnapshot);
});
it('does not preserve a cached roster from a summary count without member names', async () => {
const store = createSliceStore();
const cachedSnapshot = createTeamSnapshot({
config: { name: 'Cached Team' },
members: [{ name: 'alice', role: 'developer', currentTaskId: null }],
});
const emptySnapshot = createTeamSnapshot({
config: { name: 'Empty Team' },
members: [],
});
store.setState({
teamDataCacheByName: {
'my-team': cachedSnapshot,
},
teamByName: {
'my-team': {
teamName: 'my-team',
displayName: 'My Team',
description: '',
memberCount: 1,
taskCount: 0,
lastActivity: null,
},
},
});
hoisted.getData.mockResolvedValueOnce(emptySnapshot);
await store.getState().selectTeam('my-team');
expect(store.getState().selectedTeamData).toEqual(emptySnapshot);
expect(selectResolvedMembersForTeamName(store.getState(), 'my-team')).toHaveLength(0);
});
it('commits a late selectTeam snapshot that explicitly marks members as removed', async () => {
const store = createSliceStore();
const selectRequest = createDeferredPromise<ReturnType<typeof createTeamSnapshot>>();
const activeSnapshot = createTeamSnapshot({
members: [{ name: 'alice', role: 'developer', currentTaskId: null }],
});
const removedSnapshot = createTeamSnapshot({
members: [
{ name: 'alice', role: 'developer', currentTaskId: null, removedAt: 1710000000000 },
],
});
hoisted.getData.mockImplementationOnce(() => selectRequest.promise);
const selectPromise = store.getState().selectTeam('my-team');
await flushMicrotasks();
store.setState({
selectedTeamName: 'my-team',
selectedTeamData: activeSnapshot,
teamDataCacheByName: {
'my-team': activeSnapshot,
},
});
selectRequest.resolve(removedSnapshot);
await selectPromise;
expect(store.getState().selectedTeamData).toEqual(removedSnapshot);
expect(store.getState().teamDataCacheByName['my-team']).toEqual(removedSnapshot);
});
it('still commits a late selectTeam snapshot when concurrent local state only changed tasks', async () => {
const store = createSliceStore();
const selectRequest = createDeferredPromise<ReturnType<typeof createTeamSnapshot>>();
const previousSnapshot = createTeamSnapshot({
members: [{ name: 'alice', role: 'developer', currentTaskId: null }],
tasks: [{ id: 'task-1', subject: 'Old task', status: 'pending', owner: 'alice' }],
});
const locallyPatchedSnapshot = createTeamSnapshot({
members: [{ name: 'alice', role: 'developer', currentTaskId: null }],
tasks: [{ id: 'task-1', subject: 'Old task', status: 'pending', owner: 'alice' }],
});
const incomingSnapshot = createTeamSnapshot({
config: { name: 'Server Team' },
members: [
{ name: 'alice', role: 'developer', currentTaskId: null },
{ name: 'bob', role: 'reviewer', currentTaskId: null },
],
tasks: [{ id: 'task-2', subject: 'Server task', status: 'pending', owner: 'bob' }],
});
store.setState({
selectedTeamName: 'my-team',
selectedTeamData: previousSnapshot,
teamDataCacheByName: {
'my-team': previousSnapshot,
},
});
hoisted.getData.mockImplementationOnce(() => selectRequest.promise);
const selectPromise = store.getState().selectTeam('my-team');
await flushMicrotasks();
store.setState({
selectedTeamData: locallyPatchedSnapshot,
teamDataCacheByName: {
'my-team': locallyPatchedSnapshot,
},
});
selectRequest.resolve(incomingSnapshot);
await selectPromise;
expect(store.getState().selectedTeamData).toMatchObject({
config: { name: 'Server Team' },
members: [{ name: 'alice' }, { name: 'bob' }],
});
});
it('does not preserve a stale roster when concurrent local state only changed tasks', async () => {
const store = createSliceStore();
const selectRequest = createDeferredPromise<ReturnType<typeof createTeamSnapshot>>();
const previousSnapshot = createTeamSnapshot({
members: [{ name: 'alice', role: 'developer', currentTaskId: null }],
tasks: [{ id: 'task-1', subject: 'Old task', status: 'pending', owner: 'alice' }],
});
const locallyPatchedSnapshot = createTeamSnapshot({
members: [{ name: 'alice', role: 'developer', currentTaskId: null }],
tasks: [{ id: 'task-1', subject: 'Locally changed task', status: 'pending', owner: 'alice' }],
});
const leadOnlySnapshot = createTeamSnapshot({
config: { name: 'Solo Team' },
members: [{ name: 'team-lead', agentType: 'team-lead', currentTaskId: null }],
tasks: [],
});
store.setState({
selectedTeamName: 'my-team',
selectedTeamData: previousSnapshot,
teamDataCacheByName: {
'my-team': previousSnapshot,
},
teamByName: {
'my-team': {
teamName: 'my-team',
displayName: 'Solo Team',
description: '',
memberCount: 0,
taskCount: 0,
lastActivity: null,
},
},
});
hoisted.getData.mockImplementationOnce(() => selectRequest.promise);
const selectPromise = store.getState().selectTeam('my-team');
await flushMicrotasks();
store.setState({
selectedTeamData: locallyPatchedSnapshot,
teamDataCacheByName: {
'my-team': locallyPatchedSnapshot,
},
});
selectRequest.resolve(leadOnlySnapshot);
await selectPromise;
expect(store.getState().selectedTeamData).toEqual(leadOnlySnapshot);
expect(
selectResolvedMembersForTeamName(store.getState(), 'my-team').map((m) => m.name)
).toEqual(['team-lead']);
});
it('keeps one queued full refresh for repeated fanout while thin selectTeam is pending', async () => { it('keeps one queued full refresh for repeated fanout while thin selectTeam is pending', async () => {
vi.useFakeTimers(); vi.useFakeTimers();
stubAnimationFrameWithTimer(); stubAnimationFrameWithTimer();
@ -2531,6 +2971,112 @@ describe('teamSlice actions', () => {
}); });
}); });
it('falls back to team summary roster when detail snapshot temporarily has no members', () => {
const store = createSliceStore();
const partialSnapshot = createTeamSnapshot({
config: {
name: 'My Team',
projectPath: '/repo',
},
members: [],
tasks: [
{
id: 'task-1',
subject: 'Build',
status: 'in_progress',
owner: 'alice',
},
],
});
store.setState({
selectedTeamName: 'my-team',
selectedTeamData: partialSnapshot,
teamDataCacheByName: {
'my-team': partialSnapshot,
},
teamByName: {
'my-team': {
teamName: 'my-team',
displayName: 'My Team',
description: '',
memberCount: 2,
taskCount: 1,
lastActivity: null,
leadName: 'team-lead',
leadColor: 'purple',
members: [
{ name: 'alice', role: 'developer', color: 'blue' },
{ name: 'bob', role: 'reviewer', color: 'green' },
],
},
},
memberActivityMetaByTeam: {},
});
const members = selectResolvedMembersForTeamName(store.getState(), 'my-team');
expect(members.map((member) => member.name)).toEqual(['team-lead', 'alice', 'bob']);
expect(members.find((member) => member.name === 'alice')).toMatchObject({
role: 'developer',
currentTaskId: 'task-1',
taskCount: 1,
});
expect(selectResolvedMemberForTeamName(store.getState(), 'my-team', 'bob')).toMatchObject({
name: 'bob',
role: 'reviewer',
});
});
it('falls back to team summary roster when detail snapshot only has the synthetic lead', () => {
const store = createSliceStore();
const leadOnlySnapshot = createTeamSnapshot({
config: {
name: 'My Team',
projectPath: '/repo',
},
members: [
{
name: 'team-lead',
agentType: 'team-lead',
currentTaskId: null,
role: 'Lead from detail',
color: 'purple',
},
],
tasks: [],
});
store.setState({
selectedTeamName: 'my-team',
selectedTeamData: leadOnlySnapshot,
teamDataCacheByName: {
'my-team': leadOnlySnapshot,
},
teamByName: {
'my-team': {
teamName: 'my-team',
displayName: 'My Team',
description: '',
memberCount: 1,
taskCount: 0,
lastActivity: null,
members: [{ name: 'alice', role: 'developer', color: 'blue' }],
},
},
memberActivityMetaByTeam: {},
});
const members = selectResolvedMembersForTeamName(store.getState(), 'my-team');
expect(members.map((m) => m.name)).toEqual(['team-lead', 'alice']);
expect(members[0]).toMatchObject({
name: 'team-lead',
role: 'Lead from detail',
color: 'purple',
});
});
it('memoizes team-scoped member messages selectors over the merged message feed', () => { it('memoizes team-scoped member messages selectors over the merged message feed', () => {
const store = createSliceStore(); const store = createSliceStore();
store.setState({ store.setState({

View file

@ -842,6 +842,28 @@ describe('memberHelpers spawn-aware presence', () => {
expect(title).not.toContain('runtime_bootstrap_checkin'); expect(title).not.toContain('runtime_bootstrap_checkin');
}); });
it('formats unknown OpenCode bridge outcome timeouts as delivery advisory text', () => {
const advisory = {
kind: 'api_error' as const,
observedAt: '2026-04-07T09:00:00.000Z',
reasonCode: 'backend_error' as const,
message: 'opencode_prompt_acceptance_unknown_after_bridge_timeout',
};
expect(getMemberRuntimeAdvisoryLabel(advisory, 'opencode')).toBe(
'OpenCode delivery error'
);
const title = getMemberRuntimeAdvisoryTitle(advisory, 'opencode');
expect(title).toContain('OpenCode runtime delivery error.');
expect(title).toContain(
'OpenCode bridge outcome unknown after timeout, retrying/observing.'
);
expect(title).not.toContain('Network or connectivity error');
expect(title).not.toContain('opencode_prompt_acceptance_unknown_after_bridge_timeout');
});
it('formats non-visible tool progress advisory reasons before showing them in titles', () => { it('formats non-visible tool progress advisory reasons before showing them in titles', () => {
const title = getMemberRuntimeAdvisoryTitle( const title = getMemberRuntimeAdvisoryTitle(
{ {

View file

@ -127,6 +127,30 @@ describe('openCodeRuntimeDeliveryDiagnostics', () => {
}); });
}); });
it('surfaces unknown OpenCode bridge outcome as observe/retry state', () => {
const diagnostics = buildOpenCodeRuntimeDeliveryDiagnostics({
deliveredToInbox: true,
messageId: 'msg-bridge-unknown',
runtimeDelivery: {
providerId: 'opencode',
attempted: true,
delivered: false,
responsePending: false,
responseState: 'responded_non_visible_tool',
ledgerStatus: 'failed_terminal',
reason: 'opencode_prompt_acceptance_unknown_after_bridge_timeout',
diagnostics: ['opencode_prompt_acceptance_unknown_after_bridge_timeout'],
},
});
expect(diagnostics.warning).toBe(
'OpenCode runtime delivery failed. Message was saved to inbox, but live delivery did not complete. Reason: OpenCode bridge outcome unknown after timeout, retrying/observing.'
);
expect(diagnostics.debugDetails).toMatchObject({
reason: 'opencode_prompt_acceptance_unknown_after_bridge_timeout',
});
});
it('surfaces missing visible reply proof as a readable failure', () => { it('surfaces missing visible reply proof as a readable failure', () => {
const diagnostics = buildOpenCodeRuntimeDeliveryDiagnostics({ const diagnostics = buildOpenCodeRuntimeDeliveryDiagnostics({
deliveredToInbox: true, deliveredToInbox: true,

View file

@ -22,6 +22,6 @@
}, },
"types": ["node", "vitest/globals"] "types": ["node", "vitest/globals"]
}, },
"include": ["src/**/*", "test/**/*"], "include": ["src/**/*", "test/**/*", "scripts/team-changes-real-data-smoke.ts"],
"exclude": ["node_modules", "dist", "dist-electron"] "exclude": ["node_modules", "dist", "dist-electron"]
} }