feat(opencode): improve runtime delivery diagnostics
This commit is contained in:
parent
894bee97e2
commit
3f2b807bbc
74 changed files with 6437 additions and 642 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -52,3 +52,6 @@ remotion/*
|
|||
.board-task-log-freshness/
|
||||
|
||||
.serena/
|
||||
|
||||
# Local release operator notes
|
||||
/ORCHESTRATOR_RELEASE_RUNBOOK.local.md
|
||||
|
|
|
|||
|
|
@ -183,13 +183,18 @@ export default defineConfig({
|
|||
provider: "local",
|
||||
options: {
|
||||
translations: {
|
||||
button: "Search...",
|
||||
buttonAriaLabel: "Search documentation",
|
||||
noResultsText: "No results found",
|
||||
suggestedQueryText: "Try searching for",
|
||||
reportMissing: "Found a problem? Create an issue",
|
||||
reportMissingText: "Report missing result",
|
||||
reportMissingLink: "https://github.com/777genius/agent-teams-ai/issues/new"
|
||||
button: {
|
||||
buttonText: "Search...",
|
||||
buttonAriaLabel: "Search documentation"
|
||||
},
|
||||
modal: {
|
||||
noResultsText: "No results found",
|
||||
footer: {
|
||||
selectText: "to select",
|
||||
navigateText: "to navigate",
|
||||
closeText: "to close"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
@ -214,7 +219,6 @@ export default defineConfig({
|
|||
lang: "en-US",
|
||||
themeConfig: {
|
||||
nav: rootNav,
|
||||
sidebar: rootGuide,
|
||||
docFooter: {
|
||||
prev: "Previous",
|
||||
next: "Next"
|
||||
|
|
@ -228,7 +232,6 @@ export default defineConfig({
|
|||
description: "Документация Agent Teams, локального desktop-приложения для оркестрации AI-агентов.",
|
||||
themeConfig: {
|
||||
nav: ruNav,
|
||||
sidebar: ruGuide,
|
||||
outline: {
|
||||
level: [2, 3],
|
||||
label: "На этой странице"
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ Agent Teams is distributed as a desktop app for macOS, Windows, and Linux.
|
|||
|
||||
## 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 Intel: `.dmg`
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ This guide gets you from a fresh install to a running team in a few minutes.
|
|||
|
||||
## 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
|
||||
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.
|
||||
|
||||
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
|
||||
claude --version
|
||||
|
|
|
|||
|
|
@ -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
|
||||
description: Configure Claude Code, Codex, or OpenCode runtimes. Covers auth, provider access, multimodel mode, and prelaunch checks.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
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
|
||||
|
||||
### `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`.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
---
|
||||
|
||||
---
|
||||
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
|
||||
|
||||
## Is Agent Teams free?
|
||||
|
|
|
|||
|
|
@ -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
|
||||
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
|
||||
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
| 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. |
|
||||
| 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. |
|
||||
|
|
|
|||
11
package.json
11
package.json
|
|
@ -29,6 +29,7 @@
|
|||
"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-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",
|
||||
"build": "node --max-old-space-size=8192 ./node_modules/electron-vite/bin/electron-vite.js build",
|
||||
"dist": "electron-builder --mac --win --linux",
|
||||
|
|
@ -110,7 +111,7 @@
|
|||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@fastify/cors": "^11.2.0",
|
||||
"@fastify/static": "^9.0.0",
|
||||
"@fastify/static": "^9.1.3",
|
||||
"@floating-ui/dom": "^1.7.6",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
|
|
@ -126,7 +127,7 @@
|
|||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@sentry/electron": "^7.10.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/markdown": "^3.20.4",
|
||||
"@tiptap/pm": "^3.20.4",
|
||||
|
|
@ -146,7 +147,7 @@
|
|||
"diff": "^8.0.3",
|
||||
"dompurify": "^3.3.1",
|
||||
"electron-updater": "^6.7.3",
|
||||
"fastify": "^5.7.4",
|
||||
"fastify": "^5.8.5",
|
||||
"highlight.js": "^11.11.1",
|
||||
"idb-keyval": "^6.2.2",
|
||||
"isbinaryfile": "^6.0.0",
|
||||
|
|
@ -169,7 +170,7 @@
|
|||
"rehype-sanitize": "^6.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"remark-parse": "^11.0.0",
|
||||
"simple-git": "^3.32.3",
|
||||
"simple-git": "^3.36.0",
|
||||
"ssh-config": "^5.0.4",
|
||||
"ssh2": "^1.17.0",
|
||||
"strip-markdown": "^6.0.0",
|
||||
|
|
@ -196,7 +197,7 @@
|
|||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"@vitest/coverage-v8": "^3.1.4",
|
||||
"autoprefixer": "^10.4.17",
|
||||
"electron": "^40.3.0",
|
||||
"electron": "^40.10.0",
|
||||
"electron-builder": "^26.8.1",
|
||||
"electron-vite": "^5.0.0",
|
||||
"eslint": "^9.39.2",
|
||||
|
|
|
|||
|
|
@ -11,6 +11,10 @@ import { drawPillShell, drawPillStackLayer } from './draw-pill-shell';
|
|||
import { hexWithAlpha } from './render-cache';
|
||||
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.
|
||||
*/
|
||||
|
|
@ -159,9 +163,9 @@ function drawTaskPill(
|
|||
const hasReviewChip =
|
||||
node.reviewState !== 'approved' &&
|
||||
(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);
|
||||
ctx.fillText(subject, textX, -4);
|
||||
ctx.fillText(subject, textX, -12);
|
||||
}
|
||||
|
||||
// Display ID (secondary — small)
|
||||
|
|
@ -170,11 +174,11 @@ function drawTaskPill(
|
|||
ctx.textAlign = 'left';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillStyle = COLORS.textDim;
|
||||
ctx.fillText(displayId, -halfW + 10, 8);
|
||||
ctx.fillText(displayId, -halfW + 10, 12);
|
||||
|
||||
// Approved badge: checkmark at right side
|
||||
if (node.reviewState === 'approved') {
|
||||
ctx.font = 'bold 11px sans-serif';
|
||||
ctx.font = 'bold 13px sans-serif';
|
||||
ctx.textAlign = 'right';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillStyle = COLORS.reviewApproved;
|
||||
|
|
@ -367,11 +371,11 @@ function drawOverflowStack(
|
|||
ctx.textAlign = 'left';
|
||||
ctx.textBaseline = 'middle';
|
||||
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.fillText('more tasks', -halfW + 12, 10);
|
||||
ctx.fillText('more tasks', -halfW + 14, 12);
|
||||
}
|
||||
|
||||
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.
|
||||
*/
|
||||
|
|
@ -425,12 +457,12 @@ export function drawColumnHeaders(
|
|||
for (const zone of zones) {
|
||||
// Section header for unassigned tasks — larger, centered above all columns
|
||||
if (zone.ownerId === '__unassigned__') {
|
||||
ctx.font = 'bold 10px monospace';
|
||||
ctx.font = KANBAN_HEADER_FONT;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'bottom';
|
||||
ctx.fillStyle = hexWithAlpha(COLORS.taskPending, 0.5);
|
||||
const labelY = (zone.headers[0]?.y ?? zone.ownerY + 60) - 16;
|
||||
ctx.fillText('Unassigned', zone.ownerX, labelY);
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillStyle = hexWithAlpha(COLORS.taskPending, KANBAN_HEADER_ALPHA);
|
||||
const labelY = (zone.headers[0]?.y ?? zone.ownerY + 60) + 10;
|
||||
drawCenteredSpacedText(ctx, 'Unassigned', zone.ownerX, labelY, KANBAN_HEADER_LETTER_SPACING);
|
||||
|
||||
// Overflow badge
|
||||
for (const header of zone.headers) {
|
||||
|
|
@ -446,16 +478,22 @@ export function drawColumnHeaders(
|
|||
}
|
||||
|
||||
for (const header of zone.headers) {
|
||||
ctx.font = 'bold 8px monospace';
|
||||
ctx.font = KANBAN_HEADER_FONT;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'bottom';
|
||||
ctx.fillStyle = hexWithAlpha(header.color, 0.6);
|
||||
ctx.fillText(header.label, header.x, header.y - 2);
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillStyle = hexWithAlpha(header.color, KANBAN_HEADER_ALPHA);
|
||||
drawCenteredSpacedText(
|
||||
ctx,
|
||||
header.label,
|
||||
header.x,
|
||||
header.y + 10,
|
||||
KANBAN_HEADER_LETTER_SPACING
|
||||
);
|
||||
|
||||
// Overflow badge: "+N more"
|
||||
if (header.overflowCount > 0) {
|
||||
const badgeText = `+${header.overflowCount} more`;
|
||||
ctx.font = '7px monospace';
|
||||
ctx.font = '10px monospace';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'top';
|
||||
ctx.fillStyle = hexWithAlpha(header.color, 0.45);
|
||||
|
|
|
|||
|
|
@ -70,19 +70,19 @@ export const NODE = {
|
|||
// ─── Task pill dimensions ───────────────────────────────────────────────────
|
||||
|
||||
export const TASK_PILL = {
|
||||
width: 160,
|
||||
height: 36,
|
||||
borderRadius: 6,
|
||||
width: 260,
|
||||
height: 72,
|
||||
borderRadius: 8,
|
||||
statusDotRadius: 4,
|
||||
statusDotX: 12,
|
||||
/** Font size for display ID */
|
||||
idFontSize: 9,
|
||||
/** Font size for subject text */
|
||||
subjectFontSize: 7,
|
||||
/** Font size for the task title */
|
||||
idFontSize: 16.5,
|
||||
/** Font size for the display ID */
|
||||
subjectFontSize: 10,
|
||||
/** Max chars for subject before truncation */
|
||||
subjectMaxChars: 18,
|
||||
subjectMaxChars: 32,
|
||||
/** X offset for text content */
|
||||
textOffsetX: 20,
|
||||
textOffsetX: 18,
|
||||
} as const;
|
||||
|
||||
// ─── Agent drawing constants ────────────────────────────────────────────────
|
||||
|
|
@ -259,12 +259,12 @@ export const BACKGROUND = {
|
|||
// ─── Kanban zone layout ─────────────────────────────────────────────────────
|
||||
|
||||
export const KANBAN_ZONE = {
|
||||
/** Column width: pill (160) + gap (20) */
|
||||
columnWidth: 180,
|
||||
/** Row height: pill (36) + gap (10) */
|
||||
rowHeight: 46,
|
||||
/** Space reserved for column header label */
|
||||
headerHeight: 20,
|
||||
/** Column width: task card (260) + gap (20) */
|
||||
columnWidth: 280,
|
||||
/** Row height: task card (72) + gap (8) */
|
||||
rowHeight: 80,
|
||||
/** Task center offset from band top: header (20) + gap (4) + half card */
|
||||
headerHeight: 60,
|
||||
/** Zone starts this far below member node center */
|
||||
offsetY: 70,
|
||||
/** Column sequence: pending → wip → done → review → approved */
|
||||
|
|
|
|||
|
|
@ -217,8 +217,16 @@ export class KanbanLayoutEngine {
|
|||
for (const [rowIdx, task] of col.tasks.entries()) {
|
||||
const targetX = colX;
|
||||
const targetY = baseY + headerHeight + rowIdx * rowHeight;
|
||||
task.x = slotFrame ? 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.x = slotFrame
|
||||
? 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.fy = task.y;
|
||||
task.vx = 0;
|
||||
|
|
@ -254,18 +262,19 @@ export class KanbanLayoutEngine {
|
|||
if (unassignedTaskRect) {
|
||||
const cols = Math.min(Math.max(tasks.length, 1), 5);
|
||||
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);
|
||||
|
||||
this.zones.push({
|
||||
ownerId: '__unassigned__',
|
||||
ownerX: 0,
|
||||
ownerY: baseY - 48,
|
||||
ownerY: headerY - 48,
|
||||
headers: [
|
||||
{
|
||||
label: 'Unassigned',
|
||||
x: 0,
|
||||
y: baseY,
|
||||
y: headerY,
|
||||
color: COLORS.taskPending,
|
||||
overflowCount,
|
||||
overflowY: baseY + KANBAN_ZONE.maxVisibleRows * rowHeight,
|
||||
|
|
@ -305,7 +314,8 @@ export class KanbanLayoutEngine {
|
|||
|
||||
const centerX = memberCount > 0 ? sumX / memberCount : 0;
|
||||
// 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 totalWidth = cols * columnWidth;
|
||||
const baseX = centerX - totalWidth / 2;
|
||||
|
|
@ -316,15 +326,17 @@ export class KanbanLayoutEngine {
|
|||
this.zones.push({
|
||||
ownerId: '__unassigned__',
|
||||
ownerX: centerX,
|
||||
ownerY: baseY - 70,
|
||||
headers: [{
|
||||
label: 'Unassigned',
|
||||
x: centerX,
|
||||
y: baseY - 10,
|
||||
color: COLORS.taskPending,
|
||||
overflowCount,
|
||||
overflowY: baseY + KANBAN_ZONE.maxVisibleRows * rowHeight,
|
||||
}],
|
||||
ownerY: headerY - 70,
|
||||
headers: [
|
||||
{
|
||||
label: 'Unassigned',
|
||||
x: centerX,
|
||||
y: headerY,
|
||||
color: COLORS.taskPending,
|
||||
overflowCount,
|
||||
overflowY: baseY + KANBAN_ZONE.maxVisibleRows * rowHeight,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ export const STABLE_SLOT_GEOMETRY = {
|
|||
ownerMinWidth: 200,
|
||||
processBandHeight: 32,
|
||||
processRailWidth: 220,
|
||||
taskMaxVisibleRows: 5,
|
||||
taskMaxVisibleRows: 3,
|
||||
} as const;
|
||||
|
||||
export const STABLE_SLOT_SECTOR_VECTORS = [
|
||||
|
|
|
|||
113
pnpm-lock.yaml
113
pnpm-lock.yaml
|
|
@ -102,8 +102,8 @@ importers:
|
|||
specifier: ^11.2.0
|
||||
version: 11.2.0
|
||||
'@fastify/static':
|
||||
specifier: ^9.0.0
|
||||
version: 9.0.0
|
||||
specifier: ^9.1.3
|
||||
version: 9.1.3
|
||||
'@floating-ui/dom':
|
||||
specifier: ^1.7.6
|
||||
version: 1.7.6
|
||||
|
|
@ -150,8 +150,8 @@ importers:
|
|||
specifier: ^10.45.0
|
||||
version: 10.45.0(react@19.2.4)
|
||||
'@tanstack/react-virtual':
|
||||
specifier: ^3.10.8
|
||||
version: 3.13.18(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
specifier: ^3.13.24
|
||||
version: 3.13.24(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@tiptap/extension-placeholder':
|
||||
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))
|
||||
|
|
@ -210,8 +210,8 @@ importers:
|
|||
specifier: ^6.7.3
|
||||
version: 6.7.3
|
||||
fastify:
|
||||
specifier: ^5.7.4
|
||||
version: 5.7.4
|
||||
specifier: ^5.8.5
|
||||
version: 5.8.5
|
||||
highlight.js:
|
||||
specifier: ^11.11.1
|
||||
version: 11.11.1
|
||||
|
|
@ -279,8 +279,8 @@ importers:
|
|||
specifier: ^11.0.0
|
||||
version: 11.0.0
|
||||
simple-git:
|
||||
specifier: ^3.32.3
|
||||
version: 3.32.3
|
||||
specifier: ^3.36.0
|
||||
version: 3.36.0
|
||||
ssh-config:
|
||||
specifier: ^5.0.4
|
||||
version: 5.0.4
|
||||
|
|
@ -355,8 +355,8 @@ importers:
|
|||
specifier: ^10.4.17
|
||||
version: 10.4.23(postcss@8.5.6)
|
||||
electron:
|
||||
specifier: ^40.3.0
|
||||
version: 40.3.0
|
||||
specifier: ^40.10.0
|
||||
version: 40.10.0
|
||||
electron-builder:
|
||||
specifier: ^26.8.1
|
||||
version: 26.8.1(electron-builder-squirrel-windows@26.8.1)
|
||||
|
|
@ -1714,8 +1714,8 @@ packages:
|
|||
'@fastify/send@4.1.0':
|
||||
resolution: {integrity: sha512-TMYeQLCBSy2TOFmV95hQWkiTYgC/SEx7vMdV+wnZVX4tt8VBLKzmH8vV9OzJehV0+XBfg+WxPMt5wp+JBUKsVw==}
|
||||
|
||||
'@fastify/static@9.0.0':
|
||||
resolution: {integrity: sha512-r64H8Woe/vfilg5RTy7lwWlE8ZZcTrc3kebYFMEUBrMqlydhQyoiExQXdYAy2REVpST/G35+stAM8WYp1WGmMA==}
|
||||
'@fastify/static@9.1.3':
|
||||
resolution: {integrity: sha512-aXrYtsiryLhRxRNaxNqsn7FUISeb7rB9q4eHUPIot5aeQBLNahnz1m6thzm7JWC1poSGXS9XrX8DvuMivp2hkQ==}
|
||||
|
||||
'@floating-ui/core@1.7.5':
|
||||
resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==}
|
||||
|
|
@ -4077,6 +4077,12 @@ packages:
|
|||
'@shikijs/vscode-textmate@10.0.2':
|
||||
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':
|
||||
resolution: {integrity: sha512-TeheYy0ILzBEI/CO55CP6zJCSdSWeRtGnHy8U8dWSUH4I68iqTsy7HkMktR4xakThc9jotkPQUXT4ITdbV7cHA==}
|
||||
engines: {node: '>=18'}
|
||||
|
|
@ -4114,14 +4120,14 @@ packages:
|
|||
peerDependencies:
|
||||
tailwindcss: '>=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1'
|
||||
|
||||
'@tanstack/react-virtual@3.13.18':
|
||||
resolution: {integrity: sha512-dZkhyfahpvlaV0rIKnvQiVoWPyURppl6w4m9IwMDpuIjcJ1sD9YGWrt0wISvgU7ewACXx2Ct46WPgI6qAD4v6A==}
|
||||
'@tanstack/react-virtual@3.13.24':
|
||||
resolution: {integrity: sha512-aIJvz5OSkhNIhZIpYivrxrPTKYsjW9Uzy+sP/mx0S3sev2HyvPb7xmjbYvokzEpfgYHy/HjzJ2zFAETuUfgCpg==}
|
||||
peerDependencies:
|
||||
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
|
||||
|
||||
'@tanstack/virtual-core@3.13.18':
|
||||
resolution: {integrity: sha512-Mx86Hqu1k39icq2Zusq+Ey2J6dDWTjDvEv43PJtRCoEYTLyfaPnxIQ6iy7YAOK0NV/qOEmZQ/uCufrppZxTgcg==}
|
||||
'@tanstack/virtual-core@3.14.0':
|
||||
resolution: {integrity: sha512-JLANqGy/D6k4Ujmh8Tr25lGimuOXNiaVyXaCAZS0W+1390sADdGnyUdSWNIfd49gebtIxGMij4IktRVzrdr12Q==}
|
||||
|
||||
'@tiptap/core@3.20.4':
|
||||
resolution: {integrity: sha512-3i/DG89TFY/b34T5P+j35UcjYuB5d3+9K8u6qID+iUqNPiza015HPIZLuPfE5elNwVdV3EXIoPo0LLeBLgXXAg==}
|
||||
|
|
@ -6300,8 +6306,8 @@ packages:
|
|||
resolution: {integrity: sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg==}
|
||||
engines: {node: '>=8.0.0'}
|
||||
|
||||
electron@40.3.0:
|
||||
resolution: {integrity: sha512-ZaDkTZpNHr863tyZHieoqbaiLI0e3RVCXoEC5y1Ld70/Q5H1mPV9d5TK0h1dWtaSFVOW0w8iDvtdLwAXtasXpg==}
|
||||
electron@40.10.0:
|
||||
resolution: {integrity: sha512-e7XVcAfyWoFQGS7ZhgxeNn0AijHaqgRCa6uA6TYOrvBWv8smI6JILvMR/8DYBIn07oqvxDLRC90tu/xa2cJCow==}
|
||||
engines: {node: '>= 12.20.55'}
|
||||
hasBin: true
|
||||
|
||||
|
|
@ -6815,8 +6821,8 @@ packages:
|
|||
fastify-plugin@5.1.0:
|
||||
resolution: {integrity: sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==}
|
||||
|
||||
fastify@5.7.4:
|
||||
resolution: {integrity: sha512-e6l5NsRdaEP8rdD8VR0ErJASeyaRbzXYpmkrpr2SuvuMq6Si3lvsaVy5C+7gLanEkvjpMDzBXWE5HPeb/hgTxA==}
|
||||
fastify@5.8.5:
|
||||
resolution: {integrity: sha512-Yqptv59pQzPgQUSIm87hMqHJmdkb1+GPxdE6vW6FRyVE9G86mt7rOghitiU4JHRaTyDUk9pfeKmDeu70lAwM4Q==}
|
||||
|
||||
fastmcp@3.34.0:
|
||||
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
|
||||
hasBin: true
|
||||
|
||||
glob@13.0.2:
|
||||
resolution: {integrity: sha512-035InabNu/c1lW0tzPhAgapKctblppqsKKG9ZaNzbr+gXwWMjXoiyGSyB9sArzrjG7jY+zntRq5ZSUYemrnWVQ==}
|
||||
engines: {node: 20 || >=22}
|
||||
|
||||
glob@13.0.6:
|
||||
resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==}
|
||||
engines: {node: 18 || 20 || >=22}
|
||||
|
|
@ -8319,10 +8321,6 @@ packages:
|
|||
resolution: {integrity: sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
minipass@7.1.2:
|
||||
resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==}
|
||||
engines: {node: '>=16 || 14 >=14.17'}
|
||||
|
||||
minipass@7.1.3:
|
||||
resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==}
|
||||
engines: {node: '>=16 || 14 >=14.17'}
|
||||
|
|
@ -8743,10 +8741,6 @@ packages:
|
|||
resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==}
|
||||
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:
|
||||
resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==}
|
||||
engines: {node: 18 || 20 || >=22}
|
||||
|
|
@ -9820,12 +9814,12 @@ packages:
|
|||
resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
|
||||
engines: {node: '>=14'}
|
||||
|
||||
simple-git@3.32.3:
|
||||
resolution: {integrity: sha512-56a5oxFdWlsGygOXHWrG+xjj5w9ZIt2uQbzqiIGdR/6i5iococ7WQ/bNPzWxCJdEUGUCmyMH0t9zMpRJTaKxmw==}
|
||||
|
||||
simple-git@3.33.0:
|
||||
resolution: {integrity: sha512-D4V/tGC2sjsoNhoMybKyGoE+v8A60hRawKQ1iFRA1zwuDgGZCBJ4ByOzZ5J8joBbi4Oam0qiPH+GhzmSBwbJng==}
|
||||
|
||||
simple-git@3.36.0:
|
||||
resolution: {integrity: sha512-cGQjLjK8bxJw4QuYT7gxHw3/IouVESbhahSsHrX97MzCL1gu2u7oy38W6L2ZIGECEfIBG4BabsWDPjBxJENv9Q==}
|
||||
|
||||
simple-update-notifier@2.0.0:
|
||||
resolution: {integrity: sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==}
|
||||
engines: {node: '>=10'}
|
||||
|
|
@ -12415,14 +12409,14 @@ snapshots:
|
|||
http-errors: 2.0.1
|
||||
mime: 3.0.0
|
||||
|
||||
'@fastify/static@9.0.0':
|
||||
'@fastify/static@9.1.3':
|
||||
dependencies:
|
||||
'@fastify/accept-negotiator': 2.0.1
|
||||
'@fastify/send': 4.1.0
|
||||
content-disposition: 1.0.1
|
||||
fastify-plugin: 5.1.0
|
||||
fastq: 1.20.1
|
||||
glob: 13.0.2
|
||||
glob: 13.0.6
|
||||
|
||||
'@floating-ui/core@1.7.5':
|
||||
dependencies:
|
||||
|
|
@ -14849,6 +14843,12 @@ snapshots:
|
|||
|
||||
'@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/is@4.6.0': {}
|
||||
|
|
@ -14880,13 +14880,13 @@ snapshots:
|
|||
postcss-selector-parser: 6.0.10
|
||||
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:
|
||||
'@tanstack/virtual-core': 3.13.18
|
||||
'@tanstack/virtual-core': 3.14.0
|
||||
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)':
|
||||
dependencies:
|
||||
|
|
@ -15519,7 +15519,7 @@ snapshots:
|
|||
'@typescript-eslint/types': 8.57.1
|
||||
'@typescript-eslint/visitor-keys': 8.57.1
|
||||
debug: 4.4.3
|
||||
minimatch: 10.2.2
|
||||
minimatch: 10.2.5
|
||||
semver: 7.7.4
|
||||
tinyglobby: 0.2.15
|
||||
ts-api-utils: 2.4.0(typescript@5.9.3)
|
||||
|
|
@ -17415,7 +17415,7 @@ snapshots:
|
|||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
electron@40.3.0:
|
||||
electron@40.10.0:
|
||||
dependencies:
|
||||
'@electron/get': 2.0.3
|
||||
'@types/node': 24.10.12
|
||||
|
|
@ -17787,7 +17787,7 @@ snapshots:
|
|||
eslint: 9.39.2(jiti@1.21.7)
|
||||
eslint-import-context: 0.1.9(unrs-resolver@1.11.1)
|
||||
is-glob: 4.0.3
|
||||
minimatch: 10.2.2
|
||||
minimatch: 10.2.5
|
||||
semver: 7.7.4
|
||||
stable-hash-x: 0.2.0
|
||||
unrs-resolver: 1.11.1
|
||||
|
|
@ -17807,7 +17807,7 @@ snapshots:
|
|||
eslint: 9.39.2(jiti@2.6.1)
|
||||
eslint-import-context: 0.1.9(unrs-resolver@1.11.1)
|
||||
is-glob: 4.0.3
|
||||
minimatch: 10.2.2
|
||||
minimatch: 10.2.5
|
||||
semver: 7.7.4
|
||||
stable-hash-x: 0.2.0
|
||||
unrs-resolver: 1.11.1
|
||||
|
|
@ -18309,7 +18309,7 @@ snapshots:
|
|||
|
||||
fastify-plugin@5.1.0: {}
|
||||
|
||||
fastify@5.7.4:
|
||||
fastify@5.8.5:
|
||||
dependencies:
|
||||
'@fastify/ajv-compiler': 4.0.5
|
||||
'@fastify/error': 4.2.0
|
||||
|
|
@ -18324,7 +18324,7 @@ snapshots:
|
|||
process-warning: 5.0.0
|
||||
rfdc: 1.4.1
|
||||
secure-json-parse: 4.1.0
|
||||
semver: 7.7.3
|
||||
semver: 7.7.4
|
||||
toad-cache: 3.7.0
|
||||
|
||||
fastmcp@3.34.0:
|
||||
|
|
@ -18630,15 +18630,9 @@ snapshots:
|
|||
package-json-from-dist: 1.0.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:
|
||||
dependencies:
|
||||
minimatch: 10.2.2
|
||||
minimatch: 10.2.5
|
||||
minipass: 7.1.3
|
||||
path-scurry: 2.0.2
|
||||
|
||||
|
|
@ -20222,8 +20216,6 @@ snapshots:
|
|||
dependencies:
|
||||
yallist: 4.0.0
|
||||
|
||||
minipass@7.1.2: {}
|
||||
|
||||
minipass@7.1.3: {}
|
||||
|
||||
minisearch@7.2.0: {}
|
||||
|
|
@ -20941,11 +20933,6 @@ snapshots:
|
|||
lru-cache: 10.4.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:
|
||||
dependencies:
|
||||
lru-cache: 11.2.6
|
||||
|
|
@ -22138,7 +22125,7 @@ snapshots:
|
|||
|
||||
signal-exit@4.1.0: {}
|
||||
|
||||
simple-git@3.32.3:
|
||||
simple-git@3.33.0:
|
||||
dependencies:
|
||||
'@kwsites/file-exists': 1.1.1
|
||||
'@kwsites/promise-deferred': 1.1.1
|
||||
|
|
@ -22146,10 +22133,12 @@ snapshots:
|
|||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
simple-git@3.33.0:
|
||||
simple-git@3.36.0:
|
||||
dependencies:
|
||||
'@kwsites/file-exists': 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
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
|
|
|||
|
|
@ -1,27 +1,27 @@
|
|||
{
|
||||
"version": "0.0.29",
|
||||
"sourceRef": "v0.0.29",
|
||||
"version": "0.0.30",
|
||||
"sourceRef": "v0.0.30",
|
||||
"sourceRepository": "777genius/agent_teams_orchestrator",
|
||||
"releaseRepository": "777genius/agent-teams-ai",
|
||||
"releaseTag": "v1.2.0",
|
||||
"assets": {
|
||||
"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",
|
||||
"binaryName": "claude-multimodel"
|
||||
},
|
||||
"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",
|
||||
"binaryName": "claude-multimodel"
|
||||
},
|
||||
"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",
|
||||
"binaryName": "claude-multimodel"
|
||||
},
|
||||
"win32-x64": {
|
||||
"file": "agent-teams-runtime-win32-x64-v0.0.29.zip",
|
||||
"file": "agent-teams-runtime-win32-x64-v0.0.30.zip",
|
||||
"archiveKind": "zip",
|
||||
"binaryName": "claude-multimodel.exe"
|
||||
}
|
||||
|
|
|
|||
488
scripts/team-changes-real-data-smoke.ts
Normal file
488
scripts/team-changes-real-data-smoke.ts
Normal 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);
|
||||
}
|
||||
);
|
||||
|
|
@ -28,6 +28,7 @@ const ACTIVITY_SHELL_HEIGHT =
|
|||
ACTIVITY_LANE.headerHeight +
|
||||
ACTIVITY_LANE.maxVisibleItems * ACTIVITY_LANE.rowHeight +
|
||||
ACTIVITY_LANE.overflowHeight;
|
||||
const NEW_ACTIVITY_HIGHLIGHT_MS = 1_000;
|
||||
|
||||
interface GraphActivityHudProps {
|
||||
teamName: string;
|
||||
|
|
@ -56,6 +57,10 @@ interface GraphActivityHudProps {
|
|||
) => void;
|
||||
}
|
||||
|
||||
function buildRenderedActivityItemKey(ownerNodeId: string, itemId: string): string {
|
||||
return JSON.stringify([ownerNodeId, itemId]);
|
||||
}
|
||||
|
||||
export const GraphActivityHud = ({
|
||||
teamName,
|
||||
nodes,
|
||||
|
|
@ -73,7 +78,12 @@ export const GraphActivityHud = ({
|
|||
const shellRefs = useRef(new Map<string, HTMLDivElement | null>());
|
||||
const connectorRefs = useRef(new Map<string, SVGSVGElement | 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 [highlightedActivityItemIds, setHighlightedActivityItemIds] = useState<ReadonlySet<string>>(
|
||||
() => new Set()
|
||||
);
|
||||
const { teamData, teams } = useGraphActivityContext(teamName);
|
||||
const teamSnapshot = teamData;
|
||||
const members = teamData?.members ?? [];
|
||||
|
|
@ -114,8 +124,23 @@ export const GraphActivityHud = ({
|
|||
|
||||
useEffect(() => {
|
||||
setExpandedItem(null);
|
||||
knownActivityItemIdsByOwnerRef.current.clear();
|
||||
setHighlightedActivityItemIds(new Set());
|
||||
for (const timer of highlightTimersRef.current.values()) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
highlightTimersRef.current.clear();
|
||||
}, [teamName]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
for (const timer of highlightTimersRef.current.values()) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
highlightTimersRef.current.clear();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const visibleLanes = useMemo(() => {
|
||||
return ownerNodes
|
||||
.map((node) => {
|
||||
|
|
@ -143,6 +168,51 @@ export const GraphActivityHud = ({
|
|||
);
|
||||
}, [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(() => {
|
||||
if (!enabled || visibleLanes.length === 0) {
|
||||
for (const shell of shellRefs.current.values()) {
|
||||
|
|
@ -377,11 +447,20 @@ export const GraphActivityHud = ({
|
|||
message: entry.message,
|
||||
};
|
||||
const isUnread = !entry.message.read && !readSet.has(messageKey);
|
||||
const isHighlighted = highlightedActivityItemIds.has(
|
||||
buildRenderedActivityItemKey(entry.ownerNodeId, entry.graphItem.id)
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
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"
|
||||
tabIndex={0}
|
||||
onClick={() => handleMessageClick(timelineItem)}
|
||||
|
|
@ -405,6 +484,7 @@ export const GraphActivityHud = ({
|
|||
[
|
||||
handleMessageClick,
|
||||
handleMessageKeyDown,
|
||||
highlightedActivityItemIds,
|
||||
messageContext,
|
||||
onOpenMemberProfile,
|
||||
onOpenTaskDetail,
|
||||
|
|
|
|||
|
|
@ -116,8 +116,8 @@ function resolveEmptyText(
|
|||
if (preview?.warnings.some((warning) => warning.code === 'codex_member_wide_not_supported')) {
|
||||
return 'Unsupported provider';
|
||||
}
|
||||
if (loading && (!preview || preview.items.length === 0)) return 'Loading logs';
|
||||
if (error && (!preview || preview.items.length === 0)) return 'Logs unavailable';
|
||||
if (loading && !preview) return 'Loading logs';
|
||||
if (error && !preview) return 'Logs unavailable';
|
||||
return 'No recent logs';
|
||||
}
|
||||
|
||||
|
|
@ -215,6 +215,26 @@ function setShellHidden(shell: HTMLDivElement): void {
|
|||
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 = ({
|
||||
teamName,
|
||||
nodes,
|
||||
|
|
@ -532,6 +552,7 @@ export const GraphMemberLogPreviewHud = ({
|
|||
: node.label;
|
||||
const preview = previewsByMember.get(normalizeMemberName(memberName));
|
||||
const items = preview?.items ?? [];
|
||||
const isInitialLoading = loading && !preview;
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -557,6 +578,17 @@ export const GraphMemberLogPreviewHud = ({
|
|||
<div className="flex min-h-0 flex-1 flex-col gap-2 overflow-hidden">
|
||||
{items.length > 0 ? (
|
||||
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
|
||||
type="button"
|
||||
|
|
|
|||
|
|
@ -188,6 +188,7 @@ import {
|
|||
LocalFileSystemProvider,
|
||||
MemberStatsComputer,
|
||||
NotificationManager,
|
||||
OpenCodeRuntimeInstallerService,
|
||||
OpenCodeReadinessBridge,
|
||||
OpenCodeTeamRuntimeAdapter,
|
||||
PtyTerminalService,
|
||||
|
|
@ -700,6 +701,7 @@ let teamDataService: TeamDataService;
|
|||
let teamProvisioningService: TeamProvisioningService;
|
||||
let launchIoGovernor: LaunchIoGovernor | null = null;
|
||||
let cliInstallerService: CliInstallerService;
|
||||
let openCodeRuntimeInstallerService: OpenCodeRuntimeInstallerService;
|
||||
let ptyTerminalService: PtyTerminalService;
|
||||
let httpServer: HttpServer;
|
||||
let schedulerService: SchedulerService;
|
||||
|
|
@ -1312,6 +1314,7 @@ async function initializeServices(): Promise<void> {
|
|||
}
|
||||
});
|
||||
cliInstallerService = new CliInstallerService();
|
||||
openCodeRuntimeInstallerService = new OpenCodeRuntimeInstallerService();
|
||||
ptyTerminalService = new PtyTerminalService();
|
||||
const teamMemberLogsFinder = new TeamMemberLogsFinder();
|
||||
const teamLogSourceTracker = new TeamLogSourceTracker(teamMemberLogsFinder);
|
||||
|
|
@ -1810,6 +1813,7 @@ async function initializeServices(): Promise<void> {
|
|||
reviewApplier,
|
||||
gitDiffFallback,
|
||||
cliInstallerService,
|
||||
openCodeRuntimeInstallerService,
|
||||
ptyTerminalService,
|
||||
schedulerService,
|
||||
extensionFacadeService,
|
||||
|
|
@ -2055,6 +2059,7 @@ function attachMainWindowToServices(): void {
|
|||
notificationManager?.setMainWindow(win);
|
||||
updaterService?.setMainWindow(win);
|
||||
cliInstallerService?.setMainWindow(win);
|
||||
openCodeRuntimeInstallerService?.setMainWindow(win);
|
||||
setTmuxMainWindow(win);
|
||||
ptyTerminalService?.setMainWindow(win);
|
||||
teamProvisioningService?.setMainWindow(win);
|
||||
|
|
@ -2378,6 +2383,9 @@ function createWindow(): void {
|
|||
if (cliInstallerService) {
|
||||
cliInstallerService.setMainWindow(null);
|
||||
}
|
||||
if (openCodeRuntimeInstallerService) {
|
||||
openCodeRuntimeInstallerService.setMainWindow(null);
|
||||
}
|
||||
setTmuxMainWindow(null);
|
||||
if (ptyTerminalService) {
|
||||
ptyTerminalService.setMainWindow(null);
|
||||
|
|
|
|||
|
|
@ -44,6 +44,11 @@ import {
|
|||
registerHttpServerHandlers,
|
||||
removeHttpServerHandlers,
|
||||
} from './httpServer';
|
||||
import {
|
||||
initializeOpenCodeRuntimeHandlers,
|
||||
registerOpenCodeRuntimeHandlers,
|
||||
removeOpenCodeRuntimeHandlers,
|
||||
} from './openCodeRuntime';
|
||||
|
||||
const logger = createLogger('IPC:handlers');
|
||||
import { registerNotificationHandlers, removeNotificationHandlers } from './notifications';
|
||||
|
|
@ -100,6 +105,7 @@ import type {
|
|||
FileContentResolver,
|
||||
GitDiffFallback,
|
||||
MemberStatsComputer,
|
||||
OpenCodeRuntimeInstallerService,
|
||||
PtyTerminalService,
|
||||
ReviewApplierService,
|
||||
ServiceContext,
|
||||
|
|
@ -159,6 +165,7 @@ export function initializeIpcHandlers(
|
|||
reviewApplier?: ReviewApplierService,
|
||||
gitDiffFallback?: GitDiffFallback,
|
||||
cliInstaller?: CliInstallerService,
|
||||
openCodeRuntimeInstaller?: OpenCodeRuntimeInstallerService,
|
||||
ptyTerminal?: PtyTerminalService,
|
||||
schedulerService?: SchedulerService,
|
||||
extensionFacade?: ExtensionFacadeService,
|
||||
|
|
@ -209,6 +216,9 @@ export function initializeIpcHandlers(
|
|||
if (cliInstaller) {
|
||||
initializeCliInstallerHandlers(cliInstaller);
|
||||
}
|
||||
if (openCodeRuntimeInstaller) {
|
||||
initializeOpenCodeRuntimeHandlers(openCodeRuntimeInstaller);
|
||||
}
|
||||
if (ptyTerminal) {
|
||||
initializeTerminalHandlers(ptyTerminal);
|
||||
}
|
||||
|
|
@ -260,6 +270,9 @@ export function initializeIpcHandlers(
|
|||
if (cliInstaller) {
|
||||
registerCliInstallerHandlers(ipcMain);
|
||||
}
|
||||
if (openCodeRuntimeInstaller) {
|
||||
registerOpenCodeRuntimeHandlers(ipcMain);
|
||||
}
|
||||
if (ptyTerminal) {
|
||||
registerTerminalHandlers(ipcMain);
|
||||
}
|
||||
|
|
@ -301,6 +314,7 @@ export function removeIpcHandlers(): void {
|
|||
removeRendererLogHandlers(ipcMain);
|
||||
removeScheduleHandlers(ipcMain);
|
||||
removeCliInstallerHandlers(ipcMain);
|
||||
removeOpenCodeRuntimeHandlers(ipcMain);
|
||||
removeTerminalHandlers(ipcMain);
|
||||
removeTmuxHandlers(ipcMain);
|
||||
removeHttpServerHandlers(ipcMain);
|
||||
|
|
|
|||
78
src/main/ipc/openCodeRuntime.ts
Normal file
78
src/main/ipc/openCodeRuntime.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -24,6 +24,7 @@ export * from './FileWatcher';
|
|||
export * from './HttpServer';
|
||||
export * from './LocalFileSystemProvider';
|
||||
export * from './NotificationManager';
|
||||
export * from './OpenCodeRuntimeInstallerService';
|
||||
export * from './PtyTerminalService';
|
||||
export * from './ServiceContext';
|
||||
export * from './ServiceContextRegistry';
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import { getCachedShellEnv } from '@main/utils/shellEnv';
|
||||
|
||||
import { resolveAppManagedOpenCodeRuntimeBinaryPath } from '../infrastructure/OpenCodeRuntimeInstallerService';
|
||||
|
||||
import { buildRuntimeBaseEnv } from './buildRuntimeBaseEnv';
|
||||
import { providerConnectionService } from './ProviderConnectionService';
|
||||
|
||||
|
|
@ -34,6 +36,14 @@ export async function buildProviderAwareCliEnv(
|
|||
shellEnv,
|
||||
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 (!resolvedProviderId) {
|
||||
|
|
|
|||
|
|
@ -59,6 +59,7 @@ function pickDetectedMechanism(
|
|||
|
||||
export class TaskBoundaryParser {
|
||||
private cache = new Map<string, BoundaryCacheEntry>();
|
||||
private inFlight = new Map<string, Promise<TaskBoundariesResult>>();
|
||||
private readonly cacheTtl = 60 * 1000; // 60s
|
||||
|
||||
/** Парсинг JSONL файла для обнаружения границ задач */
|
||||
|
|
@ -77,6 +78,23 @@ export class TaskBoundaryParser {
|
|||
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
|
||||
const boundaries: TaskBoundary[] = [];
|
||||
const allToolUsesByLine = new Map<number, ToolUseInfo[]>();
|
||||
|
|
@ -151,7 +169,7 @@ export class TaskBoundaryParser {
|
|||
};
|
||||
this.cache.set(filePath, {
|
||||
data: result,
|
||||
mtime: fileStat.mtimeMs,
|
||||
mtime: fileMtimeMs,
|
||||
expiresAt: Date.now() + this.cacheTtl,
|
||||
});
|
||||
return result;
|
||||
|
|
@ -166,6 +184,7 @@ export class TaskBoundaryParser {
|
|||
/** Очистить кеш (для тестов) */
|
||||
clearCache(): void {
|
||||
this.cache.clear();
|
||||
this.inFlight.clear();
|
||||
}
|
||||
|
||||
// ── Приватные методы ──
|
||||
|
|
|
|||
|
|
@ -27,6 +27,11 @@ interface ParsedSnippetsCacheEntry {
|
|||
expiresAt: number;
|
||||
}
|
||||
|
||||
interface ParsedSnippetsResult {
|
||||
snippets: SnippetDiff[];
|
||||
mtime: number;
|
||||
}
|
||||
|
||||
interface LogFileRef {
|
||||
filePath: string;
|
||||
memberName: string;
|
||||
|
|
@ -34,7 +39,9 @@ interface LogFileRef {
|
|||
|
||||
export class TaskChangeComputer {
|
||||
private parsedSnippetsCache = new Map<string, ParsedSnippetsCacheEntry>();
|
||||
private parsedSnippetsInFlight = new Map<string, Promise<ParsedSnippetsResult>>();
|
||||
private readonly parsedSnippetsCacheTtl = 20 * 1000;
|
||||
private readonly maxParsedSnippetsCacheEntries = 1_000;
|
||||
private static readonly JSONL_PARSE_CONCURRENCY = 6;
|
||||
|
||||
constructor(
|
||||
|
|
@ -367,9 +374,7 @@ export class TaskChangeComputer {
|
|||
return results;
|
||||
}
|
||||
|
||||
private async parseJSONLFile(
|
||||
filePath: string
|
||||
): Promise<{ snippets: SnippetDiff[]; mtime: number }> {
|
||||
private async parseJSONLFile(filePath: string): Promise<ParsedSnippetsResult> {
|
||||
let fileMtime = 0;
|
||||
try {
|
||||
const fileStat = await stat(filePath);
|
||||
|
|
@ -383,6 +388,23 @@ export class TaskChangeComputer {
|
|||
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>[] = [];
|
||||
|
||||
try {
|
||||
|
|
@ -514,6 +536,11 @@ export class TaskChangeComputer {
|
|||
mtime: fileMtime,
|
||||
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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ const ATTRIBUTION_SCAN_LINES = 50;
|
|||
|
||||
/** Grace before task creation — logs cannot reference a task before it exists. */
|
||||
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;
|
||||
|
||||
/** Max concurrent file reads during parallel scan phases. */
|
||||
|
|
@ -93,6 +93,19 @@ interface RootSessionAttribution {
|
|||
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 =
|
||||
| {
|
||||
kind: 'subagent';
|
||||
|
|
@ -181,7 +194,8 @@ function collectTaskFreshnessRootDirs(candidates: readonly unknown[]): string[]
|
|||
}
|
||||
|
||||
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<
|
||||
string,
|
||||
SubagentAttribution | RootSessionAttribution | null
|
||||
|
|
@ -193,6 +207,11 @@ export class TeamMemberLogsFinder {
|
|||
expiresAt: number;
|
||||
}
|
||||
>();
|
||||
private readonly discoveryInFlight = new Map<
|
||||
string,
|
||||
{ generation: number; promise: Promise<ProjectSessionDiscovery | null> }
|
||||
>();
|
||||
private readonly discoveryGenerationByTeam = new Map<string, number>();
|
||||
|
||||
constructor(
|
||||
private readonly configReader: TeamConfigReader = new TeamConfigReader(),
|
||||
|
|
@ -234,7 +253,7 @@ export class TeamMemberLogsFinder {
|
|||
|
||||
// ── Collect and parallel-scan subagent files ──
|
||||
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;
|
||||
|
||||
const scanWorker = async (): Promise<void> => {
|
||||
|
|
@ -406,13 +425,11 @@ export class TeamMemberLogsFinder {
|
|||
// file missing or unreadable
|
||||
}
|
||||
}
|
||||
const tLead = performance.now();
|
||||
|
||||
// ── Collect all subagent file candidates ──
|
||||
const candidates = await this.collectLogCandidates(projectDir, sessionIds, config);
|
||||
|
||||
// ── 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 mentionHits = 0;
|
||||
|
||||
|
|
@ -471,8 +488,6 @@ export class TeamMemberLogsFinder {
|
|||
}
|
||||
const totalFiles = candidates.length;
|
||||
const step2Count = results.length; // count before step 3 (owner fallback)
|
||||
const tScan = performance.now();
|
||||
|
||||
const normalizedOwner =
|
||||
typeof options?.owner === 'string' ? options.owner.trim() : options?.owner;
|
||||
const isLeadOwner =
|
||||
|
|
@ -564,8 +579,6 @@ export class TeamMemberLogsFinder {
|
|||
}
|
||||
}
|
||||
}
|
||||
const tOwner = performance.now();
|
||||
|
||||
// Dedup cumulative subagent snapshots: keep 1 file per sessionId+memberName (largest).
|
||||
// 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.
|
||||
|
|
@ -592,7 +605,7 @@ export class TeamMemberLogsFinder {
|
|||
// Safety net: filterChunksByWorkIntervals on frontend still filters content by time,
|
||||
// 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()
|
||||
);
|
||||
const tTotal = performance.now();
|
||||
|
|
@ -623,15 +636,9 @@ export class TeamMemberLogsFinder {
|
|||
since?: string;
|
||||
}
|
||||
): Promise<{ filePath: string; memberName: string }[]> {
|
||||
const t0 = performance.now();
|
||||
|
||||
const discovery = await this.discoverProjectSessions(teamName);
|
||||
const tDiscovery = performance.now();
|
||||
|
||||
if (!discovery) {
|
||||
// console.log(
|
||||
// `[perf] findLogFileRefsForTask(${taskId}) discovery=null ${(tDiscovery - t0).toFixed(0)}ms`
|
||||
// );
|
||||
return [];
|
||||
}
|
||||
|
||||
|
|
@ -679,14 +686,11 @@ export class TeamMemberLogsFinder {
|
|||
// file missing or unreadable
|
||||
}
|
||||
}
|
||||
const tLead = performance.now();
|
||||
|
||||
// ── Collect all subagent file candidates ──
|
||||
const candidates = await this.collectLogCandidates(projectDir, sessionIds, config);
|
||||
|
||||
// ── Parallel scan with concurrency limit ──
|
||||
let nextIdx = 0;
|
||||
let mentionHits = 0;
|
||||
|
||||
const scanWorker = async (): Promise<void> => {
|
||||
while (nextIdx < candidates.length) {
|
||||
|
|
@ -695,7 +699,6 @@ export class TeamMemberLogsFinder {
|
|||
try {
|
||||
if (!(await this.fileMentionsTaskIdCached(c.filePath, teamName, taskId, false, sinceMs)))
|
||||
continue;
|
||||
mentionHits++;
|
||||
const attribution =
|
||||
c.kind === 'subagent'
|
||||
? await this.attributeSubagent(c.filePath, knownMembers)
|
||||
|
|
@ -717,8 +720,6 @@ export class TeamMemberLogsFinder {
|
|||
await Promise.all(
|
||||
Array.from({ length: Math.min(SCAN_CONCURRENCY, candidates.length) }, () => scanWorker())
|
||||
);
|
||||
const totalFiles = candidates.length;
|
||||
const tScan = performance.now();
|
||||
|
||||
const normalizedOwner =
|
||||
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).
|
||||
{
|
||||
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 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 }));
|
||||
}
|
||||
|
|
@ -1160,23 +1148,40 @@ export class TeamMemberLogsFinder {
|
|||
private async discoverProjectSessions(
|
||||
teamName: string,
|
||||
options?: { forceRefresh?: boolean }
|
||||
): Promise<{
|
||||
projectDir: string;
|
||||
projectId: string;
|
||||
config: NonNullable<Awaited<ReturnType<TeamConfigReader['getConfig']>>>;
|
||||
sessionIds: string[];
|
||||
knownMembers: Set<string>;
|
||||
} | null> {
|
||||
): Promise<ProjectSessionDiscovery | null> {
|
||||
let generation = this.discoveryGenerationByTeam.get(teamName) ?? 0;
|
||||
if (options?.forceRefresh) {
|
||||
generation += 1;
|
||||
this.discoveryGenerationByTeam.set(teamName, generation);
|
||||
this.discoveryCache.delete(teamName);
|
||||
this.discoveryInFlight.delete(teamName);
|
||||
} else {
|
||||
// Check discovery cache — avoids re-reading config/dirs within rapid successive calls
|
||||
const cached = this.discoveryCache.get(teamName);
|
||||
if (cached && cached.expiresAt > Date.now()) {
|
||||
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);
|
||||
if (!context) {
|
||||
logger.debug(`No transcript context for team "${teamName}"`);
|
||||
|
|
@ -1209,10 +1214,12 @@ export class TeamMemberLogsFinder {
|
|||
}
|
||||
|
||||
const discovery = { projectDir, projectId, config, sessionIds, knownMembers };
|
||||
this.discoveryCache.set(teamName, {
|
||||
result: discovery,
|
||||
expiresAt: Date.now() + DISCOVERY_CACHE_TTL,
|
||||
});
|
||||
if ((this.discoveryGenerationByTeam.get(teamName) ?? 0) === generation) {
|
||||
this.discoveryCache.set(teamName, {
|
||||
result: discovery,
|
||||
expiresAt: Date.now() + DISCOVERY_CACHE_TTL,
|
||||
});
|
||||
}
|
||||
return discovery;
|
||||
}
|
||||
|
||||
|
|
@ -1347,39 +1354,71 @@ export class TeamMemberLogsFinder {
|
|||
if (sinceMs != null && mtimeMs < sinceMs - TASK_SINCE_GRACE_MS) {
|
||||
return false;
|
||||
}
|
||||
const cacheKey = `${filePath}:${mtimeMs}:${taskId}:${teamName}:${assumeTeam}`;
|
||||
const cached = this.fileMentionsCache.get(cacheKey);
|
||||
if (cached !== undefined) return cached;
|
||||
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;
|
||||
const cacheKey = `${filePath}:${mtimeMs}:${teamName}:${assumeTeam}`;
|
||||
const index = await this.getTaskMentionIndex(cacheKey, filePath, teamName, assumeTeam);
|
||||
return this.taskMentionIndexHasTaskId(index, taskId);
|
||||
}
|
||||
|
||||
private async fileMentionsTaskId(
|
||||
private async getTaskMentionIndex(
|
||||
cacheKey: string,
|
||||
filePath: string,
|
||||
teamName: string,
|
||||
taskId: string,
|
||||
assumeTeam: boolean = false
|
||||
): Promise<boolean> {
|
||||
const teamLower = teamName.trim().toLowerCase();
|
||||
const taskIdStr = taskId.trim();
|
||||
assumeTeam: boolean
|
||||
): Promise<TaskMentionIndex> {
|
||||
const cached = this.taskMentionIndexCache.get(cacheKey);
|
||||
if (cached) return cached;
|
||||
|
||||
// CLI agents often use the short displayId (first 8 chars of UUID) in tool inputs,
|
||||
// while the UI passes the full UUID. Match both forms to bridge this gap.
|
||||
const inFlight = this.taskMentionIndexInFlight.get(cacheKey);
|
||||
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 =
|
||||
/^[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()
|
||||
: null;
|
||||
return taskIdDisplayForm !== null && index.lowerTaskIds.has(taskIdDisplayForm);
|
||||
}
|
||||
|
||||
const matchesTaskId = (candidate: string): boolean =>
|
||||
candidate === taskIdStr ||
|
||||
(taskIdDisplayForm !== null && candidate.toLowerCase() === taskIdDisplayForm);
|
||||
private addTaskMention(index: TaskMentionIndex, rawTaskId: string): void {
|
||||
const taskId = rawTaskId.trim();
|
||||
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 => {
|
||||
if (typeof raw === 'string') return raw.trim();
|
||||
|
|
@ -1430,7 +1469,13 @@ export class TeamMemberLogsFinder {
|
|||
const stream = createReadStream(filePath, { encoding: 'utf8' });
|
||||
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
||||
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) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
|
|
@ -1466,16 +1511,13 @@ export class TeamMemberLogsFinder {
|
|||
matchesTeamMentionText(b.text)
|
||||
) {
|
||||
teamSeen = true;
|
||||
acceptPendingTaskIds();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (teamSeen && taskSeenWithoutTeam) {
|
||||
rl.close();
|
||||
stream.destroy();
|
||||
return true;
|
||||
}
|
||||
acceptPendingTaskIds();
|
||||
|
||||
for (const block of content) {
|
||||
if (!block || typeof block !== 'object') continue;
|
||||
|
|
@ -1496,32 +1538,25 @@ export class TeamMemberLogsFinder {
|
|||
const inputTeam = extractTeamFromInput(input);
|
||||
const rawTaskId = input.taskId ?? input.task_id;
|
||||
const inputTaskId = extractTaskIdFromUnknown(rawTaskId);
|
||||
if (inputTaskId && matchesTaskId(inputTaskId)) {
|
||||
if (inputTaskId) {
|
||||
// If team is present in the input, require exact match.
|
||||
if (inputTeam) {
|
||||
if (inputTeam.toLowerCase() === teamLower) {
|
||||
rl.close();
|
||||
stream.destroy();
|
||||
return true;
|
||||
this.addTaskMention(index, inputTaskId);
|
||||
}
|
||||
} else {
|
||||
// Some agents use TaskUpdate without team_name (common in Solo).
|
||||
// Only accept when we have a separate team marker for this file.
|
||||
if (teamSeen) {
|
||||
rl.close();
|
||||
stream.destroy();
|
||||
return true;
|
||||
this.addTaskMention(index, inputTaskId);
|
||||
} else {
|
||||
pendingTaskIdsWithoutTeam.add(inputTaskId);
|
||||
}
|
||||
taskSeenWithoutTeam = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (teamSeen && taskSeenWithoutTeam) {
|
||||
rl.close();
|
||||
stream.destroy();
|
||||
return true;
|
||||
}
|
||||
acceptPendingTaskIds();
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
|
|
@ -1531,7 +1566,7 @@ export class TeamMemberLogsFinder {
|
|||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return false;
|
||||
return index;
|
||||
}
|
||||
|
||||
private extractEntryContent(entry: Record<string, unknown>): unknown[] | null {
|
||||
|
|
@ -1899,11 +1934,12 @@ export class TeamMemberLogsFinder {
|
|||
|
||||
const role = this.extractRole(entry);
|
||||
const textContent = this.extractTextContent(entry);
|
||||
if (!teamMatched && textContent && textContent.toLowerCase().includes(normalizedTeam)) {
|
||||
const lowerTextContent = textContent?.toLowerCase();
|
||||
if (!teamMatched && lowerTextContent?.includes(normalizedTeam)) {
|
||||
if (
|
||||
textContent.toLowerCase().includes(`on team "${normalizedTeam}"`) ||
|
||||
textContent.toLowerCase().includes(`on team '${normalizedTeam}'`) ||
|
||||
textContent.toLowerCase().includes(`(${normalizedTeam})`)
|
||||
lowerTextContent.includes(`on team "${normalizedTeam}"`) ||
|
||||
lowerTextContent.includes(`on team '${normalizedTeam}'`) ||
|
||||
lowerTextContent.includes(`(${normalizedTeam})`)
|
||||
) {
|
||||
teamMatched = true;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9139,6 +9139,11 @@ export class TeamProvisioningService {
|
|||
if (ledgerRecord?.createdAt === now) {
|
||||
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) {
|
||||
let proof = await this.applyOpenCodeVisibleDestinationProof({
|
||||
|
|
@ -9412,6 +9417,7 @@ export class TeamProvisioningService {
|
|||
cwd,
|
||||
text: deliveryText,
|
||||
messageId: input.messageId,
|
||||
deliveryAttemptId,
|
||||
fileParts: openCodeFileParts,
|
||||
replyRecipient: input.replyRecipient,
|
||||
actionMode: input.actionMode,
|
||||
|
|
|
|||
|
|
@ -170,6 +170,8 @@ export interface OpenCodeSendMessageCommandBody {
|
|||
memberName: string;
|
||||
text: string;
|
||||
messageId?: string;
|
||||
deliveryAttemptId?: string;
|
||||
payloadHash?: string;
|
||||
fileParts?: {
|
||||
type: 'file';
|
||||
mime: 'image/png' | 'image/jpeg' | 'image/webp';
|
||||
|
|
@ -235,6 +237,45 @@ export interface OpenCodeSendMessageCommandData {
|
|||
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 {
|
||||
runId?: string;
|
||||
laneId: string;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,7 @@
|
|||
import { randomUUID } from 'crypto';
|
||||
|
||||
import type { OpenCodeTeamRuntimeBridgePort } from '../../runtime/OpenCodeTeamRuntimeAdapter';
|
||||
import { stableHash } from './OpenCodeBridgeCommandContract';
|
||||
import type {
|
||||
OpenCodeTeamLaunchReadiness,
|
||||
OpenCodeTeamLaunchReadinessState,
|
||||
|
|
@ -11,6 +14,8 @@ import type {
|
|||
OpenCodeBridgeFailureKind,
|
||||
OpenCodeBridgeResult,
|
||||
OpenCodeBridgeRuntimeSnapshot,
|
||||
OpenCodeCommandStatusCommandBody,
|
||||
OpenCodeCommandStatusCommandData,
|
||||
OpenCodeCleanupHostsCommandBody,
|
||||
OpenCodeCleanupHostsCommandData,
|
||||
OpenCodeLaunchTeamCommandBody,
|
||||
|
|
@ -72,6 +77,16 @@ const DEFAULT_OBSERVE_TIMEOUT_MS = 20_000;
|
|||
const DEFAULT_STOP_TIMEOUT_MS = 30_000;
|
||||
const DEFAULT_CLEANUP_TIMEOUT_MS = 10_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 {
|
||||
private readonly lastRuntimeSnapshotsByProjectPath = new Map<
|
||||
|
|
@ -215,19 +230,34 @@ export class OpenCodeReadinessBridge implements OpenCodeTeamRuntimeBridgePort {
|
|||
async sendOpenCodeTeamMessage(
|
||||
input: OpenCodeSendMessageCommandBody
|
||||
): Promise<OpenCodeSendMessageCommandData> {
|
||||
const commandRequestId = `opencode-send-${randomUUID()}`;
|
||||
const body: OpenCodeSendMessageCommandBody = {
|
||||
...input,
|
||||
payloadHash: input.payloadHash ?? buildSendPayloadHash(input),
|
||||
};
|
||||
const result = await this.bridge.execute<
|
||||
OpenCodeSendMessageCommandBody,
|
||||
OpenCodeSendMessageCommandData
|
||||
>('opencode.sendMessage', input, {
|
||||
cwd: input.projectPath,
|
||||
>('opencode.sendMessage', body, {
|
||||
cwd: body.projectPath,
|
||||
timeoutMs: this.options.sendTimeoutMs ?? DEFAULT_SEND_TIMEOUT_MS,
|
||||
requestId: commandRequestId,
|
||||
});
|
||||
if (result.ok) {
|
||||
return result.data;
|
||||
}
|
||||
if (result.error.kind === 'timeout' && isCommandStatusRecoveryEnabled()) {
|
||||
const recovered = await this.recoverTimedOutSendMessage({
|
||||
originalRequestId: commandRequestId,
|
||||
body,
|
||||
});
|
||||
if (recovered) {
|
||||
return recovered;
|
||||
}
|
||||
}
|
||||
return {
|
||||
accepted: false,
|
||||
memberName: input.memberName,
|
||||
memberName: body.memberName,
|
||||
diagnostics: [
|
||||
{
|
||||
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(
|
||||
input: OpenCodeObserveMessageDeliveryCommandBody
|
||||
): Promise<OpenCodeObserveMessageDeliveryCommandData> {
|
||||
|
|
|
|||
|
|
@ -62,6 +62,7 @@ export interface OpenCodeTeamRuntimeMessageInput {
|
|||
cwd: string;
|
||||
text: string;
|
||||
messageId?: string;
|
||||
deliveryAttemptId?: string;
|
||||
fileParts?: OpenCodeSendMessageCommandBody['fileParts'];
|
||||
replyRecipient?: string;
|
||||
actionMode?: AgentActionMode;
|
||||
|
|
@ -331,6 +332,7 @@ export class OpenCodeTeamRuntimeAdapter implements TeamLaunchRuntimeAdapter {
|
|||
memberName: input.memberName,
|
||||
text: buildOpenCodeRuntimeMessageText(input),
|
||||
messageId: input.messageId,
|
||||
...(input.deliveryAttemptId ? { deliveryAttemptId: input.deliveryAttemptId } : {}),
|
||||
fileParts: input.fileParts,
|
||||
actionMode: input.actionMode,
|
||||
messageKind: input.messageKind,
|
||||
|
|
|
|||
|
|
@ -25,6 +25,8 @@ const SECRET_VALUE_PATTERNS = [
|
|||
|
||||
const DISK_FULL_MESSAGE =
|
||||
'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[] = [
|
||||
{
|
||||
|
|
@ -75,6 +77,16 @@ const RUNTIME_DIAGNOSTIC_RULES: readonly RuntimeDiagnosticRule[] = [
|
|||
tokens: ['codex native exec timed out'],
|
||||
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',
|
||||
tokens: ['opencode bridge command timed out'],
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
import { getClaudeBasePath } from '@main/utils/pathDecoder';
|
||||
import { getCachedShellEnv, getShellPreferredHome } from '@main/utils/shellEnv';
|
||||
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
|
||||
|
|
@ -44,14 +44,23 @@ export function buildMergedCliPath(binaryPath?: string | null): string {
|
|||
} else if (process.platform === 'win32') {
|
||||
extraDirs.push(
|
||||
vendorBinDir,
|
||||
pathJoin(home, 'AppData', 'Roaming', 'npm'),
|
||||
pathJoin(home, 'scoop', 'shims')
|
||||
pathWin32.join(home, 'AppData', 'Roaming', 'npm'),
|
||||
pathWin32.join(home, 'scoop', 'shims'),
|
||||
pathWin32.join(home, '.bun', 'bin'),
|
||||
pathWin32.join(home, '.cargo', 'bin'),
|
||||
pathWin32.join(home, '.volta', 'bin')
|
||||
);
|
||||
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) {
|
||||
extraDirs.push(pathJoin(process.env.ProgramFiles, 'claude'));
|
||||
extraDirs.push(
|
||||
pathWin32.join(process.env.ProgramFiles, 'claude'),
|
||||
pathWin32.join(process.env.ProgramFiles, 'nodejs')
|
||||
);
|
||||
}
|
||||
} else {
|
||||
extraDirs.push(
|
||||
|
|
@ -60,8 +69,20 @@ export function buildMergedCliPath(binaryPath?: string | null): string {
|
|||
pathPosix.join(home, '.local', 'bin'),
|
||||
pathPosix.join(home, '.npm-global', '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',
|
||||
'/opt/homebrew/bin'
|
||||
'/opt/homebrew/bin',
|
||||
'/opt/local/bin',
|
||||
'/usr/bin',
|
||||
'/bin',
|
||||
'/usr/sbin',
|
||||
'/sbin'
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -16,9 +16,13 @@ import { spawn } from 'child_process';
|
|||
const logger = createLogger('Utils:shellEnv');
|
||||
|
||||
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 shellEnvResolvePromise: Promise<NodeJS.ProcessEnv> | null = null;
|
||||
let shellEnvFailureCooldownUntil = 0;
|
||||
let lastShellEnvFailureMessage: string | null = null;
|
||||
|
||||
export interface ShellEnvResolveProgress {
|
||||
phase: string;
|
||||
|
|
@ -29,6 +33,19 @@ export interface ShellEnvResolveOptions {
|
|||
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(
|
||||
options: ShellEnvResolveOptions | undefined,
|
||||
phase: string,
|
||||
|
|
@ -37,6 +54,16 @@ function emitProgress(
|
|||
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 {
|
||||
const parsed: NodeJS.ProcessEnv = {};
|
||||
const lines = content.split('\0');
|
||||
|
|
@ -93,12 +120,22 @@ async function readShellEnv(shellPath: string, args: string[]): Promise<NodeJS.P
|
|||
reject(error);
|
||||
}
|
||||
});
|
||||
child.once('close', () => {
|
||||
child.once('close', (code: number | null, signal: NodeJS.Signals | null) => {
|
||||
if (timeoutHandle) {
|
||||
clearTimeout(timeoutHandle);
|
||||
}
|
||||
if (!settled) {
|
||||
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'));
|
||||
}
|
||||
});
|
||||
|
|
@ -135,6 +172,7 @@ export async function resolveInteractiveShellEnv(
|
|||
emitProgress(options, 'shell-env-login', 'Reading login shell environment...');
|
||||
const loginEnv = await readShellEnv(shellPath, ['-lic', 'env -0']);
|
||||
cachedInteractiveShellEnv = loginEnv;
|
||||
clearShellEnvFailure();
|
||||
return loginEnv;
|
||||
} catch (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...');
|
||||
const interactiveEnv = await readShellEnv(shellPath, ['-ic', 'env -0']);
|
||||
cachedInteractiveShellEnv = interactiveEnv;
|
||||
clearShellEnvFailure();
|
||||
return interactiveEnv;
|
||||
} catch (interactiveError) {
|
||||
const interactiveMessage =
|
||||
interactiveError instanceof Error ? interactiveError.message : String(interactiveError);
|
||||
logger.warn(`Failed to resolve interactive shell env: ${interactiveMessage}`);
|
||||
rememberShellEnvFailure(interactiveMessage);
|
||||
emitProgress(options, 'shell-env-fallback', 'Using current process environment...');
|
||||
return {};
|
||||
}
|
||||
|
|
@ -159,12 +199,80 @@ export async function resolveInteractiveShellEnv(
|
|||
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.
|
||||
*/
|
||||
export function clearShellEnvCache(): void {
|
||||
cachedInteractiveShellEnv = null;
|
||||
shellEnvResolvePromise = null;
|
||||
clearShellEnvFailure();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -491,6 +491,16 @@ export const CLI_INSTALLER_PROGRESS = 'cliInstaller:progress';
|
|||
|
||||
/** Invalidate cached CLI status (forces fresh check on next getStatus) */
|
||||
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 {
|
||||
TMUX_CANCEL_INSTALL,
|
||||
TMUX_GET_INSTALLER_SNAPSHOT,
|
||||
|
|
|
|||
|
|
@ -59,6 +59,10 @@ import {
|
|||
MCP_REGISTRY_INSTALL_CUSTOM,
|
||||
MCP_REGISTRY_SEARCH,
|
||||
MCP_REGISTRY_UNINSTALL,
|
||||
OPENCODE_RUNTIME_GET_STATUS,
|
||||
OPENCODE_RUNTIME_INSTALL,
|
||||
OPENCODE_RUNTIME_INVALIDATE_STATUS,
|
||||
OPENCODE_RUNTIME_PROGRESS,
|
||||
PLUGIN_GET_ALL,
|
||||
PLUGIN_GET_README,
|
||||
PLUGIN_INSTALL,
|
||||
|
|
@ -289,6 +293,7 @@ import type {
|
|||
MessagesPage,
|
||||
NotificationTrigger,
|
||||
OpenCodeRuntimeDeliveryStatus,
|
||||
OpenCodeRuntimeStatus,
|
||||
ProjectBranchChangeEvent,
|
||||
RejectResult,
|
||||
ReplaceMembersRequest,
|
||||
|
|
@ -319,9 +324,9 @@ import type {
|
|||
TeamCreateRequest,
|
||||
TeamCreateResponse,
|
||||
TeamGetDataOptions,
|
||||
TeamLaunchFailureDiagnosticsBundle,
|
||||
TeamLaunchRequest,
|
||||
TeamLaunchResponse,
|
||||
TeamLaunchFailureDiagnosticsBundle,
|
||||
TeamMemberActivityMeta,
|
||||
TeamMessageNotificationData,
|
||||
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 }),
|
||||
|
||||
// ===== Terminal API =====
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ import type {
|
|||
KanbanColumnId,
|
||||
NotificationsAPI,
|
||||
NotificationTrigger,
|
||||
OpenCodeRuntimeAPI,
|
||||
OpenCodeRuntimeDeliveryStatus,
|
||||
PaginatedSessionsResult,
|
||||
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 = {
|
||||
loadView: async (input) => ({
|
||||
schemaVersion: 1,
|
||||
|
|
|
|||
|
|
@ -73,7 +73,12 @@ import {
|
|||
} 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
|
||||
|
|
@ -89,8 +94,6 @@ const VARIANT_STYLES: Record<BannerVariant, { border: string; bg: string }> = {
|
|||
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). */
|
||||
const BANNER_MIN_H = 'min-h-[4.25rem]';
|
||||
const ANTHROPIC_LIMIT_REFRESH_INTERVAL_MS = 60 * 1000;
|
||||
|
|
@ -352,8 +355,11 @@ interface InstalledBannerProps {
|
|||
};
|
||||
codexRateLimitsLoading: boolean;
|
||||
anthropicRateLimitsRefreshing: boolean;
|
||||
openCodeRuntimeStatus: OpenCodeRuntimeStatus | null;
|
||||
openCodeRuntimeStatusLoading: boolean;
|
||||
isBusy: boolean;
|
||||
onInstall: () => void;
|
||||
onOpenCodeInstall: () => void;
|
||||
onRefresh: () => void;
|
||||
onToggleProvidersCollapsed: () => void;
|
||||
onProviderLogin: (providerId: CliProviderId) => void;
|
||||
|
|
@ -570,19 +576,51 @@ function hasVisibleAuthenticatedMultimodelProvider(
|
|||
return visibleProviders.some((provider) => provider.authenticated);
|
||||
}
|
||||
|
||||
function shouldShowOpenCodeDownloadAction(
|
||||
function shouldShowOpenCodeInstallAction(
|
||||
provider: CliProviderStatus,
|
||||
showSkeleton: boolean
|
||||
showSkeleton: boolean,
|
||||
openCodeRuntimeStatus: OpenCodeRuntimeStatus | null
|
||||
): boolean {
|
||||
return (
|
||||
provider.providerId === 'opencode' &&
|
||||
!showSkeleton &&
|
||||
!provider.supported &&
|
||||
!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 = ({
|
||||
cliStatus,
|
||||
sourceProviderMap,
|
||||
|
|
@ -594,8 +632,11 @@ const InstalledBanner = ({
|
|||
providerConnectionAuthModes,
|
||||
codexRateLimitsLoading,
|
||||
anthropicRateLimitsRefreshing,
|
||||
openCodeRuntimeStatus,
|
||||
openCodeRuntimeStatusLoading,
|
||||
isBusy,
|
||||
onInstall,
|
||||
onOpenCodeInstall,
|
||||
onRefresh,
|
||||
onToggleProvidersCollapsed,
|
||||
onProviderLogin,
|
||||
|
|
@ -901,19 +942,38 @@ const InstalledBanner = ({
|
|||
) : null}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-start gap-2">
|
||||
{shouldShowOpenCodeDownloadAction(provider, showSkeleton) ? (
|
||||
{shouldShowOpenCodeInstallAction(
|
||||
provider,
|
||||
showSkeleton,
|
||||
openCodeRuntimeStatus
|
||||
) ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void api.openExternal(OPENCODE_DOWNLOAD_URL)}
|
||||
className="flex items-center gap-1 rounded-md border px-2 py-[3px] text-[10px] font-medium transition-colors hover:bg-white/5"
|
||||
onClick={onOpenCodeInstall}
|
||||
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={{
|
||||
borderColor: 'rgba(14, 165, 233, 0.36)',
|
||||
color: '#7dd3fc',
|
||||
}}
|
||||
title="Download OpenCode CLI"
|
||||
title={
|
||||
openCodeRuntimeStatus?.error ??
|
||||
openCodeRuntimeStatus?.progress?.detail ??
|
||||
'Install OpenCode CLI into app data'
|
||||
}
|
||||
>
|
||||
<Download className="size-3" />
|
||||
Download
|
||||
{isOpenCodeRuntimeInstalling(
|
||||
openCodeRuntimeStatus,
|
||||
openCodeRuntimeStatusLoading
|
||||
) ? (
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
) : (
|
||||
<Download className="size-3" />
|
||||
)}
|
||||
{getOpenCodeInstallLabel(openCodeRuntimeStatus)}
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
|
|
@ -1028,11 +1088,14 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
|||
installerDetail,
|
||||
installerRawChunks,
|
||||
completedVersion,
|
||||
openCodeRuntimeStatus,
|
||||
openCodeRuntimeStatusLoading,
|
||||
bootstrapCliStatus,
|
||||
fetchCliStatus,
|
||||
fetchCliProviderStatus,
|
||||
invalidateCliStatus,
|
||||
installCli,
|
||||
installOpenCodeRuntime,
|
||||
isBusy,
|
||||
} = useCliInstaller();
|
||||
|
||||
|
|
@ -1465,8 +1528,11 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
|||
providerConnectionAuthModes={providerConnectionAuthModes}
|
||||
codexRateLimitsLoading={codexAccount.rateLimitsLoading}
|
||||
anthropicRateLimitsRefreshing={anthropicRateLimitsRefreshing}
|
||||
openCodeRuntimeStatus={openCodeRuntimeStatus}
|
||||
openCodeRuntimeStatusLoading={openCodeRuntimeStatusLoading}
|
||||
isBusy={isBusy}
|
||||
onInstall={handleInstall}
|
||||
onOpenCodeInstall={() => void installOpenCodeRuntime()}
|
||||
onRefresh={handleRefresh}
|
||||
onToggleProvidersCollapsed={handleToggleProvidersCollapsed}
|
||||
onProviderLogin={handleProviderLogin}
|
||||
|
|
@ -1694,8 +1760,11 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
|||
providerConnectionAuthModes={providerConnectionAuthModes}
|
||||
codexRateLimitsLoading={codexAccount.rateLimitsLoading}
|
||||
anthropicRateLimitsRefreshing={anthropicRateLimitsRefreshing}
|
||||
openCodeRuntimeStatus={openCodeRuntimeStatus}
|
||||
openCodeRuntimeStatusLoading={openCodeRuntimeStatusLoading}
|
||||
isBusy={isBusy}
|
||||
onInstall={handleInstall}
|
||||
onOpenCodeInstall={() => void installOpenCodeRuntime()}
|
||||
onRefresh={handleRefresh}
|
||||
onToggleProvidersCollapsed={handleToggleProvidersCollapsed}
|
||||
onProviderLogin={handleProviderLogin}
|
||||
|
|
@ -1757,8 +1826,11 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
|||
providerConnectionAuthModes={providerConnectionAuthModes}
|
||||
codexRateLimitsLoading={codexAccount.rateLimitsLoading}
|
||||
anthropicRateLimitsRefreshing={anthropicRateLimitsRefreshing}
|
||||
openCodeRuntimeStatus={openCodeRuntimeStatus}
|
||||
openCodeRuntimeStatusLoading={openCodeRuntimeStatusLoading}
|
||||
isBusy={isBusy}
|
||||
onInstall={handleInstall}
|
||||
onOpenCodeInstall={() => void installOpenCodeRuntime()}
|
||||
onRefresh={handleRefresh}
|
||||
onToggleProvidersCollapsed={handleToggleProvidersCollapsed}
|
||||
onProviderLogin={handleProviderLogin}
|
||||
|
|
@ -1980,8 +2052,11 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
|||
providerConnectionAuthModes={providerConnectionAuthModes}
|
||||
codexRateLimitsLoading={codexAccount.rateLimitsLoading}
|
||||
anthropicRateLimitsRefreshing={anthropicRateLimitsRefreshing}
|
||||
openCodeRuntimeStatus={openCodeRuntimeStatus}
|
||||
openCodeRuntimeStatusLoading={openCodeRuntimeStatusLoading}
|
||||
isBusy={isBusy}
|
||||
onInstall={handleInstall}
|
||||
onOpenCodeInstall={() => void installOpenCodeRuntime()}
|
||||
onRefresh={handleRefresh}
|
||||
onToggleProvidersCollapsed={handleToggleProvidersCollapsed}
|
||||
onProviderLogin={handleProviderLogin}
|
||||
|
|
|
|||
|
|
@ -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 = ({
|
||||
providerId,
|
||||
models,
|
||||
|
|
@ -57,7 +67,10 @@ export const ProviderModelBadges = ({
|
|||
readonly providerId: CliProviderId;
|
||||
readonly models: string[];
|
||||
readonly modelAvailability?: CliProviderModelAvailability[];
|
||||
readonly providerStatus?: Pick<CliProviderStatus, 'providerId' | 'authMethod' | 'backend'> | null;
|
||||
readonly providerStatus?: Pick<
|
||||
CliProviderStatus,
|
||||
'providerId' | 'authMethod' | 'backend' | 'modelCatalog'
|
||||
> | null;
|
||||
readonly collapseAfter?: number;
|
||||
readonly expandedMaxHeightPx?: number;
|
||||
}): React.JSX.Element => {
|
||||
|
|
@ -89,15 +102,29 @@ export const ProviderModelBadges = ({
|
|||
const availabilityStatus = getAvailabilityStatus(model, displayModelAvailability);
|
||||
const availabilityReason = getAvailabilityReason(model, displayModelAvailability);
|
||||
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 (
|
||||
<span
|
||||
key={`${model}-${index}`}
|
||||
className={badgeClassName}
|
||||
style={badgeStyle}
|
||||
title={availabilityReason ?? availabilityChip ?? undefined}
|
||||
title={title || undefined}
|
||||
>
|
||||
<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 ? (
|
||||
<span
|
||||
className={cn(
|
||||
|
|
|
|||
|
|
@ -92,7 +92,7 @@ import { type MemberActivityFilter, type MemberDetailTab } from './members/membe
|
|||
import type { AddMemberEntry } from './dialogs/AddMemberDialog';
|
||||
import type { TeamLaunchDialogMode } from './dialogs/LaunchTeamDialog';
|
||||
import type { TeamMessagesPanelMode } from '@renderer/types/teamMessagesPanelMode';
|
||||
import type { ComponentProps, CSSProperties } from 'react';
|
||||
import type { ComponentProps, CSSProperties, RefObject } from 'react';
|
||||
|
||||
const LaunchTeamDialog = lazy(() =>
|
||||
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({
|
||||
teamName,
|
||||
onLaunch,
|
||||
|
|
@ -2120,17 +2441,14 @@ export const TeamDetailView = memo(function TeamDetailView({
|
|||
const renderBody = (): React.JSX.Element => {
|
||||
if ((loading && !data) || (data && data.teamName !== teamName)) {
|
||||
return (
|
||||
<div className="size-full overflow-auto p-4">
|
||||
<div className="mb-4 h-10 animate-pulse rounded-md bg-[var(--color-surface-raised)]" />
|
||||
<div ref={provisioningBannerRef}>
|
||||
<TeamProvisioningBanner teamName={teamName} />
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div className="h-24 animate-pulse rounded-md bg-[var(--color-surface-raised)]" />
|
||||
<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>
|
||||
<TeamLoadingSkeleton
|
||||
teamName={teamName}
|
||||
isActive={isThisTabActive}
|
||||
isFocused={isPaneFocused}
|
||||
messagesPanelMode={messagesPanelMode}
|
||||
contentRef={contentRef}
|
||||
provisioningBannerRef={provisioningBannerRef}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
it('scans unknown pending tasks only when they have work evidence', () => {
|
||||
const plan = buildTeamChangeRequestPlan(
|
||||
|
|
@ -67,24 +78,53 @@ describe('buildTeamChangeRequestPlan', () => {
|
|||
});
|
||||
|
||||
it('caps selected requests and reports deferred candidates', () => {
|
||||
const plan = buildTeamChangeRequestPlan(
|
||||
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
|
||||
);
|
||||
const plan = buildTeamChangeRequestPlan(changedTasks(TEAM_CHANGES_MAX_REQUESTS + 5), 0, false);
|
||||
|
||||
expect(plan.requests).toHaveLength(TEAM_CHANGES_MAX_REQUESTS);
|
||||
expect(plan.eligibleCount).toBe(TEAM_CHANGES_MAX_REQUESTS + 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', () => {
|
||||
const tasks = Array.from({ length: TEAM_CHANGES_UNKNOWN_SCAN_LIMIT + 4 }, (_, index) =>
|
||||
task({
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import { type TeamChangeSummaryState, useTeamChangesSummaries } from '../useTeam
|
|||
import type {
|
||||
TaskChangeSetV2,
|
||||
TeamTaskChangeSummariesResponse,
|
||||
TeamTaskChangeSummaryRequest,
|
||||
TeamTaskWithKanban,
|
||||
} 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 {
|
||||
return {
|
||||
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 {
|
||||
return {
|
||||
...changeSet(),
|
||||
|
|
@ -197,19 +222,22 @@ interface HookSnapshot {
|
|||
error: string | null;
|
||||
badgeCount: number | null;
|
||||
summariesByTaskId: Record<string, TeamChangeSummaryState>;
|
||||
refresh: () => void;
|
||||
}
|
||||
|
||||
const HookHarness = ({
|
||||
tasks,
|
||||
sectionOpen = true,
|
||||
onSnapshot,
|
||||
}: {
|
||||
tasks: TeamTaskWithKanban[];
|
||||
sectionOpen?: boolean;
|
||||
onSnapshot: (snapshot: HookSnapshot) => void;
|
||||
}): null => {
|
||||
const state = useTeamChangesSummaries({
|
||||
teamName: 'team-a',
|
||||
tasks,
|
||||
sectionOpen: true,
|
||||
sectionOpen,
|
||||
});
|
||||
React.useEffect(() => {
|
||||
onSnapshot({
|
||||
|
|
@ -218,12 +246,14 @@ const HookHarness = ({
|
|||
error: state.error,
|
||||
badgeCount: state.badgeCount,
|
||||
summariesByTaskId: state.summariesByTaskId,
|
||||
refresh: state.refresh,
|
||||
});
|
||||
}, [
|
||||
onSnapshot,
|
||||
state.badgeCount,
|
||||
state.error,
|
||||
state.loading,
|
||||
state.refresh,
|
||||
state.refreshing,
|
||||
state.summariesByTaskId,
|
||||
]);
|
||||
|
|
@ -699,6 +729,296 @@ describe('useTeamChangesSummaries', () => {
|
|||
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 () => {
|
||||
const first = createDeferred<TeamTaskChangeSummariesResponse>();
|
||||
const second = createDeferred<TeamTaskChangeSummariesResponse>();
|
||||
|
|
@ -759,7 +1079,7 @@ describe('useTeamChangesSummaries', () => {
|
|||
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 second = createDeferred<TeamTaskChangeSummariesResponse>();
|
||||
hoisted.getTeamTaskChangeSummaries
|
||||
|
|
@ -804,13 +1124,16 @@ describe('useTeamChangesSummaries', () => {
|
|||
});
|
||||
|
||||
expect(container.textContent).toContain('Loading changes...');
|
||||
expect(hoisted.getTeamTaskChangeSummaries).toHaveBeenCalledTimes(2);
|
||||
|
||||
await act(async () => {
|
||||
first.reject(new Error('silent count failed'));
|
||||
await first.promise.catch(() => undefined);
|
||||
first.resolve(lowConfidenceFileResponse());
|
||||
await first.promise;
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain('Loading changes...');
|
||||
expect(container.textContent).not.toContain('src/app.ts');
|
||||
expect(hoisted.getTeamTaskChangeSummaries).toHaveBeenCalledTimes(2);
|
||||
|
||||
await act(async () => {
|
||||
|
|
@ -820,7 +1143,6 @@ describe('useTeamChangesSummaries', () => {
|
|||
});
|
||||
|
||||
expect(container.textContent).toContain('src/app.ts');
|
||||
expect(container.textContent).not.toContain('silent count failed');
|
||||
} finally {
|
||||
if (scrollIntoViewDescriptor) {
|
||||
Object.defineProperty(Element.prototype, 'scrollIntoView', scrollIntoViewDescriptor);
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo
|
|||
import { Checkbox } from '@renderer/components/ui/checkbox';
|
||||
import { Input } from '@renderer/components/ui/input';
|
||||
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 {
|
||||
Tooltip,
|
||||
|
|
@ -26,6 +27,7 @@ import {
|
|||
isTeamProviderModelVerificationPending,
|
||||
normalizeTeamModelForUi,
|
||||
TEAM_MODEL_UI_DISABLED_BADGE_LABEL,
|
||||
type TeamRuntimeModelOption,
|
||||
} from '@renderer/utils/teamModelAvailability';
|
||||
import {
|
||||
doesTeamModelCarryProviderBrand,
|
||||
|
|
@ -34,9 +36,7 @@ import {
|
|||
getTeamModelLabel as getCatalogTeamModelLabel,
|
||||
getTeamModelSourceBadgeLabel,
|
||||
getTeamProviderLabel as getCatalogTeamProviderLabel,
|
||||
isAnthropicHaikuTeamModel,
|
||||
} from '@renderer/utils/teamModelCatalog';
|
||||
import { extractProviderScopedBaseModel } from '@renderer/utils/teamModelContext';
|
||||
import {
|
||||
compareTeamModelRecommendations,
|
||||
getTeamModelRecommendation,
|
||||
|
|
@ -44,8 +44,19 @@ import {
|
|||
} from '@renderer/utils/teamModelRecommendations';
|
||||
import { resolveAnthropicLaunchModel } from '@shared/utils/anthropicLaunchModel';
|
||||
import { getAnthropicDefaultTeamModel } from '@shared/utils/anthropicModelDefaults';
|
||||
import { parseOpenCodeQualifiedModelRef } from '@shared/utils/opencodeModelRef';
|
||||
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';
|
||||
|
||||
|
|
@ -59,6 +70,18 @@ interface ProviderDef {
|
|||
comingSoon: boolean;
|
||||
}
|
||||
|
||||
interface OpenCodeSourceOption {
|
||||
id: string;
|
||||
label: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
interface OpenCodeModelGroup {
|
||||
sourceId: string;
|
||||
sourceLabel: string;
|
||||
options: TeamRuntimeModelOption[];
|
||||
}
|
||||
|
||||
const PROVIDERS: ProviderDef[] = [
|
||||
{ id: 'anthropic', label: 'Anthropic', comingSoon: false },
|
||||
{ id: 'codex', label: 'Codex', comingSoon: false },
|
||||
|
|
@ -66,6 +89,18 @@ const PROVIDERS: ProviderDef[] = [
|
|||
{ 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.';
|
||||
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.';
|
||||
|
|
@ -175,14 +210,16 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
|
|||
const multimodelEnabled = useStore((s) => s.appConfig?.general?.multimodelEnabled ?? true);
|
||||
const [recommendedOnly, setRecommendedOnly] = useState(false);
|
||||
const [modelQuery, setModelQuery] = useState('');
|
||||
const [openCodeSourceFilterOpen, setOpenCodeSourceFilterOpen] = useState(false);
|
||||
const [openCodeSourceQuery, setOpenCodeSourceQuery] = useState('');
|
||||
const [selectedOpenCodeSourceIds, setSelectedOpenCodeSourceIds] = useState<Set<string>>(
|
||||
() => new Set()
|
||||
);
|
||||
|
||||
const effectiveProviderId =
|
||||
disableGeminiOption && isGeminiUiFrozen() && providerId === 'gemini' ? 'anthropic' : providerId;
|
||||
const {
|
||||
cliStatus: effectiveCliStatus,
|
||||
providerStatus: runtimeProviderStatus,
|
||||
loading: effectiveCliStatusLoading,
|
||||
} = useEffectiveCliProviderStatus(effectiveProviderId);
|
||||
const { cliStatus: effectiveCliStatus, providerStatus: runtimeProviderStatus } =
|
||||
useEffectiveCliProviderStatus(effectiveProviderId);
|
||||
const multimodelAvailable =
|
||||
multimodelEnabled || effectiveCliStatus?.flavor === 'agent_teams_orchestrator';
|
||||
const runtimeProviderStatusById = useMemo(
|
||||
|
|
@ -324,14 +361,112 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
|
|||
|
||||
useEffect(() => {
|
||||
if (effectiveProviderId !== 'opencode' || !hasRecommendedOpenCodeModels) {
|
||||
setRecommendedOnly(false);
|
||||
queueMicrotask(() => setRecommendedOnly(false));
|
||||
}
|
||||
}, [effectiveProviderId, hasRecommendedOpenCodeModels]);
|
||||
|
||||
useEffect(() => {
|
||||
setModelQuery('');
|
||||
queueMicrotask(() => setModelQuery(''));
|
||||
}, [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 normalizedModelQuery = modelQuery.trim().toLowerCase();
|
||||
const matchesModelQuery = (option: (typeof modelOptions)[number]): boolean => {
|
||||
|
|
@ -343,6 +478,7 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
|
|||
option.value,
|
||||
option.label,
|
||||
option.badgeLabel ?? '',
|
||||
getOpenCodeSourceInfo(option.value)?.label ?? '',
|
||||
modelRecommendation?.label ?? '',
|
||||
modelRecommendation?.reason ?? '',
|
||||
]
|
||||
|
|
@ -362,6 +498,13 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
|
|||
({ option }) =>
|
||||
!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))
|
||||
.sort((left, right) => {
|
||||
const recommendationOrder = compareTeamModelRecommendations(
|
||||
|
|
@ -381,11 +524,201 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
|
|||
...modelOptions.filter((option) => option.value.trim().length === 0),
|
||||
...concreteOptions,
|
||||
].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 shouldShowModelSearch = concreteModelOptionCount > 8;
|
||||
const trimmedModelQuery = modelQuery.trim();
|
||||
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 (
|
||||
<div className="mb-5">
|
||||
|
|
@ -483,229 +816,158 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
|
|||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{hasRecommendedOpenCodeModels ? (
|
||||
<div className="mb-2 flex w-fit items-center gap-2">
|
||||
<Checkbox
|
||||
id="opencode-team-model-recommended-only"
|
||||
checked={recommendedOnly}
|
||||
onCheckedChange={(checked) => setRecommendedOnly(checked === true)}
|
||||
className="size-3.5"
|
||||
/>
|
||||
<Label
|
||||
htmlFor="opencode-team-model-recommended-only"
|
||||
className="cursor-pointer text-[11px] font-normal text-[var(--color-text-secondary)]"
|
||||
>
|
||||
Recommended only
|
||||
</Label>
|
||||
{(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 ? (
|
||||
<div className="flex w-fit items-center gap-2">
|
||||
<Checkbox
|
||||
id="opencode-team-model-recommended-only"
|
||||
checked={recommendedOnly}
|
||||
onCheckedChange={(checked) => setRecommendedOnly(checked === true)}
|
||||
className="size-3.5"
|
||||
/>
|
||||
<Label
|
||||
htmlFor="opencode-team-model-recommended-only"
|
||||
className="cursor-pointer text-[11px] font-normal text-[var(--color-text-secondary)]"
|
||||
>
|
||||
Recommended only
|
||||
</Label>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<div
|
||||
data-testid="team-model-selector-model-grid"
|
||||
className={cn(
|
||||
'grid gap-1.5 rounded-md bg-[var(--color-surface)]',
|
||||
shouldConstrainModelListHeight && 'overflow-y-auto pr-1'
|
||||
)}
|
||||
style={{
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))',
|
||||
maxHeight: shouldConstrainModelListHeight ? 400 : undefined,
|
||||
}}
|
||||
>
|
||||
{visibleModelOptions.map((opt) =>
|
||||
(() => {
|
||||
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>
|
||||
)}
|
||||
{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>
|
||||
</button>
|
||||
);
|
||||
})()
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="grid gap-1.5"
|
||||
style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))' }}
|
||||
>
|
||||
{group.options.map(renderModelOption)}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
data-testid="team-model-selector-model-grid"
|
||||
className={cn(
|
||||
'grid gap-1.5 rounded-md bg-[var(--color-surface)]',
|
||||
shouldConstrainModelListHeight && 'overflow-y-auto pr-1'
|
||||
)}
|
||||
style={{
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))',
|
||||
maxHeight: shouldConstrainModelListHeight ? 400 : undefined,
|
||||
}}
|
||||
>
|
||||
{visibleModelOptions.map(renderModelOption)}
|
||||
</div>
|
||||
)}
|
||||
{visibleModelOptions.length === 0 ? (
|
||||
<div className="rounded-md border border-white/10 px-3 py-2 text-xs text-[var(--color-text-muted)]">
|
||||
{trimmedModelQuery
|
||||
|
|
|
|||
|
|
@ -13,6 +13,12 @@ export const TEAM_CHANGES_MAX_REQUESTS = 120;
|
|||
export const TEAM_CHANGES_UNKNOWN_SCAN_LIMIT = 32;
|
||||
export const TEAM_CHANGES_MAX_RENDERED_FILE_ROWS = 300;
|
||||
|
||||
interface TeamChangeRequestPlanOptions {
|
||||
maxRequests?: number;
|
||||
unknownScanLimit?: number;
|
||||
satisfiedTaskIds?: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
interface TeamChangeCandidate {
|
||||
task: TeamTaskWithKanban;
|
||||
options: TaskChangeRequestOptions;
|
||||
|
|
@ -49,6 +55,13 @@ function rotateCandidates<T>(items: T[], cursor: number): T[] {
|
|||
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 {
|
||||
if ((task.workIntervals?.length ?? 0) > 0 || (task.reviewIntervals?.length ?? 0) > 0) {
|
||||
return true;
|
||||
|
|
@ -77,8 +90,15 @@ function getRelevantHistoryEvents(task: TeamTaskWithKanban): { type: string; tim
|
|||
export function buildTeamChangeRequestPlan(
|
||||
tasks: TeamTaskWithKanban[],
|
||||
unknownScanCursor: number,
|
||||
forceFresh: boolean
|
||||
forceFresh: boolean,
|
||||
options: TeamChangeRequestPlanOptions = {}
|
||||
): 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 active: TeamChangeCandidate[] = [];
|
||||
const unknown: TeamChangeCandidate[] = [];
|
||||
|
|
@ -128,11 +148,22 @@ export function buildTeamChangeRequestPlan(
|
|||
const eligibleTaskIds = new Set(
|
||||
[...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,
|
||||
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 requests = selected.map((candidate) => {
|
||||
const options = {
|
||||
|
|
@ -148,9 +179,9 @@ export function buildTeamChangeRequestPlan(
|
|||
});
|
||||
const eligibleCount = primary.length + active.length + unknown.length;
|
||||
const nextUnknownScanCursor =
|
||||
unknown.length > 0
|
||||
? (unknownScanCursor + Math.min(TEAM_CHANGES_UNKNOWN_SCAN_LIMIT, unknown.length)) %
|
||||
unknown.length
|
||||
requestUnknown.length > 0
|
||||
? (unknownScanCursor + Math.min(unknownScanLimit, requestUnknown.length)) %
|
||||
requestUnknown.length
|
||||
: 0;
|
||||
|
||||
return {
|
||||
|
|
@ -159,7 +190,7 @@ export function buildTeamChangeRequestPlan(
|
|||
eligibleTaskIds,
|
||||
eligibleCount,
|
||||
requestedCount: requests.length,
|
||||
deferredCount: Math.max(0, eligibleCount - requests.length),
|
||||
deferredCount: Math.max(0, eligibleCount - satisfiedEligibleTaskIds.size - requests.length),
|
||||
nextUnknownScanCursor,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { withTeamChangesLoadTimeout } from './teamChangesLoadTimeout';
|
|||
import {
|
||||
buildTeamChangeRequestPlan,
|
||||
buildTeamChangesTasksFingerprint,
|
||||
TEAM_CHANGES_MAX_REQUESTS,
|
||||
} from './teamChangesRequestPlan';
|
||||
|
||||
import type {
|
||||
|
|
@ -20,6 +21,14 @@ import type {
|
|||
|
||||
const TEAM_CHANGES_AUTO_REFRESH_MS = 30_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;
|
||||
|
||||
export interface TeamChangeSummaryState {
|
||||
|
|
@ -41,6 +50,11 @@ interface TeamChangesLoadOptions {
|
|||
storeSummaries?: boolean;
|
||||
reportError?: boolean;
|
||||
blockAutoRetryOnError?: boolean;
|
||||
maxRequests?: number;
|
||||
unknownScanLimit?: number;
|
||||
queueDeferredRefresh?: boolean;
|
||||
satisfiedTaskIds?: ReadonlySet<string>;
|
||||
stagedRefreshPlan?: readonly number[];
|
||||
}
|
||||
|
||||
interface UseTeamChangesSummariesInput {
|
||||
|
|
@ -180,6 +194,40 @@ function isDocumentHidden(): boolean {
|
|||
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({
|
||||
teamName,
|
||||
tasks,
|
||||
|
|
@ -205,6 +253,7 @@ export function useTeamChangesSummaries({
|
|||
const mountedRef = useRef(true);
|
||||
const requestSeqRef = useRef(0);
|
||||
const activeRequestSeqRef = useRef<number | null>(null);
|
||||
const activeRequestOptionsRef = useRef<TeamChangesLoadOptions | null>(null);
|
||||
const queuedRefreshOptionsRef = useRef<TeamChangesLoadOptions | null>(null);
|
||||
const autoRefreshBlockedUntilRef = useRef(0);
|
||||
const unknownScanCursorRef = useRef(0);
|
||||
|
|
@ -218,6 +267,7 @@ export function useTeamChangesSummaries({
|
|||
mountedRef.current = false;
|
||||
requestSeqRef.current += 1;
|
||||
activeRequestSeqRef.current = null;
|
||||
activeRequestOptionsRef.current = null;
|
||||
queuedRefreshOptionsRef.current = null;
|
||||
autoRefreshBlockedUntilRef.current = 0;
|
||||
hasLoadedRef.current = false;
|
||||
|
|
@ -235,6 +285,11 @@ export function useTeamChangesSummaries({
|
|||
storeSummaries = true,
|
||||
reportError = true,
|
||||
blockAutoRetryOnError = true,
|
||||
maxRequests,
|
||||
unknownScanLimit,
|
||||
queueDeferredRefresh = false,
|
||||
satisfiedTaskIds,
|
||||
stagedRefreshPlan,
|
||||
}: TeamChangesLoadOptions = {}): Promise<void> => {
|
||||
if (forceFresh) {
|
||||
autoRefreshBlockedUntilRef.current = 0;
|
||||
|
|
@ -242,6 +297,17 @@ export function useTeamChangesSummaries({
|
|||
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) {
|
||||
const previous = queuedRefreshOptionsRef.current;
|
||||
queuedRefreshOptionsRef.current = {
|
||||
|
|
@ -255,6 +321,31 @@ export function useTeamChangesSummaries({
|
|||
blockAutoRetryOnError: previous
|
||||
? Boolean(previous.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) {
|
||||
setLoading(true);
|
||||
|
|
@ -267,7 +358,11 @@ export function useTeamChangesSummaries({
|
|||
return;
|
||||
}
|
||||
|
||||
const plan = buildTeamChangeRequestPlan(tasks, unknownScanCursorRef.current, forceFresh);
|
||||
const plan = buildTeamChangeRequestPlan(tasks, unknownScanCursorRef.current, forceFresh, {
|
||||
maxRequests,
|
||||
unknownScanLimit,
|
||||
satisfiedTaskIds,
|
||||
});
|
||||
unknownScanCursorRef.current = plan.nextUnknownScanCursor;
|
||||
const requestSeq = requestSeqRef.current + 1;
|
||||
requestSeqRef.current = requestSeq;
|
||||
|
|
@ -296,6 +391,19 @@ export function useTeamChangesSummaries({
|
|||
setRefreshing(true);
|
||||
}
|
||||
activeRequestSeqRef.current = requestSeq;
|
||||
activeRequestOptionsRef.current = {
|
||||
forceFresh,
|
||||
showSpinner,
|
||||
preserveOnError,
|
||||
storeSummaries,
|
||||
reportError,
|
||||
blockAutoRetryOnError,
|
||||
maxRequests,
|
||||
unknownScanLimit,
|
||||
queueDeferredRefresh,
|
||||
satisfiedTaskIds,
|
||||
stagedRefreshPlan,
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await withTeamChangesLoadTimeout(
|
||||
|
|
@ -358,11 +466,32 @@ export function useTeamChangesSummaries({
|
|||
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) {
|
||||
if (!mountedRef.current || requestSeqRef.current !== requestSeq) {
|
||||
return;
|
||||
}
|
||||
const queuedOptions = queuedRefreshOptionsRef.current as TeamChangesLoadOptions | null;
|
||||
const queuedOptions = queuedRefreshOptionsRef.current;
|
||||
const shouldRunVisibleQueuedRefreshAfterSilentFailure =
|
||||
!storeSummaries &&
|
||||
!reportError &&
|
||||
|
|
@ -385,6 +514,7 @@ export function useTeamChangesSummaries({
|
|||
const hasQueuedRefresh = queuedRefreshOptionsRef.current !== null;
|
||||
if (activeRequestSeqRef.current === requestSeq) {
|
||||
activeRequestSeqRef.current = null;
|
||||
activeRequestOptionsRef.current = null;
|
||||
}
|
||||
if (hasQueuedRefresh && activeRequestSeqRef.current === null) {
|
||||
setQueuedRefreshTick((value) => value + 1);
|
||||
|
|
@ -406,6 +536,7 @@ export function useTeamChangesSummaries({
|
|||
hasLoadedRef.current = false;
|
||||
requestSeqRef.current += 1;
|
||||
activeRequestSeqRef.current = null;
|
||||
activeRequestOptionsRef.current = null;
|
||||
queuedRefreshOptionsRef.current = null;
|
||||
autoRefreshBlockedUntilRef.current = 0;
|
||||
unknownScanCursorRef.current = 0;
|
||||
|
|
@ -422,6 +553,7 @@ export function useTeamChangesSummaries({
|
|||
if (!sectionOpen) {
|
||||
requestSeqRef.current += 1;
|
||||
activeRequestSeqRef.current = null;
|
||||
activeRequestOptionsRef.current = null;
|
||||
queuedRefreshOptionsRef.current = null;
|
||||
autoRefreshBlockedUntilRef.current = 0;
|
||||
hasLoadedRef.current = false;
|
||||
|
|
@ -457,7 +589,14 @@ export function useTeamChangesSummaries({
|
|||
}
|
||||
hasLoadedRef.current = true;
|
||||
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]);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -527,7 +666,15 @@ export function useTeamChangesSummaries({
|
|||
}, [loadSummaries, sectionOpen]);
|
||||
|
||||
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]);
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
import { useStore } from '@renderer/store';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
import type { CliInstallationStatus, CliProviderId } from '@shared/types';
|
||||
import type { CliInstallationStatus, CliProviderId, OpenCodeRuntimeStatus } from '@shared/types';
|
||||
|
||||
export function useCliInstaller(): {
|
||||
cliStatus: CliInstallationStatus | null;
|
||||
|
|
@ -30,6 +30,9 @@ export function useCliInstaller(): {
|
|||
installerDetail: string | null;
|
||||
installerRawChunks: string[];
|
||||
completedVersion: string | null;
|
||||
openCodeRuntimeStatus: OpenCodeRuntimeStatus | null;
|
||||
openCodeRuntimeStatusLoading: boolean;
|
||||
openCodeRuntimeError: string | null;
|
||||
bootstrapCliStatus: (options?: { multimodelEnabled?: boolean }) => Promise<void>;
|
||||
fetchCliStatus: () => Promise<void>;
|
||||
fetchCliProviderStatus: (
|
||||
|
|
@ -38,6 +41,9 @@ export function useCliInstaller(): {
|
|||
) => Promise<void>;
|
||||
invalidateCliStatus: () => Promise<void>;
|
||||
installCli: () => void;
|
||||
fetchOpenCodeRuntimeStatus: () => Promise<void>;
|
||||
installOpenCodeRuntime: () => Promise<void>;
|
||||
invalidateOpenCodeRuntimeStatus: () => Promise<void>;
|
||||
isBusy: boolean;
|
||||
} {
|
||||
const {
|
||||
|
|
@ -53,11 +59,17 @@ export function useCliInstaller(): {
|
|||
installerDetail,
|
||||
installerRawChunks,
|
||||
completedVersion,
|
||||
openCodeRuntimeStatus,
|
||||
openCodeRuntimeStatusLoading,
|
||||
openCodeRuntimeError,
|
||||
bootstrapCliStatus,
|
||||
fetchCliStatus,
|
||||
fetchCliProviderStatus,
|
||||
invalidateCliStatus,
|
||||
installCli,
|
||||
fetchOpenCodeRuntimeStatus,
|
||||
installOpenCodeRuntime,
|
||||
invalidateOpenCodeRuntimeStatus,
|
||||
} = useStore(
|
||||
useShallow((s) => ({
|
||||
cliStatus: s.cliStatus,
|
||||
|
|
@ -72,11 +84,17 @@ export function useCliInstaller(): {
|
|||
installerDetail: s.cliInstallerDetail,
|
||||
installerRawChunks: s.cliInstallerRawChunks,
|
||||
completedVersion: s.cliCompletedVersion,
|
||||
openCodeRuntimeStatus: s.openCodeRuntimeStatus,
|
||||
openCodeRuntimeStatusLoading: s.openCodeRuntimeStatusLoading,
|
||||
openCodeRuntimeError: s.openCodeRuntimeError,
|
||||
bootstrapCliStatus: s.bootstrapCliStatus,
|
||||
fetchCliStatus: s.fetchCliStatus,
|
||||
fetchCliProviderStatus: s.fetchCliProviderStatus,
|
||||
invalidateCliStatus: s.invalidateCliStatus,
|
||||
installCli: s.installCli,
|
||||
fetchOpenCodeRuntimeStatus: s.fetchOpenCodeRuntimeStatus,
|
||||
installOpenCodeRuntime: s.installOpenCodeRuntime,
|
||||
invalidateOpenCodeRuntimeStatus: s.invalidateOpenCodeRuntimeStatus,
|
||||
}))
|
||||
);
|
||||
|
||||
|
|
@ -96,11 +114,17 @@ export function useCliInstaller(): {
|
|||
installerDetail,
|
||||
installerRawChunks,
|
||||
completedVersion,
|
||||
openCodeRuntimeStatus,
|
||||
openCodeRuntimeStatusLoading,
|
||||
openCodeRuntimeError,
|
||||
bootstrapCliStatus,
|
||||
fetchCliStatus,
|
||||
fetchCliProviderStatus,
|
||||
invalidateCliStatus,
|
||||
installCli,
|
||||
fetchOpenCodeRuntimeStatus,
|
||||
installOpenCodeRuntime,
|
||||
invalidateOpenCodeRuntimeStatus,
|
||||
isBusy,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -70,6 +70,7 @@ import type {
|
|||
CliInstallerProgress,
|
||||
CliProviderId,
|
||||
LeadContextUsage,
|
||||
OpenCodeRuntimeStatus,
|
||||
ScheduleChangeEvent,
|
||||
TeamChangeEvent,
|
||||
TeamProvisioningProgress,
|
||||
|
|
@ -244,6 +245,9 @@ export function initializeNotificationListeners(): () => void {
|
|||
cliStatusTimer = null;
|
||||
}, delayMs);
|
||||
}
|
||||
if (api.openCodeRuntime) {
|
||||
void useStore.getState().fetchOpenCodeRuntimeStatus();
|
||||
}
|
||||
|
||||
// Remaining fetches have no data dependency on each other — run in parallel
|
||||
// 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
|
||||
if (api.updater?.onStatus) {
|
||||
const cleanup = api.updater.onStatus((_event: unknown, status: unknown) => {
|
||||
|
|
|
|||
|
|
@ -7,7 +7,12 @@ import { createLogger } from '@shared/utils/logger';
|
|||
import { createDefaultCliExtensionCapabilities } from '@shared/utils/providerExtensionCapabilities';
|
||||
|
||||
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';
|
||||
|
||||
const logger = createLogger('Store:cliInstaller');
|
||||
|
|
@ -283,6 +288,9 @@ export interface CliInstallerSlice {
|
|||
cliInstallerLogs: string[];
|
||||
cliInstallerRawChunks: string[];
|
||||
cliCompletedVersion: string | null;
|
||||
openCodeRuntimeStatus: OpenCodeRuntimeStatus | null;
|
||||
openCodeRuntimeStatusLoading: boolean;
|
||||
openCodeRuntimeError: string | null;
|
||||
|
||||
// Actions
|
||||
bootstrapCliStatus: (options?: { multimodelEnabled?: boolean }) => Promise<void>;
|
||||
|
|
@ -293,12 +301,16 @@ export interface CliInstallerSlice {
|
|||
) => Promise<void>;
|
||||
invalidateCliStatus: () => Promise<void>;
|
||||
installCli: () => void;
|
||||
fetchOpenCodeRuntimeStatus: () => Promise<void>;
|
||||
installOpenCodeRuntime: () => Promise<void>;
|
||||
invalidateOpenCodeRuntimeStatus: () => Promise<void>;
|
||||
}
|
||||
|
||||
let cliStatusInFlight: Promise<void> | null = null;
|
||||
const cliProviderStatusInFlight = new Map<string, Promise<void>>();
|
||||
let cliStatusEpoch = 0;
|
||||
const cliProviderStatusSeq = new Map<CliProviderId, number>();
|
||||
let openCodeRuntimeStatusInFlight: Promise<void> | null = null;
|
||||
|
||||
// =============================================================================
|
||||
// Slice Creator
|
||||
|
|
@ -322,6 +334,9 @@ export const createCliInstallerSlice: StateCreator<AppState, [], [], CliInstalle
|
|||
cliInstallerLogs: [],
|
||||
cliInstallerRawChunks: [],
|
||||
cliCompletedVersion: null,
|
||||
openCodeRuntimeStatus: null,
|
||||
openCodeRuntimeStatusLoading: false,
|
||||
openCodeRuntimeError: null,
|
||||
|
||||
bootstrapCliStatus: async (options) => {
|
||||
if (!api.cliInstaller) return;
|
||||
|
|
@ -690,4 +705,65 @@ export const createCliInstallerSlice: StateCreator<AppState, [], [], CliInstalle
|
|||
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 });
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1870,6 +1870,7 @@ const resolvedMembersSelectorCache = new Map<
|
|||
{
|
||||
snapshotRef: TeamViewSnapshot['members'];
|
||||
configMembersRef: TeamViewSnapshot['config']['members'] | undefined;
|
||||
summaryRef: TeamSummary | undefined;
|
||||
tasksRef: TeamViewSnapshot['tasks'] | undefined;
|
||||
metaMembersRef: TeamMemberActivityMeta['members'] | undefined;
|
||||
result: ResolvedTeamMember[];
|
||||
|
|
@ -1982,10 +1983,196 @@ function buildConfigFallbackMemberSnapshots(snapshot: TeamViewSnapshot): TeamMem
|
|||
return fallbackMembers;
|
||||
}
|
||||
|
||||
function getResolvableMemberSnapshots(snapshot: TeamViewSnapshot): readonly TeamMemberSnapshot[] {
|
||||
return snapshot.members.length > 0
|
||||
? snapshot.members
|
||||
: buildConfigFallbackMemberSnapshots(snapshot);
|
||||
function buildSummaryFallbackMemberSnapshots(
|
||||
snapshot: TeamViewSnapshot,
|
||||
summary: TeamSummary | undefined
|
||||
): 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(
|
||||
|
|
@ -2042,8 +2229,13 @@ function structurallyShareMemberActivityFacts(
|
|||
return changed ? shared : previous;
|
||||
}
|
||||
|
||||
type TeamDataSelectorState = Pick<
|
||||
TeamSlice,
|
||||
'teamDataCacheByName' | 'selectedTeamName' | 'selectedTeamData'
|
||||
>;
|
||||
|
||||
export function selectTeamDataForName(
|
||||
state: Pick<TeamSlice, 'teamDataCacheByName' | 'selectedTeamName' | 'selectedTeamData'>,
|
||||
state: TeamDataSelectorState,
|
||||
teamName: string | null | undefined
|
||||
): TeamViewSnapshot | null {
|
||||
if (!teamName) {
|
||||
|
|
@ -2058,6 +2250,12 @@ export function selectTeamDataForName(
|
|||
);
|
||||
}
|
||||
|
||||
type ResolvedMemberSelectorState = Pick<
|
||||
TeamSlice,
|
||||
'teamDataCacheByName' | 'selectedTeamName' | 'selectedTeamData' | 'memberActivityMetaByTeam'
|
||||
> &
|
||||
Partial<Pick<TeamSlice, 'teamByName'>>;
|
||||
|
||||
function migrateStableSlotAssignmentsForMembers(
|
||||
assignments: TeamGraphSlotAssignments | undefined,
|
||||
members: readonly TeamGraphMemberSeedInput[]
|
||||
|
|
@ -2088,10 +2286,7 @@ function migrateStableSlotAssignmentsForMembers(
|
|||
}
|
||||
|
||||
export function selectResolvedMembersForTeamName(
|
||||
state: Pick<
|
||||
TeamSlice,
|
||||
'teamDataCacheByName' | 'selectedTeamName' | 'selectedTeamData' | 'memberActivityMetaByTeam'
|
||||
>,
|
||||
state: ResolvedMemberSelectorState,
|
||||
teamName: string | null | undefined
|
||||
): ResolvedTeamMember[] {
|
||||
const snapshot = selectTeamDataForName(state, teamName);
|
||||
|
|
@ -2101,23 +2296,28 @@ export function selectResolvedMembersForTeamName(
|
|||
|
||||
const meta = state.memberActivityMetaByTeam[teamName];
|
||||
const metaMembers = meta?.members;
|
||||
const shouldUseConfigFallback = snapshot.members.length === 0;
|
||||
const configMembersRef = shouldUseConfigFallback ? snapshot.config.members : undefined;
|
||||
const tasksRef = shouldUseConfigFallback ? snapshot.tasks : undefined;
|
||||
const shouldUseMemberFallback =
|
||||
snapshot.members.length === 0 ||
|
||||
(!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);
|
||||
if (
|
||||
cached?.snapshotRef === snapshot.members &&
|
||||
cached.configMembersRef === configMembersRef &&
|
||||
cached.summaryRef === summaryRef &&
|
||||
cached.tasksRef === tasksRef &&
|
||||
cached.metaMembersRef === metaMembers
|
||||
) {
|
||||
return cached.result;
|
||||
}
|
||||
|
||||
const result = buildResolvedMembers(getResolvableMemberSnapshots(snapshot), meta);
|
||||
const result = buildResolvedMembers(getResolvableMemberSnapshots(snapshot, summaryRef), meta);
|
||||
resolvedMembersSelectorCache.set(teamName, {
|
||||
snapshotRef: snapshot.members,
|
||||
configMembersRef,
|
||||
summaryRef,
|
||||
tasksRef,
|
||||
metaMembersRef: metaMembers,
|
||||
result,
|
||||
|
|
@ -2126,10 +2326,7 @@ export function selectResolvedMembersForTeamName(
|
|||
}
|
||||
|
||||
export function selectResolvedMemberForTeamName(
|
||||
state: Pick<
|
||||
TeamSlice,
|
||||
'teamDataCacheByName' | 'selectedTeamName' | 'selectedTeamData' | 'memberActivityMetaByTeam'
|
||||
>,
|
||||
state: ResolvedMemberSelectorState,
|
||||
teamName: string | null | undefined,
|
||||
memberName: string | null | undefined
|
||||
): ResolvedTeamMember | null {
|
||||
|
|
@ -2138,7 +2335,7 @@ export function selectResolvedMemberForTeamName(
|
|||
return null;
|
||||
}
|
||||
|
||||
const snapshotMember = getResolvableMemberSnapshots(snapshot).find(
|
||||
const snapshotMember = getResolvableMemberSnapshots(snapshot, state.teamByName?.[teamName]).find(
|
||||
(member) => member.name === memberName
|
||||
);
|
||||
if (!snapshotMember) {
|
||||
|
|
@ -2162,21 +2359,21 @@ export function selectResolvedMemberForTeamName(
|
|||
}
|
||||
|
||||
export function selectTeamMemberSnapshotsForName(
|
||||
state: Pick<TeamSlice, 'teamDataCacheByName' | 'selectedTeamName' | 'selectedTeamData'>,
|
||||
state: TeamDataSelectorState,
|
||||
teamName: string | null | undefined
|
||||
): TeamViewSnapshot['members'] {
|
||||
return selectTeamDataForName(state, teamName)?.members ?? EMPTY_TEAM_MEMBER_SNAPSHOTS;
|
||||
}
|
||||
|
||||
export function selectTeamTasksForName(
|
||||
state: Pick<TeamSlice, 'teamDataCacheByName' | 'selectedTeamName' | 'selectedTeamData'>,
|
||||
state: TeamDataSelectorState,
|
||||
teamName: string | null | undefined
|
||||
): TeamViewSnapshot['tasks'] {
|
||||
return selectTeamDataForName(state, teamName)?.tasks ?? EMPTY_TEAM_TASKS;
|
||||
}
|
||||
|
||||
export function selectTeamIsAliveForName(
|
||||
state: Pick<TeamSlice, 'teamDataCacheByName' | 'selectedTeamName' | 'selectedTeamData'>,
|
||||
state: TeamDataSelectorState,
|
||||
teamName: string | null | undefined
|
||||
): boolean | undefined {
|
||||
return selectTeamDataForName(state, teamName)?.isAlive;
|
||||
|
|
@ -3856,14 +4053,52 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
|||
set({ teamByName: { ...prevByName, [teamName]: patched } });
|
||||
}
|
||||
|
||||
const projectedTeamData = previousData
|
||||
? {
|
||||
...data,
|
||||
tasks: preserveKnownTaskChangePresence(teamName, previousData.tasks, data.tasks),
|
||||
}
|
||||
: data;
|
||||
const nextTeamData = structurallyShareTeamSnapshot(previousData, projectedTeamData);
|
||||
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,
|
||||
tasks: preserveKnownTaskChangePresence(
|
||||
teamName,
|
||||
previousForProjection.tasks,
|
||||
data.tasks
|
||||
),
|
||||
}
|
||||
: data;
|
||||
const nextTeamData = structurallyShareTeamSnapshot(
|
||||
previousForProjection,
|
||||
projectedTeamData
|
||||
);
|
||||
committedTeamData = nextTeamData;
|
||||
const nextCache =
|
||||
state.teamDataCacheByName[teamName] === nextTeamData
|
||||
? state.teamDataCacheByName
|
||||
|
|
@ -3884,7 +4119,11 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
|||
|
||||
try {
|
||||
const invalidationState = previousData
|
||||
? collectTaskChangeInvalidationState(teamName, previousData.tasks, data.tasks)
|
||||
? collectTaskChangeInvalidationState(
|
||||
teamName,
|
||||
previousData.tasks,
|
||||
committedTeamData.tasks
|
||||
)
|
||||
: { cacheKeys: [], taskIds: [] };
|
||||
if (invalidationState.cacheKeys.length > 0) {
|
||||
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.
|
||||
const displayName = data.config.name || teamName;
|
||||
const displayName = committedTeamData.config.name || teamName;
|
||||
const allTabs = get().getAllPaneTabs();
|
||||
const relatedTabs = allTabs.filter(
|
||||
(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.
|
||||
// Must search both flat projects and grouped repositoryGroups/worktrees
|
||||
// because the default viewMode is 'grouped' and flat projects may be empty.
|
||||
const projectPath = data.config.projectPath;
|
||||
const projectPath = committedTeamData.config.projectPath;
|
||||
if (
|
||||
!opts?.skipProjectAutoSelect &&
|
||||
projectPath &&
|
||||
|
|
|
|||
|
|
@ -134,6 +134,8 @@ export const SPAWN_PRESENCE_LABELS: Record<MemberSpawnStatus, string> = {
|
|||
|
||||
const OPENCODE_RUNTIME_CANDIDATE_RELAUNCH_GRACE_MS = 5 * 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(
|
||||
spawnStatus: MemberSpawnStatus | undefined,
|
||||
|
|
@ -332,6 +334,7 @@ function isOpenCodeRuntimeDeliveryAdvisoryMessage(message: string | undefined):
|
|||
displayMessage.startsWith('OpenCode runtime delivery') ||
|
||||
displayMessage.startsWith('OpenCode returned an empty assistant turn') ||
|
||||
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 created a reply without') ||
|
||||
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') {
|
||||
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 (
|
||||
trimmed === 'visible_reply_still_required' ||
|
||||
trimmed === 'visible_reply_ack_only_still_requires_answer' ||
|
||||
|
|
|
|||
|
|
@ -33,6 +33,8 @@ const FAILED_WARNING =
|
|||
'OpenCode runtime delivery failed. Message was saved to inbox, but live delivery did not complete.';
|
||||
const ATTACHMENT_FAILED_WARNING =
|
||||
'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 {
|
||||
const normalized = reason?.trim().toLowerCase();
|
||||
|
|
@ -55,6 +57,9 @@ function formatOpenCodeRuntimeDeliveryFailureReason(reason: string | null | unde
|
|||
if (normalizedLower === 'prompt_delivered_no_assistant_message') {
|
||||
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 (
|
||||
normalizedLower === 'visible_reply_still_required' ||
|
||||
normalizedLower === 'visible_reply_ack_only_still_requires_answer' ||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
*/
|
||||
|
||||
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 { ApiKeysAPI, McpCatalogAPI, PluginCatalogAPI, SkillsCatalogAPI } from './extensions';
|
||||
import type {
|
||||
|
|
@ -79,9 +79,9 @@ import type {
|
|||
TeamCreateRequest,
|
||||
TeamCreateResponse,
|
||||
TeamGetDataOptions,
|
||||
TeamLaunchFailureDiagnosticsBundle,
|
||||
TeamLaunchRequest,
|
||||
TeamLaunchResponse,
|
||||
TeamLaunchFailureDiagnosticsBundle,
|
||||
TeamMemberActivityMeta,
|
||||
TeamMessageNotificationData,
|
||||
TeamProvisioningModelVerificationMode,
|
||||
|
|
@ -927,6 +927,9 @@ export interface ElectronAPI extends RecentProjectsElectronApi, CodexAccountElec
|
|||
// CLI Installer API
|
||||
cliInstaller: CliInstallerAPI;
|
||||
|
||||
// OpenCode app-managed runtime installer API
|
||||
openCodeRuntime: OpenCodeRuntimeAPI;
|
||||
|
||||
// Runtime nested provider management API
|
||||
runtimeProviderManagement: RuntimeProviderManagementApi;
|
||||
|
||||
|
|
|
|||
|
|
@ -327,3 +327,42 @@ export interface CliInstallerAPI {
|
|||
/** Subscribe to progress events. Returns cleanup function. */
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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 () => {
|
||||
const executor = fakeExecutor(
|
||||
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(
|
||||
data: OpenCodeTeamLaunchReadiness
|
||||
): OpenCodeBridgeSuccess<OpenCodeTeamLaunchReadiness> {
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
expect(classifyRuntimeDiagnostic('empty_assistant_turn')).toMatchObject({
|
||||
reasonCode: 'backend_error',
|
||||
|
|
|
|||
|
|
@ -64,6 +64,65 @@ describe('TaskBoundaryParser', () => {
|
|||
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 () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'task-boundary-parser-'));
|
||||
const jsonlPath = path.join(tmpDir, 'mcp-qualified.jsonl');
|
||||
|
|
|
|||
93
test/main/services/team/TaskChangeComputer.test.ts
Normal file
93
test/main/services/team/TaskChangeComputer.test.ts
Normal 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']);
|
||||
});
|
||||
});
|
||||
|
|
@ -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 () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-logs-'));
|
||||
setClaudeBasePathOverride(tmpDir);
|
||||
|
|
@ -1535,6 +1633,97 @@ describe('TeamMemberLogsFinder', () => {
|
|||
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 () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-refs-cross-'));
|
||||
setClaudeBasePathOverride(tmpDir);
|
||||
|
|
|
|||
|
|
@ -16,6 +16,9 @@ vi.mock('@main/utils/pathDecoder', () => ({
|
|||
describe('buildMergedCliPath', () => {
|
||||
let buildMergedCliPath: typeof import('@main/utils/cliPathMerge').buildMergedCliPath;
|
||||
const originalPlatform = process.platform;
|
||||
const originalLocalAppData = process.env.LOCALAPPDATA;
|
||||
const originalProgramFiles = process.env.ProgramFiles;
|
||||
const originalPath = process.env.PATH;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
|
|
@ -28,6 +31,21 @@ describe('buildMergedCliPath', () => {
|
|||
|
||||
afterEach(() => {
|
||||
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', () => {
|
||||
|
|
@ -40,12 +58,24 @@ describe('buildMergedCliPath', () => {
|
|||
'/home/testuser/.local/bin',
|
||||
'/home/testuser/.npm-global/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',
|
||||
'/opt/homebrew/bin',
|
||||
'/opt/local/bin',
|
||||
'/usr/bin',
|
||||
'/bin',
|
||||
'/usr/sbin',
|
||||
'/sbin',
|
||||
])
|
||||
);
|
||||
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', () => {
|
||||
|
|
@ -57,6 +87,9 @@ describe('buildMergedCliPath', () => {
|
|||
const parts = p.split(';');
|
||||
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) => /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');
|
||||
});
|
||||
|
||||
|
|
|
|||
206
test/main/utils/shellEnv.integration.test.ts
Normal file
206
test/main/utils/shellEnv.integration.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
549
test/main/utils/shellEnv.test.ts
Normal file
549
test/main/utils/shellEnv.test.ts
Normal 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({});
|
||||
});
|
||||
});
|
||||
|
|
@ -24,11 +24,17 @@ interface StoreState {
|
|||
cliInstallerDetail: string | null;
|
||||
cliInstallerRawChunks: string[];
|
||||
cliCompletedVersion: string | null;
|
||||
openCodeRuntimeStatus: Record<string, unknown> | null;
|
||||
openCodeRuntimeStatusLoading: boolean;
|
||||
openCodeRuntimeError: string | null;
|
||||
bootstrapCliStatus: ReturnType<typeof vi.fn>;
|
||||
fetchCliStatus: ReturnType<typeof vi.fn>;
|
||||
fetchCliProviderStatus: ReturnType<typeof vi.fn>;
|
||||
invalidateCliStatus: 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: {
|
||||
general: {
|
||||
multimodelEnabled: boolean;
|
||||
|
|
@ -319,11 +325,17 @@ describe('CLI status visibility during completed install state', () => {
|
|||
storeState.cliInstallerDetail = null;
|
||||
storeState.cliInstallerRawChunks = [];
|
||||
storeState.cliCompletedVersion = '2.1.100';
|
||||
storeState.openCodeRuntimeStatus = null;
|
||||
storeState.openCodeRuntimeStatusLoading = false;
|
||||
storeState.openCodeRuntimeError = null;
|
||||
storeState.bootstrapCliStatus = vi.fn().mockResolvedValue(undefined);
|
||||
storeState.fetchCliStatus = vi.fn().mockResolvedValue(undefined);
|
||||
storeState.fetchCliProviderStatus = vi.fn().mockResolvedValue(undefined);
|
||||
storeState.invalidateCliStatus = vi.fn().mockResolvedValue(undefined);
|
||||
storeState.installCli = vi.fn();
|
||||
storeState.fetchOpenCodeRuntimeStatus = vi.fn().mockResolvedValue(undefined);
|
||||
storeState.installOpenCodeRuntime = vi.fn().mockResolvedValue(undefined);
|
||||
storeState.invalidateOpenCodeRuntimeStatus = vi.fn().mockResolvedValue(undefined);
|
||||
storeState.appConfig = {
|
||||
general: {
|
||||
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('Anthropic');
|
||||
|
||||
const collapseButton = host.querySelector(
|
||||
const collapseButton = host.querySelector<HTMLButtonElement>(
|
||||
'button[aria-label="Collapse provider details"]'
|
||||
) as HTMLButtonElement | null;
|
||||
);
|
||||
expect(collapseButton).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
|
|
@ -1106,9 +1118,9 @@ describe('CLI status visibility during completed install state', () => {
|
|||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const collapseButton = firstHost.querySelector(
|
||||
const collapseButton = firstHost.querySelector<HTMLButtonElement>(
|
||||
'button[aria-label="Collapse provider details"]'
|
||||
) as HTMLButtonElement | null;
|
||||
);
|
||||
expect(collapseButton).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
|
|
|
|||
|
|
@ -62,6 +62,53 @@ describe('ProviderModelBadges', () => {
|
|||
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', () => {
|
||||
const models = Array.from(
|
||||
{ length: 18 },
|
||||
|
|
|
|||
|
|
@ -292,6 +292,11 @@ describe('TeamModelSelector disabled Codex models', () => {
|
|||
expect(host.textContent).toContain('Unavailable in OpenCode');
|
||||
expect(host.textContent).toContain('openai/gpt-oss-20b:free');
|
||||
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(
|
||||
(button) => button.textContent ?? ''
|
||||
|
|
@ -579,16 +584,16 @@ describe('TeamModelSelector disabled Codex models', () => {
|
|||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const modelGrid = host.querySelector(
|
||||
const modelGrid = host.querySelector<HTMLElement>(
|
||||
'[data-testid="team-model-selector-model-grid"]'
|
||||
) as HTMLElement | null;
|
||||
);
|
||||
|
||||
expect(modelGrid).toBeTruthy();
|
||||
expect(modelGrid?.style.maxHeight).toBe('400px');
|
||||
expect(modelGrid?.className).toContain('overflow-y-auto');
|
||||
const searchInput = host.querySelector(
|
||||
const searchInput = host.querySelector<HTMLInputElement>(
|
||||
'[data-testid="team-model-selector-model-search"]'
|
||||
) as HTMLInputElement | null;
|
||||
);
|
||||
expect(searchInput).toBeTruthy();
|
||||
|
||||
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);
|
||||
storeState.cliStatus = {
|
||||
providers: [
|
||||
|
|
@ -1359,7 +1364,7 @@ describe('TeamModelSelector disabled Codex models', () => {
|
|||
expect(host.textContent).toContain('OpenRouter');
|
||||
|
||||
const openRouterButton = Array.from(host.querySelectorAll('button')).find((button) =>
|
||||
button.textContent?.includes('OpenRouter')
|
||||
button.textContent?.includes('moonshotai/kimi-k2')
|
||||
);
|
||||
|
||||
expect(openRouterButton).toBeTruthy();
|
||||
|
|
@ -1376,4 +1381,70 @@ describe('TeamModelSelector disabled Codex models', () => {
|
|||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -16,10 +16,7 @@ const teamState = {
|
|||
],
|
||||
tasks: [],
|
||||
},
|
||||
teamDataCacheByName: new Map<
|
||||
string,
|
||||
{ members: Record<string, unknown>[]; tasks: unknown[] }
|
||||
>([
|
||||
teamDataCacheByName: new Map<string, { members: Record<string, unknown>[]; tasks: unknown[] }>([
|
||||
[
|
||||
'demo-team',
|
||||
{
|
||||
|
|
@ -106,7 +103,10 @@ describe('GraphActivityHud', () => {
|
|||
beforeEach(() => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
buildInlineActivityEntries.mockReset();
|
||||
vi.stubGlobal('requestAnimationFrame', vi.fn(() => 1));
|
||||
vi.stubGlobal(
|
||||
'requestAnimationFrame',
|
||||
vi.fn(() => 1)
|
||||
);
|
||||
vi.stubGlobal('cancelAnimationFrame', vi.fn());
|
||||
Object.defineProperty(HTMLElement.prototype, 'offsetWidth', {
|
||||
configurable: true,
|
||||
|
|
@ -124,6 +124,7 @@ describe('GraphActivityHud', () => {
|
|||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
if (originalOffsetWidthDescriptor) {
|
||||
Object.defineProperty(HTMLElement.prototype, 'offsetWidth', originalOffsetWidthDescriptor);
|
||||
|
|
@ -356,4 +357,138 @@ describe('GraphActivityHud', () => {
|
|||
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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
id: 'member:alpha-team:codex-dev',
|
||||
kind: 'member',
|
||||
|
|
@ -724,8 +724,56 @@ describe('GraphMemberLogPreviewHud', () => {
|
|||
});
|
||||
|
||||
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).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(() => {
|
||||
root.unmount();
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
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 type { GraphNode } from '@claude-teams/agent-graph';
|
||||
|
|
@ -37,17 +41,17 @@ describe('KanbanLayoutEngine', () => {
|
|||
|
||||
KanbanLayoutEngine.layout([lead, orphanTask], {
|
||||
unassignedTaskRect: {
|
||||
left: -80,
|
||||
left: -TASK_PILL.width / 2,
|
||||
top: 120,
|
||||
right: 80,
|
||||
right: TASK_PILL.width / 2,
|
||||
bottom: 540,
|
||||
width: 160,
|
||||
width: TASK_PILL.width,
|
||||
height: 420,
|
||||
},
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -11,6 +11,12 @@ vi.mock('@renderer/api', () => ({
|
|||
install: 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
|
||||
getProjects: vi.fn(() => Promise.resolve([])),
|
||||
getSessions: vi.fn(() => Promise.resolve([])),
|
||||
|
|
@ -137,6 +143,9 @@ describe('cliInstallerSlice', () => {
|
|||
cliDownloadTotal: 0,
|
||||
cliInstallerError: null,
|
||||
cliCompletedVersion: null,
|
||||
openCodeRuntimeStatus: null,
|
||||
openCodeRuntimeStatusLoading: false,
|
||||
openCodeRuntimeError: null,
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -688,20 +697,22 @@ describe('cliInstallerSlice', () => {
|
|||
],
|
||||
};
|
||||
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') {
|
||||
return createMultimodelProvider({
|
||||
providerId: 'opencode',
|
||||
displayName: 'OpenCode',
|
||||
authenticated: true,
|
||||
authMethod: 'opencode_managed',
|
||||
statusMessage: null,
|
||||
models: ['opencode/minimax-m2.5-free'],
|
||||
canLoginFromUi: false,
|
||||
backend: { kind: 'opencode-cli', label: 'OpenCode CLI' },
|
||||
});
|
||||
return Promise.resolve(
|
||||
createMultimodelProvider({
|
||||
providerId: 'opencode',
|
||||
displayName: 'OpenCode',
|
||||
authenticated: true,
|
||||
authMethod: 'opencode_managed',
|
||||
statusMessage: null,
|
||||
models: ['opencode/minimax-m2.5-free'],
|
||||
canLoginFromUi: false,
|
||||
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 });
|
||||
|
|
|
|||
|
|
@ -1491,6 +1491,446 @@ describe('teamSlice actions', () => {
|
|||
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 () => {
|
||||
vi.useFakeTimers();
|
||||
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', () => {
|
||||
const store = createSliceStore();
|
||||
store.setState({
|
||||
|
|
|
|||
|
|
@ -842,6 +842,28 @@ describe('memberHelpers spawn-aware presence', () => {
|
|||
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', () => {
|
||||
const title = getMemberRuntimeAdvisoryTitle(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
const diagnostics = buildOpenCodeRuntimeDeliveryDiagnostics({
|
||||
deliveredToInbox: true,
|
||||
|
|
|
|||
|
|
@ -22,6 +22,6 @@
|
|||
},
|
||||
"types": ["node", "vitest/globals"]
|
||||
},
|
||||
"include": ["src/**/*", "test/**/*"],
|
||||
"include": ["src/**/*", "test/**/*", "scripts/team-changes-real-data-smoke.ts"],
|
||||
"exclude": ["node_modules", "dist", "dist-electron"]
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue