diff --git a/apps/mobile/.kilo/WORKFLOW_LEARNINGS.md b/apps/mobile/.kilo/WORKFLOW_LEARNINGS.md index 46b74eb2f4..67052a5f0e 100644 --- a/apps/mobile/.kilo/WORKFLOW_LEARNINGS.md +++ b/apps/mobile/.kilo/WORKFLOW_LEARNINGS.md @@ -4,6 +4,27 @@ Environment blockers and their fixes, recorded by the planner or orchestrator fo ## Planner +### Uncommitted `.kilo/` edits are reverted by the next role agent + +- Symptom: a learning the planner wrote to this file vanished; the dispatched verifier's report said it + "restored an accidental edit" and left the worktree clean. +- Cause: role agents snapshot `git status` as their baseline before any temporary edit and restore + anything they find modified. A planner note sitting uncommitted looks exactly like an agent's own + stray edit. +- Fix: whoever writes to this file gets it **committed** before the next role agent is dispatched. The + planner cannot commit (the orchestrator owns Git), so a planner-authored entry must be named in the + handoff as work to commit in the first commit — otherwise the first dispatched agent erases it. + +### In-app cloud-agent session creation needs a GitHub integration + +- Symptom: the new-session screen cannot create a cloud-agent session on a fresh local stack — the + repository section is empty and the flow dead-ends. +- Cause: the E2E account has no GitHub integration, so `listGitHubRepositories` returns nothing. +- Fix: for flows that just need "a session with a transcript, cost and context usage", use the remote + CLI path (`apps/mobile/e2e/remote-cli.sh start`, then prompt it) instead of a cloud-agent session. + Wire up GitHub only when the cloud-agent create flow itself is what is under test. A blocked + cloud-agent create on a fresh stack is a test-environment limitation, not a product failure. + ### Role agents apply the mobile-simulator setup to browser-extension runs - Symptom: dispatched for work whose product surface is `apps/extension` (the browser extension), a role agent starts by reading `apps/mobile/e2e/AGENTS.md`, claiming an iOS simulator, acquiring an `e2e-slot`, or trying to start Metro and the backend — none of which the extension needs. Steps are burned before any real work, and a device claim can block another run. @@ -95,6 +116,21 @@ Then wait event-driven with an `until grep -q EXITCODE= "$LOG"` loop that also b ## Orchestrator +### Kilobot's mention handle is `@kilocode-bot`, not `@kilo-code-bot` + +- Symptom: the step-9 "retrigger Kilobot with a PR comment tagging it" path silently does nothing — the + comment posts, no review follows — when the mention uses the login the bot's own comments show. +- Cause: Kilobot reviews are posted by a GitHub **App**, whose author login is `kilo-code-bot[bot]`. + That name is not mentionable: `gh api users/kilo-code-bot` returns 404. The mentionable account is + the separate GitHub *User* `kilocode-bot` (`gh api users/kilocode-bot` returns 200). +- Fix: write `@kilocode-bot` in the retrigger comment (still prefixed `(bot) ` per GitHub + Communication). Keep matching the *author* login as `kilo-code-bot[bot]` when reading threads — the + handle you tag and the login you match are deliberately different, so do not "correct" author-login + allowlists such as `KILO_GITHUB_BOT_LOGINS` in + `apps/web/src/lib/code-reviews/review-memory/github-feedback.ts`. +- If the mention still draws a "link your GitHub account to Kilo" reply, fall back to the empty-commit + retrigger (`git commit --allow-empty`), which needs no account linking. + ### Extension E2E: analytics specs need VITE_POSTHOG_API_KEY at build time - Symptom: every analytics e2e spec fails waiting on posthog identify/capture events after `pnpm --filter kilo-extension build`, even with `VITE_POSTHOG_API_KEY=e2e-test-key` on the playwright invocation. diff --git a/apps/mobile/src/app/(app)/agent-chat/[session-id].tsx b/apps/mobile/src/app/(app)/agent-chat/[session-id].tsx index 1bcc04095d..c2146605e4 100644 --- a/apps/mobile/src/app/(app)/agent-chat/[session-id].tsx +++ b/apps/mobile/src/app/(app)/agent-chat/[session-id].tsx @@ -7,6 +7,7 @@ import { SessionDetailContent, SessionSkeletonMessages, } from '@/components/agents/session-detail-content'; +import { SessionContextMetrics } from '@/components/agents/session-context-metrics'; import { AgentSessionProvider } from '@/components/agents/session-provider'; import { QueryError } from '@/components/query-error'; import { ScreenHeader } from '@/components/screen-header'; @@ -80,7 +81,17 @@ export default function SessionDetailScreen() { if (routeOrganizationId === undefined && sessionQuery.isPending) { return ( - + + } + /> ); diff --git a/apps/mobile/src/components/agents/context-usage-display.test.ts b/apps/mobile/src/components/agents/context-usage-display.test.ts index fa1ad72c2c..a76bccc519 100644 --- a/apps/mobile/src/components/agents/context-usage-display.test.ts +++ b/apps/mobile/src/components/agents/context-usage-display.test.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-lines -- Migrated getHeaderSummary cases plus new no-info pill/a11y branches. */ import { describe, expect, it } from 'vitest'; import { type SessionContextInfo } from '@/lib/session-context-info'; @@ -11,13 +12,14 @@ import { getArcFraction, getContextSheetContent, getContextTone, - getHeaderSummary, + getHeaderPillContent, getIndeterminateArcFraction, getMetricsAccessibilityLabel, getRemainingTokens, + type HeaderPillContent, } from './context-usage-display'; -function info(partial: Partial): SessionContextInfo { +function info(partial: Partial = {}): SessionContextInfo { return { contextTokens: 32_418, providerID: 'kilo', @@ -28,24 +30,36 @@ function info(partial: Partial): SessionContextInfo { }; } +function pill(args: { + info?: SessionContextInfo; + totalCostMicrodollars: number | null; + hasMessages: boolean; +}): HeaderPillContent { + return getHeaderPillContent({ + info: args.info, + totalCostMicrodollars: args.totalCostMicrodollars, + hasMessages: args.hasMessages, + }); +} + +const trackOnly: HeaderPillContent = { + primary: null, + secondary: null, + hasCost: false, + tone: 'neutral', + arcFraction: 0, + interactive: false, +}; + describe('formatCompactTokens', () => { - it('returns the raw number below one thousand', () => { + it('formats below one thousand, thousands, and millions', () => { expect(formatCompactTokens(0)).toBe('0'); expect(formatCompactTokens(999)).toBe('999'); - }); - - it('switches to one-decimal thousands at the 1000 boundary', () => { expect(formatCompactTokens(1000)).toBe('1.0K'); expect(formatCompactTokens(32_418)).toBe('32.4K'); - }); - - it('switches to millions at one million', () => { - expect(formatCompactTokens(1_200_000)).toBe('1.2M'); - }); - - it('handles sub-thousand precision for fractional thousands', () => { expect(formatCompactTokens(1500)).toBe('1.5K'); expect(formatCompactTokens(995_000)).toBe('995.0K'); + expect(formatCompactTokens(1_200_000)).toBe('1.2M'); }); }); @@ -88,23 +102,11 @@ describe('getContextTone', () => { }); describe('getArcFraction', () => { - it('maps zero to an empty arc', () => { + it('maps known percentages and leaves unknown capacity indeterminate', () => { expect(getArcFraction(0)).toBe(0); - }); - - it('maps fifty percent to half', () => { expect(getArcFraction(50)).toBe(0.5); - }); - - it('clamps to one at exactly one hundred percent', () => { expect(getArcFraction(100)).toBe(1); - }); - - it('clamps to one even when the real percentage overflows', () => { expect(getArcFraction(125)).toBe(1); - }); - - it('returns undefined for unknown capacity so callers render indeterminate', () => { expect(getArcFraction(undefined)).toBeUndefined(); }); }); @@ -114,26 +116,17 @@ describe('getIndeterminateArcFraction', () => { const fraction = getIndeterminateArcFraction(); expect(fraction).toBeGreaterThan(0); expect(fraction).toBeLessThan(1); - }); - - it('is a pure value (same on repeated calls) so render output is stable', () => { - expect(getIndeterminateArcFraction()).toBe(getIndeterminateArcFraction()); + expect(getIndeterminateArcFraction()).toBe(fraction); }); }); describe('getRemainingTokens', () => { - it('reports the remaining window when capacity is known', () => { + it('reports remaining, zero-at-overflow, and undefined when capacity unknown', () => { expect(getRemainingTokens(info({ contextTokens: 32_418, contextWindow: 200_000 }))).toBe( 167_582 ); - }); - - it('reports zero remaining when usage meets or exceeds the window', () => { expect(getRemainingTokens(info({ contextTokens: 200_000, contextWindow: 200_000 }))).toBe(0); expect(getRemainingTokens(info({ contextTokens: 250_000, contextWindow: 200_000 }))).toBe(0); - }); - - it('returns undefined when capacity is unknown', () => { expect( getRemainingTokens(info({ contextWindow: undefined, percentage: undefined })) ).toBeUndefined(); @@ -147,76 +140,126 @@ describe('formatRemainingTokens', () => { }); }); -describe('getHeaderSummary', () => { - it('returns null when there is no completed assistant context usage', () => { - expect(getHeaderSummary(undefined, 80_000)).toBeNull(); - expect(getHeaderSummary(undefined, 0)).toBeNull(); - expect(getHeaderSummary(undefined, null)).toBeNull(); +describe('getHeaderPillContent', () => { + // Migrated getHeaderSummary cases (info-present) + new no-info branches. + it('is track-only and non-interactive with no info and no transcript', () => { + expect(pill({ totalCostMicrodollars: 80_000, hasMessages: false })).toEqual(trackOnly); + expect(pill({ totalCostMicrodollars: 0, hasMessages: false })).toEqual(trackOnly); + expect(pill({ totalCostMicrodollars: null, hasMessages: false })).toEqual(trackOnly); }); it('shows percentage as primary and cost as secondary when capacity is known', () => { - const summary = getHeaderSummary(info({ percentage: 42 }), 80_000); - expect(summary).toEqual({ + expect( + pill({ info: info({ percentage: 42 }), totalCostMicrodollars: 80_000, hasMessages: true }) + ).toEqual({ primary: '42%', secondary: '$0.08', hasCost: true, tone: 'primary', + arcFraction: 0.42, + interactive: true, }); }); - it('omits the secondary cost when cost is zero or null', () => { - expect(getHeaderSummary(info({ percentage: 10 }), 0)).toEqual({ + it('omits secondary cost when cost is zero or null', () => { + const base = { primary: '10%', + secondary: null, hasCost: false, - tone: 'primary', + tone: 'primary' as const, + arcFraction: 0.1, + interactive: true, + }; + expect( + pill({ info: info({ percentage: 10 }), totalCostMicrodollars: 0, hasMessages: true }) + ).toEqual(base); + expect( + pill({ info: info({ percentage: 10 }), totalCostMicrodollars: null, hasMessages: true }) + ).toEqual(base); + }); + + it('uses warning tone at 75-89% with cost', () => { + const result = pill({ + info: info({ percentage: 80 }), + totalCostMicrodollars: 500_000, + hasMessages: true, }); - expect(getHeaderSummary(info({ percentage: 10 }), null)).toEqual({ - primary: '10%', - hasCost: false, - tone: 'primary', + expect(result.primary).toBe('80%'); + expect(result.tone).toBe('warning'); + expect(result.secondary).toBe('$0.50'); + expect(result.arcFraction).toBe(0.8); + expect(result.interactive).toBe(true); + }); + + it('keeps overflow percentage visible with destructive tone and full arc', () => { + const result = pill({ + info: info({ contextTokens: 250_000, contextWindow: 200_000, percentage: 125 }), + totalCostMicrodollars: 1_000_000, + hasMessages: true, }); + expect(result.primary).toBe('125%'); + expect(result.tone).toBe('destructive'); + expect(result.arcFraction).toBe(1); + expect(result.interactive).toBe(true); }); - it('uses percentage as primary and a warning tone at 75-89%', () => { - const summary = getHeaderSummary(info({ percentage: 80 }), 500_000); - expect(summary?.primary).toBe('80%'); - expect(summary?.tone).toBe('warning'); - expect(summary?.secondary).toBe('$0.50'); + it('falls back to compact tokens and neutral tone when capacity is unknown', () => { + expect( + pill({ + info: info({ contextWindow: undefined, percentage: undefined, contextTokens: 32_418 }), + totalCostMicrodollars: 120_000, + hasMessages: true, + }) + ).toEqual({ + primary: '32.4K', + secondary: '$0.12', + hasCost: true, + tone: 'neutral', + arcFraction: undefined, + interactive: true, + }); }); - it('keeps the real overflow percentage visible (does not clamp above 100) and uses a destructive tone', () => { - const summary = getHeaderSummary( - info({ contextTokens: 250_000, contextWindow: 200_000, percentage: 125 }), - 1_000_000 - ); - expect(summary?.primary).toBe('125%'); - expect(summary?.tone).toBe('destructive'); + it('omits secondary cost when capacity is unknown and cost is zero', () => { + expect( + pill({ + info: info({ contextWindow: undefined, percentage: undefined, contextTokens: 32_418 }), + totalCostMicrodollars: 0, + hasMessages: true, + }) + ).toEqual({ + primary: '32.4K', + secondary: null, + hasCost: false, + tone: 'neutral', + arcFraction: undefined, + interactive: true, + }); }); - it('falls back to compact tokens and a neutral tone when capacity is unknown', () => { - const summary = getHeaderSummary( - info({ contextWindow: undefined, percentage: undefined, contextTokens: 32_418 }), - 120_000 - ); - expect(summary).toEqual({ - primary: '32.4K', - secondary: '$0.12', + it('shows cost only, not interactive, arc 0 when transcript exists without context', () => { + expect(pill({ totalCostMicrodollars: 80_000, hasMessages: true })).toEqual({ + primary: '$0.08', + secondary: null, hasCost: true, tone: 'neutral', + arcFraction: 0, + interactive: false, }); }); - it('omits the secondary cost when capacity is unknown and cost is zero', () => { - const summary = getHeaderSummary( - info({ contextWindow: undefined, percentage: undefined, contextTokens: 32_418 }), - 0 - ); - expect(summary).toEqual({ primary: '32.4K', hasCost: false, tone: 'neutral' }); + it('shows no text when transcript exists without context or cost', () => { + expect(pill({ totalCostMicrodollars: null, hasMessages: true })).toEqual(trackOnly); + expect(pill({ totalCostMicrodollars: 0, hasMessages: true })).toEqual(trackOnly); + }); + + it('never surfaces a bare cost before a transcript exists', () => { + expect(pill({ totalCostMicrodollars: 700, hasMessages: false })).toEqual(trackOnly); }); }); describe('getContextSheetContent', () => { - it('describes exact usage and remaining tokens when capacity is known', () => { + it('describes exact usage and remaining when capacity is known', () => { const content = getContextSheetContent( info({ contextTokens: 84_000, contextWindow: 200_000, percentage: 42 }), 80_000 @@ -231,7 +274,7 @@ describe('getContextSheetContent', () => { expect(content.tone).toBe('primary'); }); - it('preserves the real overflow percentage and reports zero remaining tokens and 0% remaining', () => { + it('preserves overflow percentage and zero remaining', () => { const content = getContextSheetContent( info({ contextTokens: 250_000, contextWindow: 200_000, percentage: 125 }), 0 @@ -243,7 +286,7 @@ describe('getContextSheetContent', () => { expect(content.tone).toBe('destructive'); }); - it('reports used tokens, an unavailable window, and the unavailable copy when capacity is unknown', () => { + it('reports unavailable window copy when capacity is unknown', () => { const content = getContextSheetContent( info({ contextWindow: undefined, percentage: undefined, contextTokens: 32_418 }), 0 @@ -258,18 +301,18 @@ describe('getContextSheetContent', () => { expect(content.tone).toBe('neutral'); }); - it('returns null cost when total cost is zero (sheet omits the Total cost row)', () => { - const content = getContextSheetContent(info({ percentage: 20 }), 0); - expect(content.cost).toBeNull(); + it('returns null cost when total cost is zero', () => { + expect(getContextSheetContent(info({ percentage: 20 }), 0).cost).toBeNull(); }); }); describe('getMetricsAccessibilityLabel', () => { - it('includes exact usage, real percentage, humanized cost, and tap intent when capacity is known with cost', () => { - const label = getMetricsAccessibilityLabel( - info({ contextTokens: 84_000, contextWindow: 200_000, percentage: 42 }), - 80_000 - ); + it('includes usage, percentage, humanized cost, and tap intent when interactive', () => { + const label = getMetricsAccessibilityLabel({ + info: info({ contextTokens: 84_000, contextWindow: 200_000, percentage: 42 }), + totalCostMicrodollars: 80_000, + interactive: true, + }); expect(label).toContain('84,000'); expect(label).toContain('200,000'); expect(label).toContain('42%'); @@ -278,51 +321,102 @@ describe('getMetricsAccessibilityLabel', () => { expect(label.toLowerCase()).toContain('context details'); }); - it('omits the cost clause when no positive cost is available', () => { - const label = getMetricsAccessibilityLabel( - info({ contextTokens: 84_000, contextWindow: 200_000, percentage: 42 }), - 0 - ); + it('omits cost when none is available', () => { + const label = getMetricsAccessibilityLabel({ + info: info({ contextTokens: 84_000, contextWindow: 200_000, percentage: 42 }), + totalCostMicrodollars: 0, + interactive: true, + }); expect(label).not.toContain('$'); expect(label).not.toContain('cost'); }); - it('switches to the unavailable-capacity copy and omits percentage/cost when not available', () => { - const label = getMetricsAccessibilityLabel( - info({ contextWindow: undefined, percentage: undefined, contextTokens: 32_418 }), - 0 - ); + it('uses unavailable-capacity copy without percentage when window unknown', () => { + const label = getMetricsAccessibilityLabel({ + info: info({ contextWindow: undefined, percentage: undefined, contextTokens: 32_418 }), + totalCostMicrodollars: 0, + interactive: true, + }); expect(label).toContain('32,418'); expect(label.toLowerCase()).toContain('unavailable'); expect(label).not.toContain('%'); expect(label).not.toContain('$'); }); - it('includes the humanized positive cost in the unknown-capacity case', () => { - const label = getMetricsAccessibilityLabel( - info({ contextWindow: undefined, percentage: undefined, contextTokens: 32_418 }), - 120_000 - ); + it('includes humanized cost in the unknown-capacity case', () => { + const label = getMetricsAccessibilityLabel({ + info: info({ contextWindow: undefined, percentage: undefined, contextTokens: 32_418 }), + totalCostMicrodollars: 120_000, + interactive: true, + }); expect(label).toContain('12 cents'); expect(label).not.toContain('$'); }); - it('preserves the real overflow percentage in the known-capacity case (125%)', () => { - const label = getMetricsAccessibilityLabel( - info({ contextTokens: 250_000, contextWindow: 200_000, percentage: 125 }), - 0 - ); + it('preserves overflow percentage (125%)', () => { + const label = getMetricsAccessibilityLabel({ + info: info({ contextTokens: 250_000, contextWindow: 200_000, percentage: 125 }), + totalCostMicrodollars: 0, + interactive: true, + }); expect(label).toContain('125%'); expect(label).not.toContain('100%'); }); + + it('prefixes platform when known and interactive', () => { + const label = getMetricsAccessibilityLabel({ + info: info({ contextTokens: 84_000, contextWindow: 200_000, percentage: 42 }), + totalCostMicrodollars: 80_000, + platform: 'cli', + interactive: true, + }); + expect(label.startsWith('CLI')).toBe(true); + expect(label.toLowerCase()).toContain('context details'); + }); + + it('drops tap intent when not pressable and reads platform plus cost', () => { + expect( + getMetricsAccessibilityLabel({ + info: undefined, + totalCostMicrodollars: 80_000, + platform: 'cli', + interactive: false, + }) + ).toBe('CLI, cost 8 cents'); + expect( + getMetricsAccessibilityLabel({ + info: undefined, + totalCostMicrodollars: 120_000, + platform: 'cli', + interactive: false, + }) + ).toBe('CLI, cost 12 cents'); + }); + + it('reads only platform, or empty, when there is no info or cost', () => { + expect( + getMetricsAccessibilityLabel({ + info: undefined, + totalCostMicrodollars: null, + platform: 'cli', + interactive: false, + }) + ).toBe('CLI'); + expect( + getMetricsAccessibilityLabel({ + info: undefined, + totalCostMicrodollars: null, + interactive: false, + }) + ).toBe(''); + }); }); describe('pure integration fallback', () => { - it('returns null summary when there is no completed assistant context usage', () => { - // Mirrors the SessionDetailContent integration: when resolveSessionContextInfo - // returns undefined the header falls through to SessionContextCostFallback - // rather than a context control. - const summary = getHeaderSummary(undefined, 80_000); - expect(summary).toBeNull(); + it('keeps a fixed non-interactive pill when context usage is unresolved', () => { + const result = pill({ totalCostMicrodollars: 80_000, hasMessages: true }); + expect(result.interactive).toBe(false); + expect(result.arcFraction).toBe(0); + expect(result.primary).toBe('$0.08'); }); }); diff --git a/apps/mobile/src/components/agents/context-usage-display.ts b/apps/mobile/src/components/agents/context-usage-display.ts index dcaf1c866d..9693c1ae62 100644 --- a/apps/mobile/src/components/agents/context-usage-display.ts +++ b/apps/mobile/src/components/agents/context-usage-display.ts @@ -1,3 +1,4 @@ +import { platformLabel } from '@/lib/platform-label'; import { type SessionContextInfo } from '@/lib/session-context-info'; import { formatSessionTotalCost } from './session-list-helpers'; @@ -85,28 +86,55 @@ export function formatRemainingTokens(remaining: number): string { return formatExactTokens(remaining); } -type HeaderSummary = { - primary: string; - secondary?: string; +export type HeaderPillContent = { + primary: string | null; + secondary: string | null; hasCost: boolean; tone: ContextTone; + /** `undefined` = indeterminate arc; `0` = track only (no usage asserted). */ + arcFraction: number | undefined; + interactive: boolean; }; -export function getHeaderSummary( - info: SessionContextInfo | undefined, - totalCostMicrodollars: number | null -): HeaderSummary | null { - if (!info) { - return null; - } - const tone = getContextTone(info.percentage); - const primary = - info.percentage !== undefined ? `${info.percentage}%` : formatCompactTokens(info.contextTokens); - const secondary = formatSessionTotalCost(totalCostMicrodollars); - if (secondary === null) { - return { primary, hasCost: false, tone }; +/** + * Single selector for the session-detail header pill. Always returns content + * so the pill can reserve a fixed height before context usage resolves. + */ +export function getHeaderPillContent({ + info, + totalCostMicrodollars, + hasMessages, +}: { + info: SessionContextInfo | undefined; + totalCostMicrodollars: number | null; + hasMessages: boolean; +}): HeaderPillContent { + if (info) { + const tone = getContextTone(info.percentage); + const primary = + info.percentage !== undefined + ? `${info.percentage}%` + : formatCompactTokens(info.contextTokens); + const secondary = formatSessionTotalCost(totalCostMicrodollars); + return { + primary, + secondary, + hasCost: secondary !== null, + tone, + arcFraction: getArcFraction(info.percentage), + interactive: true, + }; } - return { primary, secondary, hasCost: true, tone }; + + const costText = hasMessages ? formatSessionTotalCost(totalCostMicrodollars) : null; + return { + primary: costText, + secondary: null, + hasCost: costText !== null, + tone: 'neutral', + arcFraction: 0, + interactive: false, + }; } type ContextSheetContent = { @@ -163,17 +191,39 @@ export function getContextSheetContent( }; } -export function getMetricsAccessibilityLabel( - info: SessionContextInfo, - totalCostMicrodollars: number | null -): string { +export function getMetricsAccessibilityLabel({ + info, + totalCostMicrodollars, + platform, + interactive, +}: { + info: SessionContextInfo | undefined; + totalCostMicrodollars: number | null; + /** Spoken only when the caller has a mapped platform glyph. */ + platform?: string | null; + interactive: boolean; +}): string { + const platformPart = platform != null && platform !== '' ? platformLabel(platform) : null; const spoken = formatSpokenCost(totalCostMicrodollars); - const costPart = spoken ? `, cost ${spoken}` : ''; - if (info.contextWindow === undefined) { - return `Context ${formatExactTokens(info.contextTokens)} tokens, window unavailable${costPart}. Tap to view context details.`; + const tapPart = interactive ? ' Tap to view context details.' : ''; + + if (!info) { + const parts: string[] = []; + if (platformPart) { + parts.push(platformPart); + } + if (spoken) { + parts.push(`cost ${spoken}`); + } + return parts.join(', '); } - const realPercentage = info.percentage ?? 0; - return `Context ${formatExactTokens(info.contextTokens)} of ${formatExactTokens(info.contextWindow)} tokens, ${realPercentage}% used${costPart}. Tap to view context details.`; + + const costPart = spoken ? `, cost ${spoken}` : ''; + const body = + info.contextWindow === undefined + ? `Context ${formatExactTokens(info.contextTokens)} tokens, window unavailable${costPart}.` + : `Context ${formatExactTokens(info.contextTokens)} of ${formatExactTokens(info.contextWindow)} tokens, ${info.percentage ?? 0}% used${costPart}.`; + return platformPart ? `${platformPart}. ${body}${tapPart}` : `${body}${tapPart}`; } type SheetMountState = diff --git a/apps/mobile/src/components/agents/remote-session-row.tsx b/apps/mobile/src/components/agents/remote-session-row.tsx index 578ad9f987..d534c4381d 100644 --- a/apps/mobile/src/components/agents/remote-session-row.tsx +++ b/apps/mobile/src/components/agents/remote-session-row.tsx @@ -1,8 +1,9 @@ import * as Haptics from 'expo-haptics'; import { useEffect } from 'react'; -import { ActionSheetIOS, Alert, Platform, Pressable } from 'react-native'; +import { ActionSheetIOS, Alert, Platform, Pressable, View } from 'react-native'; import { SessionRow } from '@/components/ui/session-row'; +import { useThemeColors } from '@/lib/hooks/use-theme-colors'; import { type ActiveSession } from '@/lib/hooks/use-agent-sessions'; import { isAttentionAcked, @@ -11,6 +12,7 @@ import { useSessionAttentionRevision, } from '@/lib/session-attention'; import { remoteMeta, remoteSessionEyebrowLabel } from './session-list-helpers'; +import { selectRowPlatformPresentation, SessionPlatformIcon } from './session-platform-icon'; import { type RowVariant } from './session-row'; import { copySessionId } from './session-row-actions'; import { @@ -33,6 +35,7 @@ export function RemoteSessionRow({ variant = 'list', interactive = true, }: Readonly) { + const colors = useThemeColors(); const title = session.title.length > 0 ? session.title : 'Untitled session'; const canManage = interactive; const agentLabel = remoteSessionEyebrowLabel(session); @@ -62,6 +65,23 @@ export function RemoteSessionRow({ : session.status.toLowerCase().replaceAll('_', ' '); } + const { iconKind: platformIconKind, spokenPlatform } = selectRowPlatformPresentation({ + platform: session.createdOnPlatform, + variant, + needsInput, + gitUrl: session.gitUrl, + }); + const platformIcon = + platformIconKind != null ? ( + + + + ) : undefined; + const handleLongPress = () => { void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); @@ -96,6 +116,7 @@ export function RemoteSessionRow({ needsInput, badge: agentLabel, meta: spokenMeta, + platform: spokenPlatform, })} className="active:opacity-70" > @@ -107,6 +128,7 @@ export function RemoteSessionRow({ live needsInput={needsInput} metaWhileLive + platformIcon={platformIcon} stripMode={variant === 'card' ? 'edge' : 'inline'} last={variant === 'card' ? true : undefined} className={variant === 'card' ? undefined : 'pl-[22px] pr-[22px]'} diff --git a/apps/mobile/src/components/agents/session-context-metrics.tsx b/apps/mobile/src/components/agents/session-context-metrics.tsx index 3849a1d9bb..d176861e15 100644 --- a/apps/mobile/src/components/agents/session-context-metrics.tsx +++ b/apps/mobile/src/components/agents/session-context-metrics.tsx @@ -1,28 +1,29 @@ import { Pressable, View } from 'react-native'; import { Text } from '@/components/ui/text'; +import { useThemeColors } from '@/lib/hooks/use-theme-colors'; import { cn } from '@/lib/utils'; import { type SessionContextInfo } from '@/lib/session-context-info'; import { ContextUsageRing } from './context-usage-ring'; import { type ContextTone, - getArcFraction, - getContextTone, - getHeaderSummary, + getHeaderPillContent, getMetricsAccessibilityLabel, } from './context-usage-display'; -import { formatSessionTotalCost } from './session-list-helpers'; -import { formatSpokenCost } from './session-row-accessibility-label'; +import { SessionPlatformIcon } from './session-platform-icon'; type SessionContextMetricsProps = { - info: SessionContextInfo; + info: SessionContextInfo | undefined; + platform: string | null | undefined; totalCostMicrodollars: number | null; - onPress: () => void; + hasMessages: boolean; + onPress?: () => void; }; const RING_SIZE = 28; const RING_STROKE = 3; +const GLYPH_SIZE = 14; const TONE_TEXT_CLASS: Record = { destructive: 'text-destructive', @@ -37,69 +38,83 @@ function toneTextClass(tone: ContextTone): string { export function SessionContextMetrics({ info, + platform, totalCostMicrodollars, + hasMessages, onPress, }: Readonly) { - const summary = getHeaderSummary(info, totalCostMicrodollars); - if (!summary) { - return null; - } - const tone = getContextTone(info.percentage); - const arcFraction = getArcFraction(info.percentage); - const accessibilityLabel = getMetricsAccessibilityLabel(info, totalCostMicrodollars); + const colors = useThemeColors(); + const content = getHeaderPillContent({ info, totalCostMicrodollars, hasMessages }); + // Single source for element kind and a11y affordance wording so a future + // caller with interactive content but no onPress cannot advertise a tap. + const pressable = content.interactive && onPress != null; + const accessibilityLabel = getMetricsAccessibilityLabel({ + info, + totalCostMicrodollars, + platform, + interactive: pressable, + }); - return ( - - - - - {summary.primary} - - {summary.hasCost && summary.secondary ? ( - - {summary.secondary} - - ) : null} + // Exactly 44pt via h-[44px]. rem-scaled h-11 measured ~38.7pt on device with + // NativeWind 5 preview (rem ≈ 14px here), so an arbitrary px value is required + // for the 44pt minimum touch target; height is identical in every pill state. + const pillClassName = + 'h-[44px] flex-row items-center gap-1.5 rounded-full border border-border bg-secondary px-2.5'; + + const body = ( + <> + + + + + - + {content.primary != null ? ( + + + {content.primary} + + {content.hasCost && content.secondary ? ( + + {content.secondary} + + ) : null} + + ) : null} + ); -} -// Preserves the positive-cost header text when no completed context usage -// exists. Marked noninteractive; VoiceOver reads the humanized cost. -export function SessionContextCostFallback({ - totalCostMicrodollars, -}: Readonly<{ totalCostMicrodollars: number | null }>) { - const visible = formatSessionTotalCost(totalCostMicrodollars); - if (visible === null) { - return null; + if (pressable) { + return ( + + {body} + + ); } - const spoken = formatSpokenCost(totalCostMicrodollars); + return ( - - {visible} - + {body} + ); } diff --git a/apps/mobile/src/components/agents/session-detail-content.tsx b/apps/mobile/src/components/agents/session-detail-content.tsx index 4d2b19d231..c8058f61b8 100644 --- a/apps/mobile/src/components/agents/session-detail-content.tsx +++ b/apps/mobile/src/components/agents/session-detail-content.tsx @@ -28,10 +28,7 @@ import { type ContextSheetIdentity, getContextSheetMountState, } from '@/components/agents/context-usage-display'; -import { - SessionContextCostFallback, - SessionContextMetrics, -} from '@/components/agents/session-context-metrics'; +import { SessionContextMetrics } from '@/components/agents/session-context-metrics'; import { SessionContextSheet } from '@/components/agents/session-context-sheet'; import { selectSessionCostInputs } from '@/components/agents/session-list-helpers'; import { buildRemoteAttachmentParts } from '@/components/agents/mobile-session-manager-helpers'; @@ -69,10 +66,6 @@ import { ChildSessionSheet } from '@/components/agents/child-session-sheet'; import { PartRenderer } from '@/components/agents/part-renderer'; import { QueryError } from '@/components/query-error'; import { RenameModal } from '@/components/rename-modal'; -import { - SessionPlatformIcon, - sessionPlatformIconKind, -} from '@/components/agents/session-platform-icon'; import { ScreenHeader } from '@/components/screen-header'; import { BlurBar } from '@/components/ui/blur-bar'; import { Button } from '@/components/ui/button'; @@ -97,8 +90,6 @@ import { revalidateLegacyGatewayOverride, useSessionModelOptions, } from '@/lib/hooks/use-session-model-options'; -import { useThemeColors } from '@/lib/hooks/use-theme-colors'; -import { platformLabel } from '@/lib/platform-label'; import { resolveSessionContextInfo } from '@/lib/session-context-info'; import { areModelPickerSelectionScopesEqual, @@ -126,7 +117,6 @@ export function SessionDetailContent({ }: Readonly) { const manager = useSessionManager(); const router = useRouter(); - const colors = useThemeColors(); const [childSession, setChildSession] = useState<{ sessionId: KiloSessionId; title: string; @@ -239,21 +229,6 @@ export function SessionDetailContent({ (contextInfo.providerID === 'kilo' ? 'Kilo' : contextInfo.providerID), }; }, [contextInfo, sessionModels.options]); - const headerRight = contextInfo ? ( - { - setOpenContextSheetIdentity({ - sessionId, - providerID: contextInfo.providerID, - modelID: contextInfo.modelID, - }); - }} - /> - ) : ( - - ); const sheetMountState = getContextSheetMountState( contextInfo, openContextSheetIdentity, @@ -504,17 +479,25 @@ export function SessionDetailContent({ const handleRenameSave = rename.submit; const handleRenameClose = rename.closeModal; const platform = isSessionLoaded ? (fetchedData.createdOnPlatform ?? null) : null; - const platformKind = sessionPlatformIconKind(platform); - const leadingAccessory = - platform != null && platformKind != null ? ( - - - - ) : null; + const headerRight = ( + 0} + onPress={ + contextInfo + ? () => { + setOpenContextSheetIdentity({ + sessionId, + providerID: contextInfo.providerID, + modelID: contextInfo.modelID, + }); + } + : undefined + } + /> + ); const requiresModel = Boolean(fetchedData?.cloudAgentSessionId); const blockingInteraction = getBlockingInteraction({ activeQuestion, activePermission }); const hasBlockingInteraction = blockingInteraction !== 'none'; @@ -694,7 +677,6 @@ export function SessionDetailContent({ { expect(sessionPlatformIconKind('')).toBeNull(); }); }); + +describe('selectRowPlatformPresentation', () => { + const repoGitUrl = 'git@github.com:org/my-repo.git'; + + it('resolves icon kind for variant=list with a mapped platform', () => { + expect( + selectRowPlatformPresentation({ + platform: 'cli', + variant: 'list', + needsInput: false, + gitUrl: repoGitUrl, + }) + ).toEqual({ iconKind: 'terminal', spokenPlatform: 'cli' }); + }); + + it('returns null iconKind for variant=card', () => { + expect( + selectRowPlatformPresentation({ + platform: 'cli', + variant: 'card', + needsInput: false, + gitUrl: repoGitUrl, + }) + ).toEqual({ iconKind: null, spokenPlatform: undefined }); + }); + + it('leaves spokenPlatform undefined when needsInput', () => { + expect( + selectRowPlatformPresentation({ + platform: 'cli', + variant: 'list', + needsInput: true, + gitUrl: repoGitUrl, + }) + ).toEqual({ iconKind: 'terminal', spokenPlatform: undefined }); + }); + + it('leaves spokenPlatform undefined when the git URL yields no repo name', () => { + expect( + selectRowPlatformPresentation({ + platform: 'cli', + variant: 'list', + needsInput: false, + gitUrl: null, + }) + ).toEqual({ iconKind: 'terminal', spokenPlatform: undefined }); + + expect( + selectRowPlatformPresentation({ + platform: 'cli', + variant: 'list', + needsInput: false, + gitUrl: undefined, + }) + ).toEqual({ iconKind: 'terminal', spokenPlatform: undefined }); + + expect( + selectRowPlatformPresentation({ + platform: 'cli', + variant: 'list', + needsInput: false, + gitUrl: '', + }) + ).toEqual({ iconKind: 'terminal', spokenPlatform: undefined }); + }); + + it('sets spokenPlatform when an icon shows with a repo-name eyebrow (stored git_url shape)', () => { + // Stored rows pass session.git_url into the same gitUrl param. + expect( + selectRowPlatformPresentation({ + platform: 'cloud-agent', + variant: 'list', + needsInput: false, + gitUrl: 'https://github.com/org/stored-repo.git', + }) + ).toEqual({ iconKind: 'cloud', spokenPlatform: 'cloud-agent' }); + }); + + it('sets spokenPlatform when an icon shows with a repo-name eyebrow (live gitUrl shape)', () => { + // Live rows pass session.gitUrl (camelCase ActiveSession field). + expect( + selectRowPlatformPresentation({ + platform: 'cli', + variant: 'list', + needsInput: false, + gitUrl: 'git@github.com:org/live-repo.git', + }) + ).toEqual({ iconKind: 'terminal', spokenPlatform: 'cli' }); + }); + + it('returns null iconKind and no spoken platform for unmapped platforms', () => { + expect( + selectRowPlatformPresentation({ + platform: 'linear', + variant: 'list', + needsInput: false, + gitUrl: repoGitUrl, + }) + ).toEqual({ iconKind: null, spokenPlatform: undefined }); + }); +}); diff --git a/apps/mobile/src/components/agents/session-platform-icon.tsx b/apps/mobile/src/components/agents/session-platform-icon.tsx index 4cf11d7aa6..96a7682da5 100644 --- a/apps/mobile/src/components/agents/session-platform-icon.tsx +++ b/apps/mobile/src/components/agents/session-platform-icon.tsx @@ -3,6 +3,7 @@ import { Cloud, Code, Terminal } from 'lucide-react-native'; import { GitHubIcon } from '@/components/icons/github-icon'; import { SlackIcon } from '@/components/icons/slack-icon'; +import { repoNameFromGitUrl } from './session-list-helpers'; type SessionPlatformIconKind = 'cloud' | 'terminal' | 'code' | 'slack' | 'github'; @@ -30,6 +31,41 @@ export function sessionPlatformIconKind( return PLATFORM_TO_KIND[platform] ?? null; } +type RowPlatformPresentationInput = Readonly<{ + platform: string | null | undefined; + variant: 'list' | 'card'; + needsInput: boolean; + gitUrl: string | null | undefined; +}>; + +type RowPlatformPresentation = Readonly<{ + iconKind: SessionPlatformIconKind | null; + spokenPlatform: string | undefined; +}>; + +/** + * Shared list/card platform glyph + VoiceOver rule for stored and live rows. + * Icon only for `variant === 'list'` with a mapped platform. Platform is + * spoken only when an icon is shown, the row is not needs-input, and the + * eyebrow is a repo name (so the badge does not already speak the platform). + */ +export function selectRowPlatformPresentation({ + platform, + variant, + needsInput, + gitUrl, +}: RowPlatformPresentationInput): RowPlatformPresentation { + const iconKind = variant === 'list' ? sessionPlatformIconKind(platform) : null; + const spokenPlatform = + iconKind != null && !needsInput && repoNameFromGitUrl(gitUrl) != null + ? (platform ?? undefined) + : undefined; + return { + iconKind, + spokenPlatform: spokenPlatform === '' ? undefined : spokenPlatform, + }; +} + type SessionPlatformIconProps = Readonly<{ platform: string | null | undefined; size: number; diff --git a/apps/mobile/src/components/agents/session-row.tsx b/apps/mobile/src/components/agents/session-row.tsx index ff8af28106..806e805e22 100644 --- a/apps/mobile/src/components/agents/session-row.tsx +++ b/apps/mobile/src/components/agents/session-row.tsx @@ -17,10 +17,9 @@ import { composeStoredSessionVisibleMeta, formatMeta, formatSessionTotalCost, - repoNameFromGitUrl, storedSessionEyebrowLabel, } from './session-list-helpers'; -import { SessionPlatformIcon, sessionPlatformIconKind } from './session-platform-icon'; +import { selectRowPlatformPresentation, SessionPlatformIcon } from './session-platform-icon'; import { formatSpokenCost, formatSpokenTimeAgo, @@ -185,8 +184,13 @@ export function StoredSessionRow({ // Platform icon only on the Agents list variant. Home cards stay // byte-identical (platformIcon defaults to undefined). - const platformIconKind = - variant === 'list' ? sessionPlatformIconKind(session.created_on_platform) : null; + const { iconKind: platformIconKind, spokenPlatform: a11yPlatform } = + selectRowPlatformPresentation({ + platform: session.created_on_platform, + variant, + needsInput, + gitUrl: session.git_url, + }); const platformIcon = platformIconKind != null ? ( @@ -198,14 +202,6 @@ export function StoredSessionRow({ ) : undefined; - // Speak the platform only when an icon is shown, not needs-input, AND the - // eyebrow badge is a repo name (otherwise the badge already speaks the - // platform label and appending would be redundant). - const a11yPlatform = - platformIconKind != null && !needsInput && repoNameFromGitUrl(session.git_url) != null - ? session.created_on_platform - : undefined; - return ( <> void; @@ -43,7 +37,6 @@ export function ScreenHeader({ eyebrow, size = 'default', headerRight, - leadingAccessory, modal, showBackButton, onBack, @@ -76,6 +69,9 @@ export function ScreenHeader({ {title} ); + // Title caret removed: rename stays available via the pressable title + // itself. The backIcon === 'close' ChevronDown on the back control is + // unrelated and stays. titleNode = onTitlePress ? ( {titleText} - ) : ( titleText @@ -119,7 +114,6 @@ export function ScreenHeader({ )} )} - {leadingAccessory != null ? {leadingAccessory} : null} {eyebrow ? {eyebrow} : null} {titleNode} diff --git a/apps/mobile/src/components/share/share-cli-spawn.test.ts b/apps/mobile/src/components/share/share-cli-spawn.test.ts new file mode 100644 index 0000000000..545a4287d3 --- /dev/null +++ b/apps/mobile/src/components/share/share-cli-spawn.test.ts @@ -0,0 +1,227 @@ +import { type KiloSessionId } from '@kilocode/cloud-agent-sdk'; +import { describe, expect, it } from 'vitest'; + +import { getSpawnedAgentSessionPath } from '@/components/agents/session-detail-routes'; +import { + REMOTE_SPAWN_NON_RETRYABLE_TOAST, + REMOTE_SPAWN_RETRYABLE_TOAST, + resolveRemoteSubmitOutcome, +} from '@/lib/remote-submit-outcome'; + +import { resolveShareDestinationAdmission } from './share-cli-admission'; +import { + selectShareCliSpawnRows, + type ShareCliSpawnRow, + shouldCommitShareSpawnReady, +} from './share-cli-spawn'; + +function instance( + overrides: Partial & Pick +): ShareCliSpawnRow { + return { + name: 'laptop', + projectName: 'kilo', + ...overrides, + }; +} + +const ROWS: readonly ShareCliSpawnRow[] = [ + instance({ connectionId: 'conn-1', name: 'MacBook', projectName: 'cloud' }), + instance({ + connectionId: 'conn-2', + name: 'Studio', + projectName: 'mobile', + capabilities: { attachments: true }, + }), +]; + +/** Mirrors the gate's appendShareId helper for href construction coverage. */ +function appendShareId(base: string, shareId: string): string { + const separator = base.includes('?') ? '&' : '?'; + return `${base}${separator}shareId=${encodeURIComponent(shareId)}`; +} + +describe('selectShareCliSpawnRows', () => { + it('returns no rows when there are no instances', () => { + expect( + selectShareCliSpawnRows({ + instances: [], + organizationId: null, + orgLoaded: true, + gateShowsNewSession: true, + }) + ).toEqual([]); + }); + + it('returns no rows when an organization id is present', () => { + expect( + selectShareCliSpawnRows({ + instances: ROWS, + organizationId: 'org-1', + orgLoaded: true, + gateShowsNewSession: true, + }) + ).toEqual([]); + }); + + it('returns rows for a personal account once org context is loaded', () => { + expect( + selectShareCliSpawnRows({ + instances: ROWS, + organizationId: null, + orgLoaded: true, + gateShowsNewSession: true, + }) + ).toEqual(ROWS); + }); + + it('returns no rows while org context is still loading (null is not yet personal)', () => { + expect( + selectShareCliSpawnRows({ + instances: ROWS, + organizationId: null, + orgLoaded: false, + gateShowsNewSession: true, + }) + ).toEqual([]); + }); + + it('returns no rows in a terminal gate state (New-session row hidden)', () => { + expect( + selectShareCliSpawnRows({ + instances: ROWS, + organizationId: null, + orgLoaded: true, + gateShowsNewSession: false, + }) + ).toEqual([]); + }); +}); + +describe('share CLI spawn admission', () => { + it('refuses a file payload against an instance without attachments', () => { + expect( + resolveShareDestinationAdmission({ + createdOnPlatform: 'cli', + live: true, + attachmentsCapable: instance({ connectionId: 'c' }).capabilities?.attachments === true, + hasFiles: true, + }) + ).toEqual({ + ok: false, + title: "This session can't receive files", + message: + "The Kilo CLI running this session can't receive files. Update the CLI on that machine, or share to a new session instead.", + }); + }); + + it('admits a text-only payload against an instance without attachments', () => { + expect( + resolveShareDestinationAdmission({ + createdOnPlatform: 'cli', + live: true, + attachmentsCapable: instance({ connectionId: 'c' }).capabilities?.attachments === true, + hasFiles: false, + }) + ).toEqual({ ok: true }); + }); + + it('admits a file payload when the instance advertises attachments', () => { + expect( + resolveShareDestinationAdmission({ + createdOnPlatform: 'cli', + live: true, + attachmentsCapable: + instance({ connectionId: 'c', capabilities: { attachments: true } }).capabilities + ?.attachments === true, + hasFiles: true, + }) + ).toEqual({ ok: true }); + }); +}); + +describe('spawn outcome → action mapping (resolveRemoteSubmitOutcome)', () => { + const SESSION_ID = 'ses_12345678901234567890123456' as KiloSessionId; + const CONNECTION_ID = 'conn-1'; + const SHARE_ID = 'share-abc'; + + it('maps ready to navigate; gate builds spawned personal href with shareId', () => { + const action = resolveRemoteSubmitOutcome({ + outcome: { status: 'ready', sessionID: SESSION_ID }, + refetchedInstances: [], + selectedConnectionId: CONNECTION_ID, + }); + expect(action).toEqual({ kind: 'navigate', sessionID: SESSION_ID }); + if (action.kind !== 'navigate') { + return; + } + // Gate path: appendShareId(getSpawnedAgentSessionPath(...) as string, shareId) + // — no organizationId (personal CLI session). + const href = appendShareId(getSpawnedAgentSessionPath(action.sessionID) as string, SHARE_ID); + expect(href).toContain(`/agent-chat/${SESSION_ID}`); + expect(href).toContain('spawned=1'); + expect(href).toContain(`shareId=${SHARE_ID}`); + expect(href).not.toContain('organizationId'); + }); + + it('maps retryable to the retryable toast and refetch flag', () => { + const action = resolveRemoteSubmitOutcome({ + outcome: { status: 'retryable', reason: 'timeout', cause: new Error('timeout') }, + refetchedInstances: [], + selectedConnectionId: CONNECTION_ID, + }); + expect(action).toMatchObject({ + kind: 'retryable', + toast: REMOTE_SPAWN_RETRYABLE_TOAST, + shouldRefetchInstances: true, + }); + }); + + it('maps nonRetryable to the non-retryable toast without refetch', () => { + expect( + resolveRemoteSubmitOutcome({ + outcome: { status: 'nonRetryable', reason: 'upgrade', cause: new Error('upgrade') }, + refetchedInstances: [], + selectedConnectionId: CONNECTION_ID, + }) + ).toEqual({ kind: 'nonRetryable', toast: REMOTE_SPAWN_NON_RETRYABLE_TOAST }); + }); +}); + +describe('shouldCommitShareSpawnReady', () => { + it('is false when a share already committed (commit race)', () => { + expect( + shouldCommitShareSpawnReady({ + committedShareId: 'share-1', + payloadStillStaged: true, + }) + ).toBe(false); + }); + + it('is false when the payload was cleared mid-spawn (dismiss/unmount)', () => { + expect( + shouldCommitShareSpawnReady({ + committedShareId: null, + payloadStillStaged: false, + }) + ).toBe(false); + }); + + it('is true when nothing committed and the payload is still staged', () => { + expect( + shouldCommitShareSpawnReady({ + committedShareId: null, + payloadStillStaged: true, + }) + ).toBe(true); + }); + + it('is false when both committed and payload already gone', () => { + expect( + shouldCommitShareSpawnReady({ + committedShareId: 'share-1', + payloadStillStaged: false, + }) + ).toBe(false); + }); +}); diff --git a/apps/mobile/src/components/share/share-cli-spawn.ts b/apps/mobile/src/components/share/share-cli-spawn.ts new file mode 100644 index 0000000000..db48f537ef --- /dev/null +++ b/apps/mobile/src/components/share/share-cli-spawn.ts @@ -0,0 +1,55 @@ +import { type inferRouterOutputs, type RootRouter } from '@kilocode/trpc'; + +type RouterOutputs = inferRouterOutputs; + +/** + * One connected CLI instance from `activeSessions.listInstances`. Derived + * from tRPC so S3's optional `capabilities.attachments` is present without + * copying shapes (do not use `InstancePickerInstance`, which omits it). + */ +export type ShareCliSpawnRow = + RouterOutputs['activeSessions']['listInstances']['instances'][number]; + +/** + * Rows to offer as "new session on a connected CLI" in the share gate. + * + * Empty unless the org context has finished loading as personal + * (`orgLoaded && organizationId == null`), the gate shows its New-session + * row, and there is at least one connected instance. Before org load, + * `organizationId` defaults to `null` with `isLoaded: false` — that must + * not collapse into "personal". + */ +export function selectShareCliSpawnRows({ + instances, + organizationId, + orgLoaded, + gateShowsNewSession, +}: { + instances: readonly ShareCliSpawnRow[]; + organizationId: string | null | undefined; + orgLoaded: boolean; + gateShowsNewSession: boolean; +}): readonly ShareCliSpawnRow[] { + if (!orgLoaded || organizationId != null || !gateShowsNewSession) { + return []; + } + if (instances.length === 0) { + return []; + } + return instances; +} + +/** + * Whether a `ready` spawn may commit navigation with the staged share. + * False when another destination already committed (19c) or the user + * dismissed/unmounted mid-spawn and cleared the payload (19b). + */ +export function shouldCommitShareSpawnReady({ + committedShareId, + payloadStillStaged, +}: { + committedShareId: string | null; + payloadStillStaged: boolean; +}): boolean { + return committedShareId == null && payloadStillStaged; +} diff --git a/apps/mobile/src/components/share/share-destination-list.tsx b/apps/mobile/src/components/share/share-destination-list.tsx index 33d561c94e..d45e41ed28 100644 --- a/apps/mobile/src/components/share/share-destination-list.tsx +++ b/apps/mobile/src/components/share/share-destination-list.tsx @@ -1,14 +1,23 @@ -import { Search } from 'lucide-react-native'; +import { Search, Terminal } from 'lucide-react-native'; import { useMemo, useState } from 'react'; -import { FlatList, TextInput, View, type ViewStyle } from 'react-native'; +import { + ActivityIndicator, + FlatList, + Pressable, + TextInput, + View, + type ViewStyle, +} from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { SessionListSectionHeader } from '@/components/agents/session-list-section-header'; import { StoredSessionRow } from '@/components/agents/session-row'; import { QueryError } from '@/components/query-error'; import { Skeleton } from '@/components/ui/skeleton'; import { Text } from '@/components/ui/text'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; +import { type ShareCliSpawnRow } from './share-cli-spawn'; import { type ShareDestinationRow } from './share-destinations'; import { type ShareGateState } from './share-gate-state'; @@ -20,6 +29,11 @@ type ShareDestinationListProps = { destinations: readonly ShareDestinationRow[]; onSelect: (row: ShareDestinationRow) => void; onRetry: () => void; + instances: readonly ShareCliSpawnRow[]; + spawningConnectionId: string | null; + instanceRowsDisabled: boolean; + destinationsDisabled: boolean; + onSpawnInstance: (row: ShareCliSpawnRow) => void; }; function DestinationSearch({ onChange }: { onChange: (next: string) => void }) { @@ -63,6 +77,62 @@ function EmptyMessage({ message }: { message: string }) { ); } +function CliInstanceRows({ + instances, + spawningConnectionId, + instanceRowsDisabled, + onSpawnInstance, +}: { + instances: readonly ShareCliSpawnRow[]; + spawningConnectionId: string | null; + instanceRowsDisabled: boolean; + onSpawnInstance: (row: ShareCliSpawnRow) => void; +}) { + const colors = useThemeColors(); + + return ( + + + {instances.map(row => { + const isThisSpawning = spawningConnectionId === row.connectionId; + const disabled = instanceRowsDisabled; + return ( + { + onSpawnInstance(row); + } + } + disabled={disabled} + accessibilityRole="button" + accessibilityLabel={`New session on ${row.name}`} + accessibilityState={{ disabled }} + className={`flex-row items-center gap-3 border-b border-border px-4 py-3.5 ${ + disabled ? 'opacity-50' : 'active:opacity-70' + }`} + > + + + + + + {row.name} + + + {row.projectName} + + + {isThisSpawning ? : null} + + ); + })} + + ); +} + /** * Destination FlatList for the share gate. Must be a direct child of the * formSheet screen content (paired with the collapsable header View). @@ -74,6 +144,11 @@ export function ShareDestinationList({ destinations, onSelect, onRetry, + instances, + spawningConnectionId, + instanceRowsDisabled, + destinationsDisabled, + onSpawnInstance, }: Readonly) { const { bottom } = useSafeAreaInsets(); const [search, setSearch] = useState(''); @@ -98,13 +173,28 @@ export function ShareDestinationList({ [bottom] ); + const cliSection = + instances.length > 0 ? ( + + ) : null; + if (state.kind === 'loading') { return ( `skeleton-${index}`} - ListHeaderComponent={} + ListHeaderComponent={ + <> + {cliSection} + + + } renderItem={() => null} contentContainerStyle={contentPad} keyboardShouldPersistTaps="handled" @@ -118,6 +208,7 @@ export function ShareDestinationList({ className="flex-1 bg-background" data={[] as ShareDestinationRow[]} keyExtractor={() => 'error'} + ListHeaderComponent={cliSection} ListEmptyComponent={ } @@ -134,6 +225,7 @@ export function ShareDestinationList({ className="flex-1 bg-background" data={[] as ShareDestinationRow[]} keyExtractor={() => 'empty'} + ListHeaderComponent={cliSection} ListEmptyComponent={} renderItem={() => null} contentContainerStyle={growContentPad} @@ -144,6 +236,7 @@ export function ShareDestinationList({ // Terminal non-retryable states: header already shows the message; keep an // empty FlatList so the formSheet still has [header, list] as direct children. + // No CLI section (criterion 20). if (state.kind === 'stale-share' || state.kind === 'non-retryable-classification') { return ( item.session_id} - ListHeaderComponent={showSearch ? : null} + ListHeaderComponent={ + <> + {cliSection} + {showSearch ? : null} + + } keyboardShouldPersistTaps="handled" keyboardDismissMode="on-drag" contentContainerStyle={contentPad} renderItem={({ item }) => ( - { - onSelect(item); - }} - /> + + { + if (destinationsDisabled) { + return; + } + onSelect(item); + }} + /> + )} ListEmptyComponent={search.trim() ? : null} /> diff --git a/apps/mobile/src/components/share/share-gate-sheet.tsx b/apps/mobile/src/components/share/share-gate-sheet.tsx index aeaf3376a7..77488de55b 100644 --- a/apps/mobile/src/components/share/share-gate-sheet.tsx +++ b/apps/mobile/src/components/share/share-gate-sheet.tsx @@ -1,25 +1,40 @@ +/* eslint-disable max-lines -- Share gate owns commit, destination admission, and CLI-spawn orchestration in one formSheet body. */ +import { useQuery } from '@tanstack/react-query'; import * as Haptics from 'expo-haptics'; import { useRouter } from 'expo-router'; import { Plus, X } from 'lucide-react-native'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Alert, Pressable, View } from 'react-native'; +import { toast } from 'sonner-native'; -import { getAgentSessionPath } from '@/components/agents/session-detail-routes'; +import { + getAgentSessionPath, + getSpawnedAgentSessionPath, +} from '@/components/agents/session-detail-routes'; import { expandPlatformFilter } from '@/components/agents/session-list-helpers'; import { getNewAgentSessionPath } from '@/components/agents/session-list-routes'; import { Button } from '@/components/ui/button'; import { Text } from '@/components/ui/text'; import { useAgentSessions } from '@/lib/hooks/use-agent-sessions'; +import { useRemoteInstanceSpawn } from '@/lib/hooks/use-remote-instance-spawn'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; import { useOrganization } from '@/lib/organization-context'; +import { resolveRemoteSubmitOutcome } from '@/lib/remote-submit-outcome'; import { setPendingShareNavigation } from '@/lib/share-navigation'; import { clearSharePayload, peekSharePayload, type ShareId } from '@/lib/share-payload'; +import { shouldShowRunOnSelector } from '@/lib/should-show-run-on-selector'; +import { useTRPC } from '@/lib/trpc'; import { resolveShareDestinationAdmission, resolveShareHasFiles, type ShareDestinationAdmission, } from './share-cli-admission'; +import { + selectShareCliSpawnRows, + type ShareCliSpawnRow, + shouldCommitShareSpawnReady, +} from './share-cli-spawn'; import { selectShareDestinations, type ShareDestinationRow } from './share-destinations'; import { ShareDestinationList } from './share-destination-list'; import { isShareCommitEnabled, selectShareGateState } from './share-gate-state'; @@ -42,6 +57,7 @@ type ShareGateSheetProps = { export function ShareGateSheet({ shareId }: Readonly) { const router = useRouter(); const colors = useThemeColors(); + const trpc = useTRPC(); const { organizationId, isLoaded: orgLoaded } = useOrganization(); // Org-scoped stored page only (cloud-agent + cli). Active list is an // id/capability lookup — never a row source (no organizationId filter). @@ -51,6 +67,21 @@ export function ShareGateSheet({ shareId }: Readonly) { enabled: orgLoaded, }); + const { spawn } = useRemoteInstanceSpawn(); + const [spawningConnectionId, setSpawningConnectionId] = useState(null); + // Per-attempt token so a stale spawn's finally cannot clear a newer lock + // (share replace mid-flight, or same-connection re-tap after replace). + const spawnAttemptRef = useRef(0); + const isSpawning = spawningConnectionId !== null; + + const { data: instancesData, refetch: refetchInstances } = useQuery({ + ...trpc.activeSessions.listInstances.queryOptions(undefined, { + refetchOnWindowFocus: true, + staleTime: 5000, + }), + enabled: orgLoaded && shouldShowRunOnSelector(organizationId), + }); + // Committed id survives param replace + dismiss animation; only that id // is exempt from clear on param change / unmount. const committedShareIdRef = useRef(null); @@ -61,11 +92,16 @@ export function ShareGateSheet({ shareId }: Readonly) { // When S1 replaces an open gate with a newer shareId, clear the older id // only if it was not committed. A committed previous id must survive the - // dismiss animation while a newer shareId is focused. + // dismiss animation while a newer shareId is focused. Also drop the spawn + // lock so a stale in-flight spawn cannot leave the new gate's commit + // affordances disabled until it settles. useEffect(() => { const previous = previousShareIdRef.current; - if (previous && previous !== shareId && previous !== committedShareIdRef.current) { - clearSharePayload(previous); + if (previous && previous !== shareId) { + if (previous !== committedShareIdRef.current) { + clearSharePayload(previous); + } + setSpawningConnectionId(null); } previousShareIdRef.current = shareId; }, [shareId]); @@ -133,6 +169,17 @@ export function ShareGateSheet({ shareId }: Readonly) { ] ); + const instanceRows = useMemo( + () => + selectShareCliSpawnRows({ + instances: instancesData?.instances ?? [], + organizationId, + orgLoaded, + gateShowsNewSession: state.showNewSession, + }), + [instancesData?.instances, organizationId, orgLoaded, state.showNewSession] + ); + const abandon = useCallback(() => { const id = ownedShareIdRef.current; // Committed ids survive every gate-side clear; delivery owns consumption. @@ -173,16 +220,20 @@ export function ShareGateSheet({ shareId }: Readonly) { ); const handleNewSession = useCallback(() => { - if (!shareId) { + if (!shareId || isSpawning) { return; } const base = getNewAgentSessionPath(organizationId); commit(appendShareId(base, shareId)); - }, [commit, organizationId, shareId]); + }, [commit, isSpawning, organizationId, shareId]); + + const commitEnabled = isShareCommitEnabled({ orgLoaded, validation }); + const instanceRowsDisabled = !commitEnabled || isSpawning; + const newSessionDisabled = !commitEnabled || isSpawning; const handleSelectDestination = useCallback( (row: ShareDestinationRow) => { - if (!shareId) { + if (!shareId || isSpawning) { return; } const admission: ShareDestinationAdmission = resolveShareDestinationAdmission({ @@ -201,7 +252,72 @@ export function ShareGateSheet({ shareId }: Readonly) { const base = getAgentSessionPath(row.session_id, org) as string; commit(appendShareId(base, shareId)); }, - [attachmentsCapableBySessionId, commit, payload, shareId, validation] + [attachmentsCapableBySessionId, commit, isSpawning, payload, shareId, validation] + ); + + const handleSpawnInstance = useCallback( + (instance: ShareCliSpawnRow) => { + if (!shareId || !commitEnabled || isSpawning) { + return; + } + + const admission = resolveShareDestinationAdmission({ + createdOnPlatform: 'cli', + live: true, + attachmentsCapable: instance.capabilities?.attachments === true, + hasFiles: resolveShareHasFiles(validation, payload?.files.length ?? 0), + }); + if (!admission.ok) { + Alert.alert(admission.title, admission.message); + return; + } + + void (async () => { + spawnAttemptRef.current += 1; + const attempt = spawnAttemptRef.current; + setSpawningConnectionId(instance.connectionId); + try { + const outcome = await spawn(instance.connectionId); + // Gate has no "Run on" selection; ignore selection-reset flags. + const action = resolveRemoteSubmitOutcome({ + outcome, + refetchedInstances: [], + selectedConnectionId: instance.connectionId, + }); + + if (action.kind === 'navigate') { + if ( + !shouldCommitShareSpawnReady({ + committedShareId: committedShareIdRef.current, + payloadStillStaged: peekSharePayload(shareId) !== null, + }) + ) { + return; + } + commit(appendShareId(getSpawnedAgentSessionPath(action.sessionID) as string, shareId)); + return; + } + + if (action.kind === 'retryable') { + toast.error(action.toast); + try { + await refetchInstances(); + } catch { + // Stay open with the staged payload; user can retry. + } + return; + } + + toast.error(action.toast); + } finally { + // Only the attempt that still owns the lock may clear it. + if (spawnAttemptRef.current === attempt) { + setSpawningConnectionId(null); + } + } + })(); + }, + [commit, commitEnabled, isSpawning, payload, refetchInstances, shareId, spawn, validation] ); const handleRetry = useCallback(() => { @@ -212,7 +328,6 @@ export function ShareGateSheet({ shareId }: Readonly) { const showTerminalMessage = state.kind === 'stale-share' || state.kind === 'non-retryable-classification'; const previewPayload = payload !== null && state.kind !== 'stale-share' ? payload : null; - const commitEnabled = isShareCommitEnabled({ orgLoaded, validation }); // Header block: title+close, preview, New session. collapsable={false} is // required so react-native-screens finds it as the formSheet header. @@ -245,13 +360,13 @@ export function ShareGateSheet({ shareId }: Readonly) { {showNewSession ? ( @@ -272,6 +387,11 @@ export function ShareGateSheet({ shareId }: Readonly) { destinations={destinations} onSelect={handleSelectDestination} onRetry={handleRetry} + instances={instanceRows} + spawningConnectionId={spawningConnectionId} + instanceRowsDisabled={instanceRowsDisabled} + destinationsDisabled={isSpawning} + onSpawnInstance={handleSpawnInstance} /> ); diff --git a/apps/mobile/src/lib/share-to-new-remote-session.test.ts b/apps/mobile/src/lib/share-to-new-remote-session.test.ts index d0e48faedc..512594e728 100644 --- a/apps/mobile/src/lib/share-to-new-remote-session.test.ts +++ b/apps/mobile/src/lib/share-to-new-remote-session.test.ts @@ -46,7 +46,7 @@ describe('share-to-new-remote-session copy', () => { expect(SHARE_TO_NEW_REMOTE_SESSION_ALERT).toEqual({ title: "Can't share to a new remote session", message: - "A session started on a remote CLI can't receive shared text or files. Start a cloud session, or share again and pick the running CLI session.", + "A session started on a remote CLI can't receive shared text or files from this screen. Start a cloud session, or open the share sheet and pick a connected CLI to start a new session there.", }); }); diff --git a/apps/mobile/src/lib/share-to-new-remote-session.ts b/apps/mobile/src/lib/share-to-new-remote-session.ts index b1347bdeb0..5d15ee95ca 100644 --- a/apps/mobile/src/lib/share-to-new-remote-session.ts +++ b/apps/mobile/src/lib/share-to-new-remote-session.ts @@ -2,7 +2,7 @@ export const SHARE_TO_NEW_REMOTE_SESSION_ALERT = { title: "Can't share to a new remote session", message: - "A session started on a remote CLI can't receive shared text or files. Start a cloud session, or share again and pick the running CLI session.", + "A session started on a remote CLI can't receive shared text or files from this screen. Start a cloud session, or open the share sheet and pick a connected CLI to start a new session there.", } as const; /** diff --git a/apps/mobile/src/lib/should-show-run-on-selector.test.ts b/apps/mobile/src/lib/should-show-run-on-selector.test.ts index bea361b577..d37108c7f8 100644 --- a/apps/mobile/src/lib/should-show-run-on-selector.test.ts +++ b/apps/mobile/src/lib/should-show-run-on-selector.test.ts @@ -3,10 +3,14 @@ import { describe, expect, it } from 'vitest'; import { shouldShowRunOnSelector } from './should-show-run-on-selector'; describe('shouldShowRunOnSelector', () => { - it('shows the selector on a personal flow (no organizationId)', () => { + it('shows the selector on a personal flow (organizationId undefined)', () => { expect(shouldShowRunOnSelector(undefined)).toBe(true); }); + it('shows the selector when organizationId is null (share-gate personal)', () => { + expect(shouldShowRunOnSelector(null)).toBe(true); + }); + it('hides the selector on an org-scoped flow (organizationId present)', () => { expect(shouldShowRunOnSelector('org-123')).toBe(false); }); diff --git a/apps/mobile/src/lib/should-show-run-on-selector.ts b/apps/mobile/src/lib/should-show-run-on-selector.ts index 4f94184964..0323dfead8 100644 --- a/apps/mobile/src/lib/should-show-run-on-selector.ts +++ b/apps/mobile/src/lib/should-show-run-on-selector.ts @@ -1,14 +1,18 @@ /** - * Whether the new-agent screen should show the "Run on" instance selector. + * Whether personal-only remote CLI surfaces should show (the new-agent + * "Run on" selector, and the share gate's connected-CLI spawn rows). * - * Org-scoped flows (where the route param `organizationId` is present) are - * Cloud-Agent only by design: a remote `kilo remote` instance spawns a - * personal CLI session that mobile's data model can only surface on - * personal routes. Offering a personal-instance picker inside an org flow - * would create sessions invisible in the org's context, so the row is - * hidden entirely — this is not a feature state, it's an absent-by-design - * UI branch. + * Org-scoped flows are Cloud-Agent only by design: a remote `kilo remote` + * instance spawns a personal CLI session that mobile's data model can only + * surface on personal routes. Offering a personal-instance picker inside an + * org flow would create sessions invisible in the org's context, so the + * affordance is hidden entirely — this is not a feature state, it's an + * absent-by-design UI branch. + * + * Accepts both absent forms so one predicate covers both call sites: + * - new-session route param: `string | undefined` (`undefined` = personal) + * - share gate `useOrganization()`: `string | null` (`null` = personal) */ -export function shouldShowRunOnSelector(organizationId: string | undefined): boolean { - return organizationId === undefined; +export function shouldShowRunOnSelector(organizationId: string | null | undefined): boolean { + return organizationId == null; } diff --git a/apps/web/src/routers/active-sessions-router.test.ts b/apps/web/src/routers/active-sessions-router.test.ts index 5dd9a68080..382ab7a79c 100644 --- a/apps/web/src/routers/active-sessions-router.test.ts +++ b/apps/web/src/routers/active-sessions-router.test.ts @@ -113,6 +113,55 @@ describe('active-sessions-router', () => { expect(result).toEqual({ instances: [] }); }); + it('passes through capabilities when the worker advertises them', async () => { + jest.spyOn(global, 'fetch').mockResolvedValue( + new Response( + JSON.stringify({ + instances: [ + { + connectionId: 'cli-cap', + name: 'laptop-cap', + projectName: 'kilo', + capabilities: { attachments: true }, + }, + ], + }), + { status: 200, headers: { 'Content-Type': 'application/json' } } + ) + ); + + const caller = await createCallerForUser(regularUser.id); + const result = await caller.activeSessions.listInstances(); + expect(result).toEqual({ + instances: [ + { + connectionId: 'cli-cap', + name: 'laptop-cap', + projectName: 'kilo', + capabilities: { attachments: true }, + }, + ], + }); + }); + + it('keeps capabilities absent when the worker omits them (legacy CLI)', async () => { + jest.spyOn(global, 'fetch').mockResolvedValue( + new Response( + JSON.stringify({ + instances: [{ connectionId: 'cli-legacy', name: 'laptop-legacy', projectName: 'kilo' }], + }), + { status: 200, headers: { 'Content-Type': 'application/json' } } + ) + ); + + const caller = await createCallerForUser(regularUser.id); + const result = await caller.activeSessions.listInstances(); + expect(result).toEqual({ + instances: [{ connectionId: 'cli-legacy', name: 'laptop-legacy', projectName: 'kilo' }], + }); + expect(result.instances[0]).not.toHaveProperty('capabilities'); + }); + it('throws a TRPCError when the upstream worker returns a non-2xx response', async () => { jest .spyOn(global, 'fetch') diff --git a/apps/web/src/routers/active-sessions-router.ts b/apps/web/src/routers/active-sessions-router.ts index 40d37f301f..6003049cc9 100644 --- a/apps/web/src/routers/active-sessions-router.ts +++ b/apps/web/src/routers/active-sessions-router.ts @@ -38,6 +38,12 @@ const connectedInstanceSchema = z.object({ name: z.string(), projectName: z.string(), version: z.string().optional(), + /** + * Capabilities advertised by this connected CLI instance. Omitted when the + * CLI's latest attachment did not include a capabilities object (legacy CLI + * or a build that predates the field). + */ + capabilities: z.object({ attachments: z.boolean().optional() }).optional(), }); const connectedInstancesResponseSchema = z.object({ diff --git a/services/session-ingest/src/dos/UserConnectionDO.test.ts b/services/session-ingest/src/dos/UserConnectionDO.test.ts index 6b07ff7b94..e2f3863a3e 100644 --- a/services/session-ingest/src/dos/UserConnectionDO.test.ts +++ b/services/session-ingest/src/dos/UserConnectionDO.test.ts @@ -3531,6 +3531,44 @@ describe('UserConnectionDO', () => { ]); }); + it('includes capabilities when the CLI attachment advertises them', () => { + const { doInstance, mockCtx } = setup(); + // Hibernated attachment carries capabilities — same source + // getConnectedInstances already uses for instance/version. + const cliWs = createMockWs(['cli'], { + role: 'cli', + connectionId: 'cli-cap', + sessions: [], + instance: { name: 'laptop-cap', projectName: 'kilo' }, + capabilities: { attachments: true }, + }); + mockCtx.addSocket(cliWs); + + const { instances } = doInstance.getConnectedInstances(); + expect(instances).toEqual([ + { + connectionId: 'cli-cap', + name: 'laptop-cap', + projectName: 'kilo', + capabilities: { attachments: true }, + }, + ]); + }); + + it('omits capabilities when the CLI attachment has none (legacy CLI)', () => { + const { doInstance, mockCtx } = setup(); + addCliSocket(mockCtx, 'cli-legacy-cap', [], { + name: 'laptop-legacy', + projectName: 'kilo', + }); + + const { instances } = doInstance.getConnectedInstances(); + expect(instances).toEqual([ + { connectionId: 'cli-legacy-cap', name: 'laptop-legacy', projectName: 'kilo' }, + ]); + expect(instances[0]).not.toHaveProperty('capabilities'); + }); + it('persists `instance` in the WS attachment across heartbeats', () => { const { doInstance, mockCtx } = setup(); const cliWs = addCliSocket(mockCtx, 'cli-1'); diff --git a/services/session-ingest/src/dos/UserConnectionDO.ts b/services/session-ingest/src/dos/UserConnectionDO.ts index c18d39cd66..ab1c52306f 100644 --- a/services/session-ingest/src/dos/UserConnectionDO.ts +++ b/services/session-ingest/src/dos/UserConnectionDO.ts @@ -62,6 +62,10 @@ export type ConnectedInstanceRow = { name: string; projectName: string; version?: string; + // Latest capabilities from the CLI socket attachment. Omitted when the + // attachment has no capabilities (legacy CLI / pre-field build) so the + // response stays byte-identical for those clients. + capabilities?: ConnectionCapabilities; }; export const MAX_CATALOG_RESULT_BYTES = 512 * 1024; @@ -1007,6 +1011,7 @@ export class UserConnectionDO extends DurableObject { name: att.instance.name, projectName: att.instance.projectName, ...(att.instance.version ? { version: att.instance.version } : {}), + ...(att.capabilities ? { capabilities: att.capabilities } : {}), }); } return { instances };