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 };