From 5e7c1477857f3db81e13a35916348689512adce2 Mon Sep 17 00:00:00 2001 From: Howon Lee Date: Sun, 6 Sep 2026 05:16:13 +0900 Subject: [PATCH 01/94] feat: add Charm Hyper quota provider (#3368) --- packages/ui/src/lib/quota/providers/index.ts | 1 + packages/ui/src/types/quota.ts | 1 + packages/vscode/src/quotaProviders.test.ts | 85 ++++++++++++ packages/vscode/src/quotaProviders.ts | 111 +++++++++++++++ .../web/server/lib/quota/DOCUMENTATION.md | 1 + packages/web/server/lib/quota/index.js | 1 + .../web/server/lib/quota/providers/hyper.js | 118 ++++++++++++++++ .../server/lib/quota/providers/hyper.test.js | 128 ++++++++++++++++++ .../web/server/lib/quota/providers/index.js | 8 ++ 9 files changed, 454 insertions(+) create mode 100644 packages/web/server/lib/quota/providers/hyper.js create mode 100644 packages/web/server/lib/quota/providers/hyper.test.js diff --git a/packages/ui/src/lib/quota/providers/index.ts b/packages/ui/src/lib/quota/providers/index.ts index 749f5516..5616135d 100644 --- a/packages/ui/src/lib/quota/providers/index.ts +++ b/packages/ui/src/lib/quota/providers/index.ts @@ -24,6 +24,7 @@ export const QUOTA_PROVIDERS: QuotaProviderMeta[] = [ { id: 'crof', name: 'CrofAI' }, { id: 'deepseek', name: 'DeepSeek' }, { id: 'exe-dev', name: 'exe.dev' }, + { id: 'hyper', name: 'Charm Hyper' }, { id: 'neuralwatt', name: 'NeuralWatt' }, { id: 'xai', name: 'xAI' }, ]; diff --git a/packages/ui/src/types/quota.ts b/packages/ui/src/types/quota.ts index 1524ca51..aa55d52e 100644 --- a/packages/ui/src/types/quota.ts +++ b/packages/ui/src/types/quota.ts @@ -19,6 +19,7 @@ export type QuotaProviderId = | 'crof' | 'deepseek' | 'exe-dev' + | 'hyper' | 'neuralwatt' | 'xai'; diff --git a/packages/vscode/src/quotaProviders.test.ts b/packages/vscode/src/quotaProviders.test.ts index 62a8f014..ec9e13c2 100644 --- a/packages/vscode/src/quotaProviders.test.ts +++ b/packages/vscode/src/quotaProviders.test.ts @@ -19,6 +19,7 @@ const AUTH = JSON.stringify({ 'opencode-go': { key: 'test-token' }, 'zai-coding-plan': { key: 'test-token' }, deepseek: { key: 'test-token' }, + hyper: { key: 'test-token' }, 'github-copilot': { access: 'test-token' }, anthropic: { access: 'test-token', refresh: 'test-refresh' }, }); @@ -714,3 +715,87 @@ describe('DeepSeek quota provider (VS Code parity)', () => { fsMock.readFileSync = ORIGINAL_FS.readFileSync; }); }); + +describe('Charm Hyper quota provider (VS Code parity)', () => { + beforeEach(() => { + const fsMock = fs as unknown as { existsSync: () => boolean; readFileSync: () => string }; + fsMock.existsSync = () => true; + fsMock.readFileSync = () => AUTH; + }); + + test('builds credits and credits_balance windows from documented payload (numeric balance)', async () => { + stubFetchReturning(() => Promise.resolve(mockResponse({ balance: 100 }))); + + const result = await fetchQuotaForProvider('hyper'); + + assert.equal(result.ok, true); + assert.equal(result.providerId, 'hyper'); + + const balanceWindow = result.usage!.windows.credits_balance!; + assert.equal(balanceWindow.valueLabel, '$5.00'); + assert.equal(balanceWindow.usedPercent, null); + assert.equal(balanceWindow.windowSeconds, null); + assert.equal(balanceWindow.resetAt, null); + + const creditsWindow = result.usage!.windows.credits!; + assert.equal(creditsWindow.valueLabel, '100 credits'); + assert.equal(creditsWindow.usedPercent, null); + assert.equal(creditsWindow.windowSeconds, null); + assert.equal(creditsWindow.resetAt, null); + }); + + test('tolerates a string balance', async () => { + stubFetchReturning(() => Promise.resolve(mockResponse({ balance: '50' }))); + + const result = await fetchQuotaForProvider('hyper'); + + assert.equal(result.ok, true); + assert.equal(result.usage!.windows.credits!.valueLabel, '50 credits'); + assert.equal(result.usage!.windows.credits_balance!.valueLabel, '$2.50'); + }); + + test('maps 401 to session-expired', async () => { + stubFetchFailing(async () => ({}), { ok: false, status: 401 }); + + const result = await fetchQuotaForProvider('hyper'); + + assert.equal(result.ok, false); + assert.equal(result.error, 'Session expired — please re-authenticate with Charm Hyper'); + }); + + test('reports a normalized timeout error', async () => { + stubFetchReturning(() => Promise.reject(new DOMException('The operation timed out.', 'TimeoutError'))); + + const result = await fetchQuotaForProvider('hyper'); + + assert.equal(result.ok, false); + assert.equal(result.error, 'Request timed out'); + }); + + test('returns no-quota-data on a 200 payload with no balance', async () => { + stubFetchReturning(() => Promise.resolve(mockResponse({}))); + + const result = await fetchQuotaForProvider('hyper'); + + assert.equal(result.ok, false); + assert.equal(result.configured, true); + assert.equal(result.error, 'No quota data in response'); + assert.equal(result.usage, null); + }); + + test('keeps a literal zero balance as a valid valueLabel', async () => { + stubFetchReturning(() => Promise.resolve(mockResponse({ balance: 0 }))); + + const result = await fetchQuotaForProvider('hyper'); + + assert.equal(result.ok, true); + assert.equal(result.usage!.windows.credits!.valueLabel, '0 credits'); + assert.equal(result.usage!.windows.credits_balance!.valueLabel, '$0.00'); + }); + + test('teardown: restore fs', () => { + const fsMock = fs as unknown as { existsSync: unknown; readFileSync: unknown }; + fsMock.existsSync = ORIGINAL_FS.existsSync; + fsMock.readFileSync = ORIGINAL_FS.readFileSync; + }); +}); diff --git a/packages/vscode/src/quotaProviders.ts b/packages/vscode/src/quotaProviders.ts index 74be878f..b1518f2d 100644 --- a/packages/vscode/src/quotaProviders.ts +++ b/packages/vscode/src/quotaProviders.ts @@ -155,6 +155,10 @@ type DeepseekPayload = { }>; }; +type HyperPayload = { + balance?: number | string; +}; + type NeuralwattPayload = { balance?: { credits_remaining_usd?: number | string; @@ -853,6 +857,11 @@ export const listConfiguredQuotaProviders = () => { configured.add('deepseek'); } + const hyperAuth = normalizeAuthEntry(getAuthEntry(auth, ['hyper'])); + if (hyperAuth && ((hyperAuth as Record).key || (hyperAuth as Record).token)) { + configured.add('hyper'); + } + let xaiAuth: XaiAuthEntry | null = null; try { xaiAuth = resolveXaiAuth(); @@ -2786,6 +2795,106 @@ const fetchDeepseekQuota = async (): Promise => { } }; +const HYPER_QUOTA_URL = 'https://hyper.charm.land/v1/credits'; +const HYPER_CREDIT_TO_USD = 0.05; + +const fetchHyperQuota = async (): Promise => { + const auth = readAuthFile(); + const entry = normalizeAuthEntry(getAuthEntry(auth, ['hyper'])) as Record | null; + const apiKey = (entry?.key as string | undefined) ?? (entry?.token as string | undefined); + + if (!apiKey) { + return buildResult({ + providerId: 'hyper', + providerName: 'Charm Hyper', + ok: false, + configured: false, + error: 'Not configured', + }); + } + + const timeoutSignal = AbortSignal.timeout(15_000); + + try { + const response = await fetch(HYPER_QUOTA_URL, { + method: 'GET', + headers: { + Authorization: `Bearer ${apiKey}`, + 'Accept-Encoding': 'identity', + }, + signal: timeoutSignal, + }); + + if (!response.ok) { + return buildResult({ + providerId: 'hyper', + providerName: 'Charm Hyper', + ok: false, + configured: true, + error: response.status === 401 || response.status === 403 + ? 'Session expired — please re-authenticate with Charm Hyper' + : `API error: ${response.status}`, + }); + } + + const payload = await response.json() as HyperPayload; + const rawBalance = payload?.balance; + const balance = (typeof rawBalance === 'number' || (typeof rawBalance === 'string' && rawBalance.trim() !== '')) + ? toNumber(rawBalance) + : null; + + if (balance === null) { + return buildResult({ + providerId: 'hyper', + providerName: 'Charm Hyper', + ok: false, + configured: true, + error: 'No quota data in response', + }); + } + + const creditsLabel = Number.isInteger(balance) ? String(balance) : formatMoney(balance); + const windows: Record = { + credits_balance: toUsageWindow({ + usedPercent: null, + windowSeconds: null, + resetAt: null, + valueLabel: `$${formatMoney(balance * HYPER_CREDIT_TO_USD)}`, + }), + credits: toUsageWindow({ + usedPercent: null, + windowSeconds: null, + resetAt: null, + valueLabel: `${creditsLabel} credits`, + }), + }; + + return buildResult({ + providerId: 'hyper', + providerName: 'Charm Hyper', + ok: true, + configured: true, + usage: { windows }, + }); + } catch (error) { + const isTimeout = error instanceof DOMException && ( + error.name === 'TimeoutError' || (error.name === 'AbortError' && timeoutSignal.aborted) + ); + const isParseError = error instanceof SyntaxError; + return buildResult({ + providerId: 'hyper', + providerName: 'Charm Hyper', + ok: false, + configured: true, + error: isTimeout + ? 'Request timed out' + : isParseError + ? 'Invalid response from provider' + : (error instanceof Error ? error.message : 'Request failed'), + }); + } +}; + const fetchXaiQuota = async (): Promise => { try { const entry = resolveXaiAuth(); @@ -2907,6 +3016,8 @@ const fetchQuotaForProviderUncoalesced = async (providerId: string): Promise { + const auth = readAuthFile(); + const entry = normalizeAuthEntry(getAuthEntry(auth, aliases)); + return Boolean(entry?.key || entry?.token); +}; + +export const fetchQuota = async () => { + const auth = readAuthFile(); + const entry = normalizeAuthEntry(getAuthEntry(auth, aliases)); + const apiKey = entry?.key ?? entry?.token; + + if (!apiKey) { + return buildResult({ + providerId, + providerName, + ok: false, + configured: false, + error: 'Not configured' + }); + } + + const timeoutSignal = AbortSignal.timeout(15_000); + + try { + const response = await fetch(HYPER_QUOTA_URL, { + method: 'GET', + headers: { + Authorization: `Bearer ${apiKey}`, + 'Accept-Encoding': 'identity' + }, + signal: timeoutSignal + }); + + if (!response.ok) { + return buildResult({ + providerId, + providerName, + ok: false, + configured: true, + error: response.status === 401 || response.status === 403 + ? 'Session expired — please re-authenticate with Charm Hyper' + : `API error: ${response.status}` + }); + } + + const payload = await response.json(); + const rawBalance = payload?.balance; + const balance = (typeof rawBalance === 'number' || (typeof rawBalance === 'string' && rawBalance.trim() !== '')) + ? toNumber(rawBalance) + : null; + + if (balance === null) { + return buildResult({ + providerId, + providerName, + ok: false, + configured: true, + error: 'No quota data in response' + }); + } + + const creditsLabel = Number.isInteger(balance) ? String(balance) : formatMoney(balance); + const windows = { + credits_balance: toUsageWindow({ + usedPercent: null, + windowSeconds: null, + resetAt: null, + valueLabel: `$${formatMoney(balance * CREDIT_TO_USD)}` + }), + credits: toUsageWindow({ + usedPercent: null, + windowSeconds: null, + resetAt: null, + valueLabel: `${creditsLabel} credits` + }) + }; + + return buildResult({ + providerId, + providerName, + ok: true, + configured: true, + usage: { windows } + }); + } catch (error) { + const isTimeout = error instanceof DOMException && ( + error.name === 'TimeoutError' || (error.name === 'AbortError' && timeoutSignal.aborted) + ); + const isParseError = error instanceof SyntaxError; + return buildResult({ + providerId, + providerName, + ok: false, + configured: true, + error: isTimeout + ? 'Request timed out' + : isParseError + ? 'Invalid response from provider' + : (error instanceof Error ? error.message : 'Request failed') + }); + } +}; \ No newline at end of file diff --git a/packages/web/server/lib/quota/providers/hyper.test.js b/packages/web/server/lib/quota/providers/hyper.test.js new file mode 100644 index 00000000..9fd869f0 --- /dev/null +++ b/packages/web/server/lib/quota/providers/hyper.test.js @@ -0,0 +1,128 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../opencode/auth.js', () => ({ + readAuthFile: () => ({ hyper: { key: 'test-token' } }), +})); + +import { fetchQuota } from './hyper.js'; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +const mockResponse = (body, init = {}) => ({ + ok: true, + status: 200, + json: async () => body, + ...init, +}); + +// Documented payload shape from https://hyper.charm.land/docs/api/credits.html +// The balance is denominated in Hypercredits; 1 credit = $0.05. +describe('Charm Hyper quota provider', () => { + it('builds credits and credits_balance windows from documented payload (numeric balance)', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse({ balance: 100 }))); + + const result = await fetchQuota(); + + expect(result.ok).toBe(true); + expect(result.providerId).toBe('hyper'); + + const balanceWindow = result.usage.windows.credits_balance; + expect(balanceWindow).toBeDefined(); + expect(balanceWindow.valueLabel).toBe('$5.00'); + expect(balanceWindow.usedPercent).toBeNull(); + expect(balanceWindow.windowSeconds).toBeNull(); + expect(balanceWindow.resetAt).toBeNull(); + + const creditsWindow = result.usage.windows.credits; + expect(creditsWindow).toBeDefined(); + expect(creditsWindow.valueLabel).toBe('100 credits'); + expect(creditsWindow.usedPercent).toBeNull(); + expect(creditsWindow.windowSeconds).toBeNull(); + expect(creditsWindow.resetAt).toBeNull(); + }); + + it('tolerates a string balance', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse({ balance: '50' }))); + + const result = await fetchQuota(); + + expect(result.ok).toBe(true); + expect(result.usage.windows.credits.valueLabel).toBe('50 credits'); + expect(result.usage.windows.credits_balance.valueLabel).toBe('$2.50'); + }); + + it('formats a fractional balance in both windows', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse({ balance: 25.5 }))); + + const result = await fetchQuota(); + + expect(result.ok).toBe(true); + expect(result.usage.windows.credits.valueLabel).toBe('25.50 credits'); + expect(result.usage.windows.credits_balance.valueLabel).toBe('$1.28'); + }); + + it('maps 401 to session-expired error', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 401, json: async () => ({}) })); + + const result = await fetchQuota(); + + expect(result.ok).toBe(false); + expect(result.error).toBe('Session expired — please re-authenticate with Charm Hyper'); + }); + + it('maps 403 to session-expired error', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 403, json: async () => ({}) })); + + const result = await fetchQuota(); + + expect(result.ok).toBe(false); + expect(result.error).toBe('Session expired — please re-authenticate with Charm Hyper'); + }); + + it('reports invalid-response on JSON parse failure', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => { throw new SyntaxError('Unexpected token'); }, + })); + + const result = await fetchQuota(); + + expect(result.ok).toBe(false); + expect(result.error).toBe('Invalid response from provider'); + }); + + it('returns no-quota-data on a 200 payload with no balance', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse({}))); + + const result = await fetchQuota(); + + expect(result.ok).toBe(false); + expect(result.configured).toBe(true); + expect(result.error).toBe('No quota data in response'); + expect(result.usage).toBeNull(); + }); + + it('returns no-quota-data on an empty-string balance', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse({ balance: '' }))); + + const result = await fetchQuota(); + + expect(result.ok).toBe(false); + expect(result.configured).toBe(true); + expect(result.error).toBe('No quota data in response'); + expect(result.usage).toBeNull(); + }); + + it('keeps a literal zero balance as a valid valueLabel', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse({ balance: 0 }))); + + const result = await fetchQuota(); + + expect(result.ok).toBe(true); + expect(result.usage.windows.credits.valueLabel).toBe('0 credits'); + expect(result.usage.windows.credits_balance.valueLabel).toBe('$0.00'); + }); +}); \ No newline at end of file diff --git a/packages/web/server/lib/quota/providers/index.js b/packages/web/server/lib/quota/providers/index.js index b6d7be3d..c9ef0050 100644 --- a/packages/web/server/lib/quota/providers/index.js +++ b/packages/web/server/lib/quota/providers/index.js @@ -15,6 +15,7 @@ import * as cursor from './cursor.js'; import * as deepseek from './deepseek.js'; import * as exeDev from './exe-dev.js'; import * as google from './google/index.js'; +import * as hyper from './hyper.js'; import * as kimi from './kimi.js'; import * as nanogpt from './nanogpt.js'; import * as openai from './openai.js'; @@ -72,6 +73,12 @@ const registry = { isConfigured: google.isConfigured, fetchQuota: google.fetchGoogleQuota }, + hyper: { + providerId: hyper.providerId, + providerName: hyper.providerName, + isConfigured: hyper.isConfigured, + fetchQuota: hyper.fetchQuota + }, 'zai-coding-plan': { providerId: zai.providerId, providerName: zai.providerName, @@ -220,6 +227,7 @@ export const fetchGoogleQuota = google.fetchGoogleQuota; export const fetchCodexQuota = codex.fetchQuota; export const fetchCursorQuota = cursor.fetchQuota; export const fetchDeepseekQuota = deepseek.fetchQuota; +export const fetchHyperQuota = hyper.fetchQuota; export const fetchCopilotQuota = copilot.fetchQuota; export const fetchCopilotAddonQuota = copilot.fetchQuotaAddon; export const fetchKimiQuota = kimi.fetchQuota; From b0282b27207b80bf972c4b07e8efa36eece54f59 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sat, 5 Sep 2026 23:26:35 +0300 Subject: [PATCH 02/94] fix(quota): validate Hyper credentials and clean up balance labels Reject invalid credentials while preserving valid token fallback, parse balances with existing boundary helpers, and keep credit values free of untranslated unit text. Inject auth and HTTP dependencies for focused tests in both runtimes. Validated web quota and registry tests (35 passed), VS Code quota tests (70 passed), both package type checks and lint, extension build, and changed-line anti-slop checks. Reviewed dead-code output. Live Hyper validation was not run because no API key is available. --- packages/vscode/src/quotaProviders.test.ts | 192 ++++++++++------- packages/vscode/src/quotaProviders.ts | 36 ++-- .../web/server/lib/quota/DOCUMENTATION.md | 6 + .../web/server/lib/quota/providers/hyper.js | 32 +-- .../server/lib/quota/providers/hyper.test.js | 196 +++++++++--------- 5 files changed, 259 insertions(+), 203 deletions(-) diff --git a/packages/vscode/src/quotaProviders.test.ts b/packages/vscode/src/quotaProviders.test.ts index ec9e13c2..ba9cb1d8 100644 --- a/packages/vscode/src/quotaProviders.test.ts +++ b/packages/vscode/src/quotaProviders.test.ts @@ -26,7 +26,7 @@ const AUTH = JSON.stringify({ ((fs as unknown) as { existsSync: () => boolean }).existsSync = () => true; ((fs as unknown) as { readFileSync: () => string }).readFileSync = () => AUTH; -import { fetchQuotaForProvider } from './quotaProviders'; +import { fetchHyperQuota, fetchQuotaForProvider } from './quotaProviders'; type MockResponseInit = { ok?: boolean; status?: number }; @@ -85,6 +85,13 @@ const stubFetchFailing = (json: () => Promise, init: MockResponseInit): globalThis.fetch = (async () => ({ json, ...init }) as unknown as Response) as typeof fetch; }; +test('dispatches Charm Hyper through the generic quota API', async () => { + stubFetchReturning(async () => Response.json({ balance: 100 })); + const result = await fetchQuotaForProvider('hyper'); + assert.equal(result.ok, true); + assert.equal(result.usage?.windows.credits?.valueLabel, '100'); +}); + describe('OpenCode Go quota provider (VS Code parity)', () => { test('uses the opencode-go key from auth.json', async () => { let request: RequestInit | undefined; @@ -717,85 +724,128 @@ describe('DeepSeek quota provider (VS Code parity)', () => { }); describe('Charm Hyper quota provider (VS Code parity)', () => { - beforeEach(() => { - const fsMock = fs as unknown as { existsSync: () => boolean; readFileSync: () => string }; - fsMock.existsSync = () => true; - fsMock.readFileSync = () => AUTH; - }); + const readAuth = () => ({ hyper: { key: 'test-token' } }); - test('builds credits and credits_balance windows from documented payload (numeric balance)', async () => { - stubFetchReturning(() => Promise.resolve(mockResponse({ balance: 100 }))); + for (const { balance, credits, dollars } of [ + { balance: 100, credits: '100', dollars: '$5.00' }, + { balance: '50', credits: '50', dollars: '$2.50' }, + { balance: 25.5, credits: '25.50', dollars: '$1.28' }, + { balance: 0, credits: '0', dollars: '$0.00' }, + { balance: '0', credits: '0', dollars: '$0.00' }, + ]) { + test(`formats balance ${JSON.stringify(balance)} without an untranslated unit`, async () => { + const result = await fetchHyperQuota({ readAuth, fetchImpl: async () => Response.json({ balance }) }); + assert.equal(result.ok, true); + assert.equal(result.providerId, 'hyper'); + assert.equal(result.configured, true); + assert.ok(result.usage); + assert.equal(result.usage.windows.credits?.valueLabel, credits); + assert.equal(result.usage.windows.credits_balance?.valueLabel, dollars); + for (const window of Object.values(result.usage.windows)) { + assert.equal(window.usedPercent, null); + assert.equal(window.remainingPercent, null); + assert.equal(window.windowSeconds, null); + assert.equal(window.resetAt, null); + assert.equal(window.resetAfterSeconds, null); + } + }); + } - const result = await fetchQuotaForProvider('hyper'); + for (const payload of [ + {}, null, [], { balance: '' }, { balance: ' \t ' }, { balance: 'NaN' }, + { balance: 'Infinity' }, { balance: null }, { balance: true }, { balance: [] }, + { balance: {} }, + ]) { + test(`rejects invalid payload ${JSON.stringify(payload)} instead of showing zero`, async () => { + const result = await fetchHyperQuota({ readAuth, fetchImpl: async () => Response.json(payload) }); + assert.equal(result.ok, false); + assert.equal(result.configured, true); + assert.equal(result.error, 'No quota data in response'); + assert.equal(result.usage, null); + }); + } - assert.equal(result.ok, true); - assert.equal(result.providerId, 'hyper'); + for (const [index, auth] of [ + { hyper: { key: 'test-token' } }, + { hyper: { token: 'test-token' } }, + { hyper: 'test-token' }, + { hyper: { key: ' ', token: 'test-token' } }, + { hyper: { key: 42, token: 'test-token' } }, + ].entries()) { + test(`uses validated credential variant ${index} for the documented request`, async () => { + let requests = 0; + const result = await fetchHyperQuota({ + readAuth: () => auth, + fetchImpl: async (url, options) => { + requests += 1; + assert.equal(url, 'https://hyper.charm.land/v1/credits'); + assert.equal(options.method, 'GET'); + assert.equal(new Headers(options.headers).get('Authorization'), 'Bearer test-token'); + assert.ok(options.signal instanceof AbortSignal); + return Response.json({ balance: 100 }); + }, + }); + assert.equal(requests, 1); + assert.equal(result.ok, true); + assert.equal(JSON.stringify(result).includes('test-token'), false); + }); + } - const balanceWindow = result.usage!.windows.credits_balance!; - assert.equal(balanceWindow.valueLabel, '$5.00'); - assert.equal(balanceWindow.usedPercent, null); - assert.equal(balanceWindow.windowSeconds, null); - assert.equal(balanceWindow.resetAt, null); + for (const [index, readInvalidAuth] of [ + () => ({}), + () => ({ hyper: { key: '' } }), + () => ({ hyper: { key: ' ' } }), + () => ({ hyper: { key: 42 } }), + ].entries()) { + test(`does not request usage with missing or invalid credential variant ${index}`, async () => { + let requests = 0; + const result = await fetchHyperQuota({ + readAuth: readInvalidAuth, + fetchImpl: async () => { + requests += 1; + return Response.json({ balance: 100 }); + }, + }); + assert.equal(requests, 0); + assert.equal(result.ok, false); + assert.equal(result.configured, false); + assert.equal(result.error, 'Not configured'); + }); + } - const creditsWindow = result.usage!.windows.credits!; - assert.equal(creditsWindow.valueLabel, '100 credits'); - assert.equal(creditsWindow.usedPercent, null); - assert.equal(creditsWindow.windowSeconds, null); - assert.equal(creditsWindow.resetAt, null); - }); - - test('tolerates a string balance', async () => { - stubFetchReturning(() => Promise.resolve(mockResponse({ balance: '50' }))); - - const result = await fetchQuotaForProvider('hyper'); - - assert.equal(result.ok, true); - assert.equal(result.usage!.windows.credits!.valueLabel, '50 credits'); - assert.equal(result.usage!.windows.credits_balance!.valueLabel, '$2.50'); - }); - - test('maps 401 to session-expired', async () => { - stubFetchFailing(async () => ({}), { ok: false, status: 401 }); - - const result = await fetchQuotaForProvider('hyper'); - - assert.equal(result.ok, false); - assert.equal(result.error, 'Session expired — please re-authenticate with Charm Hyper'); - }); - - test('reports a normalized timeout error', async () => { - stubFetchReturning(() => Promise.reject(new DOMException('The operation timed out.', 'TimeoutError'))); - - const result = await fetchQuotaForProvider('hyper'); - - assert.equal(result.ok, false); - assert.equal(result.error, 'Request timed out'); - }); - - test('returns no-quota-data on a 200 payload with no balance', async () => { - stubFetchReturning(() => Promise.resolve(mockResponse({}))); - - const result = await fetchQuotaForProvider('hyper'); + for (const { status, error } of [ + { status: 401, error: 'Session expired — please re-authenticate with Charm Hyper' }, + { status: 403, error: 'Session expired — please re-authenticate with Charm Hyper' }, + { status: 429, error: 'API error: 429' }, + { status: 500, error: 'API error: 500' }, + ]) { + test(`reports HTTP ${status} as a failure`, async () => { + const result = await fetchHyperQuota({ readAuth, fetchImpl: async () => new Response(null, { status }) }); + assert.equal(result.ok, false); + assert.equal(result.configured, true); + assert.equal(result.error, error); + assert.equal(result.usage, null); + }); + } + test('reports invalid JSON as a parse failure', async () => { + const result = await fetchHyperQuota({ readAuth, fetchImpl: async () => new Response('{') }); + assert.equal(result.error, 'Invalid response from provider'); assert.equal(result.ok, false); assert.equal(result.configured, true); - assert.equal(result.error, 'No quota data in response'); assert.equal(result.usage, null); }); - test('keeps a literal zero balance as a valid valueLabel', async () => { - stubFetchReturning(() => Promise.resolve(mockResponse({ balance: 0 }))); - - const result = await fetchQuotaForProvider('hyper'); - - assert.equal(result.ok, true); - assert.equal(result.usage!.windows.credits!.valueLabel, '0 credits'); - assert.equal(result.usage!.windows.credits_balance!.valueLabel, '$0.00'); - }); - - test('teardown: restore fs', () => { - const fsMock = fs as unknown as { existsSync: unknown; readFileSync: unknown }; - fsMock.existsSync = ORIGINAL_FS.existsSync; - fsMock.readFileSync = ORIGINAL_FS.readFileSync; - }); + for (const { failure, message } of [ + { failure: new DOMException('Timed out', 'TimeoutError'), message: 'Request timed out' }, + { failure: new Error('Network unavailable'), message: 'Network unavailable' }, + ]) { + test(`reports ${message}`, async () => { + const result = await fetchHyperQuota({ readAuth, fetchImpl: async () => { throw failure; } }); + assert.equal(result.error, message); + assert.equal(result.ok, false); + assert.equal(result.configured, true); + assert.equal(result.usage, null); + }); + } }); diff --git a/packages/vscode/src/quotaProviders.ts b/packages/vscode/src/quotaProviders.ts index b1518f2d..8e282b6b 100644 --- a/packages/vscode/src/quotaProviders.ts +++ b/packages/vscode/src/quotaProviders.ts @@ -155,10 +155,6 @@ type DeepseekPayload = { }>; }; -type HyperPayload = { - balance?: number | string; -}; - type NeuralwattPayload = { balance?: { credits_remaining_usd?: number | string; @@ -857,8 +853,7 @@ export const listConfiguredQuotaProviders = () => { configured.add('deepseek'); } - const hyperAuth = normalizeAuthEntry(getAuthEntry(auth, ['hyper'])); - if (hyperAuth && ((hyperAuth as Record).key || (hyperAuth as Record).token)) { + if (getHyperApiKey(auth)) { configured.add('hyper'); } @@ -2798,10 +2793,18 @@ const fetchDeepseekQuota = async (): Promise => { const HYPER_QUOTA_URL = 'https://hyper.charm.land/v1/credits'; const HYPER_CREDIT_TO_USD = 0.05; -const fetchHyperQuota = async (): Promise => { - const auth = readAuthFile(); - const entry = normalizeAuthEntry(getAuthEntry(auth, ['hyper'])) as Record | null; - const apiKey = (entry?.key as string | undefined) ?? (entry?.token as string | undefined); +const getHyperApiKey = (auth: AuthFile) => { + const entry = normalizeAuthEntry(getAuthEntry(auth, ['hyper'])); + return asNonEmptyString(entry?.key) ?? asNonEmptyString(entry?.token); +}; + +type HyperQuotaDependencies = { + readAuth?: () => AuthFile; + fetchImpl?: (url: string, options: RequestInit) => Promise; +}; + +export const fetchHyperQuota = async ({ readAuth = readAuthFile, fetchImpl = fetch }: HyperQuotaDependencies = {}): Promise => { + const apiKey = getHyperApiKey(readAuth()); if (!apiKey) { return buildResult({ @@ -2816,7 +2819,7 @@ const fetchHyperQuota = async (): Promise => { const timeoutSignal = AbortSignal.timeout(15_000); try { - const response = await fetch(HYPER_QUOTA_URL, { + const response = await fetchImpl(HYPER_QUOTA_URL, { method: 'GET', headers: { Authorization: `Bearer ${apiKey}`, @@ -2837,11 +2840,10 @@ const fetchHyperQuota = async (): Promise => { }); } - const payload = await response.json() as HyperPayload; + const payload = asObject(await response.json()); const rawBalance = payload?.balance; - const balance = (typeof rawBalance === 'number' || (typeof rawBalance === 'string' && rawBalance.trim() !== '')) - ? toNumber(rawBalance) - : null; + const balance = toNumber(asNonEmptyString(rawBalance) + ?? (Number.isFinite(rawBalance) ? rawBalance : null)); if (balance === null) { return buildResult({ @@ -2854,7 +2856,7 @@ const fetchHyperQuota = async (): Promise => { } const creditsLabel = Number.isInteger(balance) ? String(balance) : formatMoney(balance); - const windows: Record = { + const windows = { credits_balance: toUsageWindow({ usedPercent: null, windowSeconds: null, @@ -2865,7 +2867,7 @@ const fetchHyperQuota = async (): Promise => { usedPercent: null, windowSeconds: null, resetAt: null, - valueLabel: `${creditsLabel} credits`, + valueLabel: creditsLabel, }), }; diff --git a/packages/web/server/lib/quota/DOCUMENTATION.md b/packages/web/server/lib/quota/DOCUMENTATION.md index 69bf5889..867d7132 100644 --- a/packages/web/server/lib/quota/DOCUMENTATION.md +++ b/packages/web/server/lib/quota/DOCUMENTATION.md @@ -91,6 +91,12 @@ In 2025/2026 MiniMax rebranded "Coding Plan" to "Token Plan" alongside the M3 mo - **model_remains array**: Now contains entries for multiple model categories (chat, speech, video, image). The provider selects the chat-model entry by matching `MiniMax-M*`, then `general`/`chat`/`text` by name, then any entry with a remaining percent. - **Window status**: The `current_interval_status` and `current_weekly_status` fields indicate whether a window is active. Status `3` means the window is not applicable for the current plan tier (e.g. legacy plans without weekly limits). The provider omits inactive windows. +## Charm Hyper balance semantics + +`GET https://hyper.charm.land/v1/credits` returns a team's current Hypercredit balance, not a percentage or reset timestamp. The [Hyper FAQ](https://hyper.charm.land/faq) defines one Hypercredit as $0.05. Both runtimes expose `credits_balance` in dollars and `credits` as a numeric label under the UI's localized window title. Keep English unit text out of that numeric label. + +Web and VS Code accept finite numeric balances and non-empty numeric strings. Missing, blank, or malformed balances remain explicit failures; zero is valid. Credential lookup uses a non-empty string `key`, then `token`, so malformed or blank keys cannot mark the provider configured or hide a valid fallback token. Hyper fetchers accept `readAuth` and `fetchImpl` dependencies for tests without replacing filesystem or auth modules. + ## Kimi for Coding field semantics `GET https://api.kimi.com/coding/v1/usages` is inconsistent about which field carries consumption: diff --git a/packages/web/server/lib/quota/providers/hyper.js b/packages/web/server/lib/quota/providers/hyper.js index 19170511..b86d6b2e 100644 --- a/packages/web/server/lib/quota/providers/hyper.js +++ b/packages/web/server/lib/quota/providers/hyper.js @@ -5,25 +5,26 @@ import { buildResult, toUsageWindow, toNumber, - formatMoney + formatMoney, + asObject, + asNonEmptyString } from '../utils/index.js'; export const providerId = 'hyper'; export const providerName = 'Charm Hyper'; -const aliases = ['hyper']; +export const aliases = ['hyper']; const HYPER_QUOTA_URL = 'https://hyper.charm.land/v1/credits'; const CREDIT_TO_USD = 0.05; -export const isConfigured = () => { - const auth = readAuthFile(); +const getApiKey = (auth) => { const entry = normalizeAuthEntry(getAuthEntry(auth, aliases)); - return Boolean(entry?.key || entry?.token); + return asNonEmptyString(entry?.key) ?? asNonEmptyString(entry?.token); }; -export const fetchQuota = async () => { - const auth = readAuthFile(); - const entry = normalizeAuthEntry(getAuthEntry(auth, aliases)); - const apiKey = entry?.key ?? entry?.token; +export const isConfigured = (auth = readAuthFile()) => Boolean(getApiKey(auth)); + +export const fetchQuota = async ({ readAuth = readAuthFile, fetchImpl = fetch } = {}) => { + const apiKey = getApiKey(readAuth()); if (!apiKey) { return buildResult({ @@ -38,7 +39,7 @@ export const fetchQuota = async () => { const timeoutSignal = AbortSignal.timeout(15_000); try { - const response = await fetch(HYPER_QUOTA_URL, { + const response = await fetchImpl(HYPER_QUOTA_URL, { method: 'GET', headers: { Authorization: `Bearer ${apiKey}`, @@ -59,11 +60,10 @@ export const fetchQuota = async () => { }); } - const payload = await response.json(); + const payload = asObject(await response.json()); const rawBalance = payload?.balance; - const balance = (typeof rawBalance === 'number' || (typeof rawBalance === 'string' && rawBalance.trim() !== '')) - ? toNumber(rawBalance) - : null; + const balance = toNumber(asNonEmptyString(rawBalance) + ?? (Number.isFinite(rawBalance) ? rawBalance : null)); if (balance === null) { return buildResult({ @@ -87,7 +87,7 @@ export const fetchQuota = async () => { usedPercent: null, windowSeconds: null, resetAt: null, - valueLabel: `${creditsLabel} credits` + valueLabel: creditsLabel }) }; @@ -115,4 +115,4 @@ export const fetchQuota = async () => { : (error instanceof Error ? error.message : 'Request failed') }); } -}; \ No newline at end of file +}; diff --git a/packages/web/server/lib/quota/providers/hyper.test.js b/packages/web/server/lib/quota/providers/hyper.test.js index 9fd869f0..ad143d66 100644 --- a/packages/web/server/lib/quota/providers/hyper.test.js +++ b/packages/web/server/lib/quota/providers/hyper.test.js @@ -1,128 +1,126 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { describe, expect, it } from 'vitest'; +import { fetchQuota, isConfigured } from './hyper.js'; -vi.mock('../../opencode/auth.js', () => ({ - readAuthFile: () => ({ hyper: { key: 'test-token' } }), -})); +const readAuth = () => ({ hyper: { key: 'test-token' } }); -import { fetchQuota } from './hyper.js'; - -afterEach(() => { - vi.unstubAllGlobals(); -}); - -const mockResponse = (body, init = {}) => ({ - ok: true, - status: 200, - json: async () => body, - ...init, -}); - -// Documented payload shape from https://hyper.charm.land/docs/api/credits.html -// The balance is denominated in Hypercredits; 1 credit = $0.05. +// https://hyper.charm.land/docs/api/credits.html documents the balance payload. +// https://hyper.charm.land/faq defines one Hypercredit as $0.05. describe('Charm Hyper quota provider', () => { - it('builds credits and credits_balance windows from documented payload (numeric balance)', async () => { - vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse({ balance: 100 }))); - - const result = await fetchQuota(); + it.each([ + [100, '100', '$5.00'], + ['50', '50', '$2.50'], + [25.5, '25.50', '$1.28'], + [0, '0', '$0.00'], + ['0', '0', '$0.00'], + ])('formats balance %s without an untranslated unit', async (balance, credits, dollars) => { + const result = await fetchQuota({ + readAuth, + fetchImpl: async () => Response.json({ balance }), + }); expect(result.ok).toBe(true); expect(result.providerId).toBe('hyper'); - - const balanceWindow = result.usage.windows.credits_balance; - expect(balanceWindow).toBeDefined(); - expect(balanceWindow.valueLabel).toBe('$5.00'); - expect(balanceWindow.usedPercent).toBeNull(); - expect(balanceWindow.windowSeconds).toBeNull(); - expect(balanceWindow.resetAt).toBeNull(); - - const creditsWindow = result.usage.windows.credits; - expect(creditsWindow).toBeDefined(); - expect(creditsWindow.valueLabel).toBe('100 credits'); - expect(creditsWindow.usedPercent).toBeNull(); - expect(creditsWindow.windowSeconds).toBeNull(); - expect(creditsWindow.resetAt).toBeNull(); + expect(result.configured).toBe(true); + expect(result.usage.windows.credits.valueLabel).toBe(credits); + expect(result.usage.windows.credits_balance.valueLabel).toBe(dollars); + for (const window of Object.values(result.usage.windows)) { + expect(window.usedPercent).toBeNull(); + expect(window.remainingPercent).toBeNull(); + expect(window.windowSeconds).toBeNull(); + expect(window.resetAt).toBeNull(); + expect(window.resetAfterSeconds).toBeNull(); + } }); - it('tolerates a string balance', async () => { - vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse({ balance: '50' }))); + it.each([ + {}, null, [], { balance: '' }, { balance: ' \t ' }, { balance: 'NaN' }, + { balance: 'Infinity' }, { balance: null }, { balance: true }, { balance: [] }, + { balance: {} }, + ])('rejects invalid payload %j instead of showing zero', async (payload) => { + const result = await fetchQuota({ readAuth, fetchImpl: async () => Response.json(payload) }); - const result = await fetchQuota(); + expect(result.ok).toBe(false); + expect(result.configured).toBe(true); + expect(result.error).toBe('No quota data in response'); + expect(result.usage).toBeNull(); + }); + it.each([ + { hyper: { key: 'test-token' } }, + { hyper: { token: 'test-token' } }, + { hyper: 'test-token' }, + { hyper: { key: ' ', token: 'test-token' } }, + { hyper: { key: 42, token: 'test-token' } }, + ])('uses a validated credential for the documented request', async (auth) => { + expect(isConfigured(auth)).toBe(true); + let requests = 0; + const result = await fetchQuota({ + readAuth: () => auth, + fetchImpl: async (url, options) => { + requests += 1; + expect(url).toBe('https://hyper.charm.land/v1/credits'); + expect(options.method).toBe('GET'); + expect(new Headers(options.headers).get('Authorization')).toBe('Bearer test-token'); + expect(options.signal).toBeInstanceOf(AbortSignal); + return Response.json({ balance: 100 }); + }, + }); + + expect(requests).toBe(1); expect(result.ok).toBe(true); - expect(result.usage.windows.credits.valueLabel).toBe('50 credits'); - expect(result.usage.windows.credits_balance.valueLabel).toBe('$2.50'); + expect(JSON.stringify(result)).not.toContain('test-token'); }); - it('formats a fractional balance in both windows', async () => { - vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse({ balance: 25.5 }))); + it.each([{}, { hyper: { key: '' } }, { hyper: { key: ' ' } }, { hyper: { key: 42 } }])( + 'does not request usage without a valid credential', + async (auth) => { + expect(isConfigured(auth)).toBe(false); + let requests = 0; + const result = await fetchQuota({ + readAuth: () => auth, + fetchImpl: async () => { + requests += 1; + return Response.json({ balance: 100 }); + }, + }); - const result = await fetchQuota(); + expect(requests).toBe(0); + expect(result.ok).toBe(false); + expect(result.configured).toBe(false); + expect(result.error).toBe('Not configured'); + }, + ); - expect(result.ok).toBe(true); - expect(result.usage.windows.credits.valueLabel).toBe('25.50 credits'); - expect(result.usage.windows.credits_balance.valueLabel).toBe('$1.28'); - }); - - it('maps 401 to session-expired error', async () => { - vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 401, json: async () => ({}) })); - - const result = await fetchQuota(); + it.each([ + [401, 'Session expired — please re-authenticate with Charm Hyper'], + [403, 'Session expired — please re-authenticate with Charm Hyper'], + [429, 'API error: 429'], + [500, 'API error: 500'], + ])('reports HTTP %s as a failure', async (status, error) => { + const result = await fetchQuota({ readAuth, fetchImpl: async () => new Response(null, { status }) }); expect(result.ok).toBe(false); - expect(result.error).toBe('Session expired — please re-authenticate with Charm Hyper'); + expect(result.configured).toBe(true); + expect(result.error).toBe(error); + expect(result.usage).toBeNull(); }); - it('maps 403 to session-expired error', async () => { - vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 403, json: async () => ({}) })); - - const result = await fetchQuota(); - - expect(result.ok).toBe(false); - expect(result.error).toBe('Session expired — please re-authenticate with Charm Hyper'); - }); - - it('reports invalid-response on JSON parse failure', async () => { - vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ - ok: true, - status: 200, - json: async () => { throw new SyntaxError('Unexpected token'); }, - })); - - const result = await fetchQuota(); - - expect(result.ok).toBe(false); + it('reports invalid JSON as a parse failure', async () => { + const result = await fetchQuota({ readAuth, fetchImpl: async () => new Response('{') }); expect(result.error).toBe('Invalid response from provider'); - }); - - it('returns no-quota-data on a 200 payload with no balance', async () => { - vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse({}))); - - const result = await fetchQuota(); - expect(result.ok).toBe(false); expect(result.configured).toBe(true); - expect(result.error).toBe('No quota data in response'); expect(result.usage).toBeNull(); }); - it('returns no-quota-data on an empty-string balance', async () => { - vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse({ balance: '' }))); - - const result = await fetchQuota(); - + it.each([ + [new DOMException('Timed out', 'TimeoutError'), 'Request timed out'], + [new Error('Network unavailable'), 'Network unavailable'], + ])('reports request failure', async (failure, message) => { + const result = await fetchQuota({ readAuth, fetchImpl: async () => { throw failure; } }); + expect(result.error).toBe(message); expect(result.ok).toBe(false); expect(result.configured).toBe(true); - expect(result.error).toBe('No quota data in response'); expect(result.usage).toBeNull(); }); - - it('keeps a literal zero balance as a valid valueLabel', async () => { - vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse({ balance: 0 }))); - - const result = await fetchQuota(); - - expect(result.ok).toBe(true); - expect(result.usage.windows.credits.valueLabel).toBe('0 credits'); - expect(result.usage.windows.credits_balance.valueLabel).toBe('$0.00'); - }); -}); \ No newline at end of file +}); From d8215ef5b3dd9571e86846432764bdae0bb0ee03 Mon Sep 17 00:00:00 2001 From: alvins82 Date: Mon, 7 Sep 2026 05:56:27 +1000 Subject: [PATCH 03/94] feat(work-status): add opt-in turn statistics (#3177) Add optional completed-turn statistics without changing the existing panel layout. Separate final text delivery speed from whole-turn throughput, preserve scope and opt-in settings, and explain each metric with localized delayed tooltips. Validated focused telemetry, lifecycle, sync and persistence tests, all-workspace type-check and lint, web builds, the 12-locale narrow layout, and full GitHub CI. --- .../chat/work-status/DOCUMENTATION.md | 57 +++- .../chat/work-status/WorkStatusPanel.tsx | 2 + .../work-status/WorkStatusSectionsDialog.tsx | 5 +- .../WorkStatusTelemetrySection.test.tsx | 234 ++++++++++++++ .../WorkStatusTelemetrySection.tsx | 185 +++++++++++ .../chat/work-status/sections.test.ts | 8 +- .../components/chat/work-status/sections.ts | 20 +- .../chat/work-status/telemetry.test.ts | 204 ++++++++++++ .../components/chat/work-status/telemetry.ts | 291 ++++++++++++++++++ packages/ui/src/lib/api/types.ts | 3 + packages/ui/src/lib/appearanceAutoSave.ts | 7 +- packages/ui/src/lib/desktop.ts | 2 + packages/ui/src/lib/i18n/messages.test.ts | 23 ++ packages/ui/src/lib/i18n/messages/de.ts | 20 ++ packages/ui/src/lib/i18n/messages/en.ts | 20 ++ packages/ui/src/lib/i18n/messages/es.ts | 20 ++ packages/ui/src/lib/i18n/messages/fr.ts | 20 ++ packages/ui/src/lib/i18n/messages/ja.ts | 20 ++ packages/ui/src/lib/i18n/messages/ko.ts | 20 ++ packages/ui/src/lib/i18n/messages/pl.ts | 20 ++ packages/ui/src/lib/i18n/messages/pt-BR.ts | 20 ++ packages/ui/src/lib/i18n/messages/tr.ts | 20 ++ packages/ui/src/lib/i18n/messages/uk.ts | 20 ++ packages/ui/src/lib/i18n/messages/zh-CN.ts | 20 ++ packages/ui/src/lib/i18n/messages/zh-TW.ts | 20 ++ packages/ui/src/lib/persistence.test.ts | 38 +++ packages/ui/src/lib/persistence.ts | 11 +- .../src/stores/useUIStore.telemetry.test.ts | 41 +++ packages/ui/src/stores/useUIStore.ts | 23 +- packages/ui/src/sync/DOCUMENTATION.md | 1 + packages/ui/src/sync/bootstrap.test.ts | 19 +- packages/ui/src/sync/bootstrap.ts | 2 +- packages/ui/src/sync/sync-context.tsx | 1 + packages/ui/src/sync/types.ts | 2 + .../server/lib/opencode/settings-helpers.js | 3 + .../lib/opencode/settings-helpers.test.js | 13 + 36 files changed, 1404 insertions(+), 31 deletions(-) create mode 100644 packages/ui/src/components/chat/work-status/WorkStatusTelemetrySection.test.tsx create mode 100644 packages/ui/src/components/chat/work-status/WorkStatusTelemetrySection.tsx create mode 100644 packages/ui/src/components/chat/work-status/telemetry.test.ts create mode 100644 packages/ui/src/components/chat/work-status/telemetry.ts create mode 100644 packages/ui/src/stores/useUIStore.telemetry.test.ts diff --git a/packages/ui/src/components/chat/work-status/DOCUMENTATION.md b/packages/ui/src/components/chat/work-status/DOCUMENTATION.md index 1c43a17f..bae6b2f2 100644 --- a/packages/ui/src/components/chat/work-status/DOCUMENTATION.md +++ b/packages/ui/src/components/chat/work-status/DOCUMENTATION.md @@ -17,7 +17,8 @@ conditionally; passing "am I first?" down would mean each one tracking what the sections above it decided to render. Sections render nothing when they have no rows, so the panel collapses upward -instead of reserving empty space. +instead of reserving empty space. Opt-in Turn stats keeps its header for a +selected session even without metrics, so a saved collapsed state can reopen. ## What it is not @@ -103,11 +104,49 @@ which requests only providers enabled for this panel. | Subagent blockers | directory `permission` / `question` maps | one subscription covers every child | | Usage | `components/usage/usageGroups.ts` over `useQuotaStore` | grouping shared with the mobile popover; presentation is not | | Linked threads | `lib/linkedIssues.ts` over session metadata | written by the flows that attach an issue or PR | +| Turn stats | `telemetry.ts` over `useSessionMessageRecords` | opt-in; computed only while expanded and authoritatively idle | | Goal | `useSessionGoal` | respects the Settings toggle | | MCP | `useMcpStore` | connect/disconnect reuses the dropdown's actions | | Pinned messages | `getContextObligatoryMessages` + `state.part` | see below | | Todos | live `state.todo[sessionId]`, persisted fallback | live channel wins | +### Turn stats + +The section follows Usage and reuses the panel's existing rows. Only its header +has an icon; metric rows use labels and values without leading icons. It +reads already-loaded records without fetching history. The newest turn needs a +preceding user message and completed assistant steps. A truncated or unfinished +turn has no whole-turn result; later materialization can supply it. + +Two rates answer different questions. Response speed uses the final assistant +message's output tokens divided by the union of its nonempty text intervals. +It excludes initial waiting, reasoning tokens and reasoning time, and earlier +tool steps. It requires complete, valid text timing and a final message without +tools, errors, synthetic text or ignored text. This measures text delivery from +stored timestamps, not provider-side decode speed. The header shows only this +rate; unavailable response timing never falls back to whole-turn speed. + +Whole-turn speed uses output plus reasoning tokens from every step, divided by +elapsed assistant time minus the union of completed and failed tool intervals. +Waiting for each model response remains included. Invalid or missing inputs +omit the dependent metric rather than becoming zero; reported zeros remain +valid. TTFT averages the earliest text/reasoning start delay from every step, +only when all steps have a valid sample. + +Metric labels stay short. Every row is a single hover and keyboard-focus target +for a shared tooltip, with a 750ms hover delay and a portal outside the panel's +scroller. Tooltips explain the measurement in every locale. The token row uses +compact input/output arrows; its tooltip gives full counts and explains that +input excludes cached tokens and output includes reasoning across all steps. + +Records subscriptions and aggregation stop while collapsed, busy, retrying, or +awaiting status authority. Explicit idle events or a successful directory status +snapshot allow computation. One component-owned committed result keeps the +headline and rows stable during the next active turn. Its identity includes +runtime, normalized directory and session. Scope changes discard it; fresh empty +or reverted records clear it. There is no global message-ID cache. Corrections +to existing message/part identities invalidate the current result. + ### Context usage has its own computation, on purpose `useSessionUIStore.getContextUsage` cannot serve this panel for two reasons: @@ -191,8 +230,9 @@ the row reflects the reset tree rather than a mid-creation snapshot. Ordering is by durability, not category: 1. **Session** (goal, context, cost), **Project** (attention, branch, - changes, PR, checks) and **Usage** — true for as long as the session is - open. Usage sits here rather than lower down because a spent quota stops the + changes, PR, checks), **Usage**, and **Turn stats** (opt-in session telemetry: + throughput, duration, TTFT, cache hit rate) — true for as long as the session + is open. Usage sits here rather than lower down because a spent quota stops the work outright; 2. **Subagents**, **Tasks** — what is happening right now; 3. **MCP**, **Pinned messages**, **Context sources** — supporting material. @@ -201,10 +241,13 @@ Ordering is by durability, not category: A persisted preference (`workStatusPanelEnabled`) drives a header toggle, and a dialog behind the equalizer icon switches individual sections off. Hidden -sections are stored rather than visible ones, so a section added later appears -for everyone instead of staying invisible to whoever had saved settings before -it existed. Both travel the full settings pipeline, including the server -whitelist without which the keys never reach `settings.json`. +sections are stored rather than visible ones. Telemetry is the opt-in exception: +UI-store v20 and legacy server-list hydration add it to the hidden set. A +`workStatusHiddenSectionsExplicit` marker records that a list was chosen in a +client with telemetry support. The marker and list travel together through +autosave, sanitization, and server settings, so an explicit empty list enables +everything while an old empty list does not enable telemetry. Complete settings +snapshots own this preference; unrelated partial save echoes leave it unchanged. `workStatusPanelVisible` is separate and transient: the switch can be on while layout still refuses the panel. The header and the git rail read it to drop the diff --git a/packages/ui/src/components/chat/work-status/WorkStatusPanel.tsx b/packages/ui/src/components/chat/work-status/WorkStatusPanel.tsx index af097a23..34ba4d98 100644 --- a/packages/ui/src/components/chat/work-status/WorkStatusPanel.tsx +++ b/packages/ui/src/components/chat/work-status/WorkStatusPanel.tsx @@ -8,6 +8,7 @@ import { WORK_STATUS_PANEL_WIDTH } from './useWorkStatusVisibility'; import { WorkStatusGoalRow } from './WorkStatusGoalRow'; import { WorkStatusPrimaryGroup } from './WorkStatusPrimaryGroup'; import { WorkStatusUsageSection } from './WorkStatusUsageSection'; +import { WorkStatusTelemetrySection } from './WorkStatusTelemetrySection'; import { WorkStatusSubagentsSection } from './WorkStatusSubagentsSection'; import { WorkStatusTasksSection } from './WorkStatusTasksSection'; import { WorkStatusMcpSection } from './WorkStatusMcpSection'; @@ -254,6 +255,7 @@ export const WorkStatusPanel: React.FC = ({ sessionId, directory, visible goalRow={} /> {sectionVisible('usage') ? : null} + {sectionVisible('telemetry') ? : null} {sectionVisible('subagents') ? : null} {sectionVisible('tasks') ? : null} {sectionVisible('mcp') ? : null} diff --git a/packages/ui/src/components/chat/work-status/WorkStatusSectionsDialog.tsx b/packages/ui/src/components/chat/work-status/WorkStatusSectionsDialog.tsx index 4bbc81db..09cbf0c3 100644 --- a/packages/ui/src/components/chat/work-status/WorkStatusSectionsDialog.tsx +++ b/packages/ui/src/components/chat/work-status/WorkStatusSectionsDialog.tsx @@ -20,9 +20,8 @@ import { /** * Which sections the work-status panel may show. * - * Everything is on by default and the choice is stored as the *hidden* set, so - * a section added in a later release appears for everyone rather than staying - * invisible to whoever had saved settings before it existed. + * Choices are stored as the hidden set. Telemetry is opt-in; Show all is an + * explicit choice to enable it along with the other sections. */ export const WorkStatusSectionsDialog: React.FC<{ open: boolean; diff --git a/packages/ui/src/components/chat/work-status/WorkStatusTelemetrySection.test.tsx b/packages/ui/src/components/chat/work-status/WorkStatusTelemetrySection.test.tsx new file mode 100644 index 00000000..ab97ecdb --- /dev/null +++ b/packages/ui/src/components/chat/work-status/WorkStatusTelemetrySection.test.tsx @@ -0,0 +1,234 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { Window } from 'happy-dom'; +import { createOpencodeClient, type AssistantMessage, type Session, type UserMessage } from '@opencode-ai/sdk/v2'; +import { useUIStore } from '@/stores/useUIStore'; +import { I18nProvider } from '@/lib/i18n'; +import { SyncProvider } from '@/sync/sync-context'; +import { getSyncChildStores } from '@/sync/sync-refs'; +import { getSyncPerformanceDiagnostics, resetSyncPerformanceDiagnostics, setSyncPerformanceDiagnosticsEnabled } from '@/sync/performance-diagnostics'; +let WorkStatusTelemetrySection: typeof import('./WorkStatusTelemetrySection').WorkStatusTelemetrySection; + +const directory = '/repo'; +const sessionId = 'session-1'; +const user: UserMessage = { id: 'user-1', sessionID: sessionId, role: 'user', time: { created: 1000 }, agent: 'build', model: { providerID: 'test', modelID: 'test' } }; +const session: Session = { id: sessionId, slug: 'test', projectID: 'project', directory, title: 'test', version: '1', time: { created: 0, updated: 1 } }; +let tokenReads = 0; +const assistant: AssistantMessage = { + id: 'assistant-final', sessionID: sessionId, role: 'assistant', parentID: user.id, + agent: 'build', mode: 'build', providerID: 'test', modelID: 'test', path: { cwd: directory, root: directory }, + time: { created: 2000, completed: 7000 }, cost: 0.01, + get tokens() { tokenReads += 1; return { input: 100, output: 20, reasoning: 10, cache: { read: 40, write: 0 } }; }, +}; + +const DOM_GLOBAL_NAMES = ['window', 'document', 'navigator', 'Node', 'Element', 'HTMLElement', 'HTMLIFrameElement', 'localStorage', 'getComputedStyle', 'ResizeObserver', 'requestAnimationFrame', 'cancelAnimationFrame', 'IS_REACT_ACT_ENVIRONMENT'] as const; +const installDom = () => { + const win = new Window({ url: 'http://localhost' }); + const previous = DOM_GLOBAL_NAMES.map((name) => [name, Object.getOwnPropertyDescriptor(globalThis, name)] as const); + const values = { window: win, document: win.document, navigator: win.navigator, Node: win.Node, Element: win.Element, + HTMLElement: win.HTMLElement, HTMLIFrameElement: win.HTMLIFrameElement, localStorage: win.localStorage, + getComputedStyle: win.getComputedStyle.bind(win), ResizeObserver: win.ResizeObserver, + requestAnimationFrame: win.requestAnimationFrame.bind(win), cancelAnimationFrame: win.cancelAnimationFrame.bind(win), IS_REACT_ACT_ENVIRONMENT: true }; + for (const name of DOM_GLOBAL_NAMES) Object.defineProperty(globalThis, name, { value: values[name], configurable: true, writable: true }); + const container = document.createElement('div'); + document.body.appendChild(container); + return { container, restore: () => { + for (const [name, descriptor] of previous) { + if (descriptor) Object.defineProperty(globalThis, name, descriptor); + else Reflect.deleteProperty(globalThis, name); + } + void win.happyDOM.close(); + } }; +}; + +describe('mounted turn telemetry with live sync stores', () => { + let root: Root; + let dom: ReturnType; + let messageRequests = 0; + // Keep bootstrap pending so each test controls real store publications. No + // hook/module replacements: subscription and materialization paths are real. + const sdk = createOpencodeClient({ baseUrl: 'http://telemetry.test', fetch: (request) => { + const url = new URL(request instanceof Request ? request.url : request.toString()); + if (/\/session\/[^/]+\/message$/.test(url.pathname)) messageRequests += 1; + return new Promise(() => undefined); + } }); + const render = async (visible = true, selectedDirectory = directory, selectedSession = sessionId) => { + await act(async () => root.render( + + {visible ? : null} + , + )); + }; + const store = (dir = directory) => { + const result = getSyncChildStores().getChild(dir); + if (!result) throw new Error('Expected mounted directory store'); + return result; + }; + + beforeEach(async () => { + dom = installDom(); + ({ WorkStatusTelemetrySection } = await import('./WorkStatusTelemetrySection')); + root = createRoot(dom.container); + tokenReads = 0; + messageRequests = 0; + setSyncPerformanceDiagnosticsEnabled(true); + useUIStore.setState({ workStatusExpandedSections: {}, workStatusHiddenSections: ['telemetry'], workStatusHiddenSectionsExplicit: false }); + await render(); + await act(async () => store().setState({ session: [session], message: { [sessionId]: [user, assistant] }, part: { [assistant.id]: [] }, session_status: {} })); + }); + + afterEach(async () => { + await act(async () => root.unmount()); + setSyncPerformanceDiagnosticsEnabled(false); + dom.restore(); + }); + + test('waits for authority, then shows actual token values even when idle is omitted from the snapshot', async () => { + expect(dom.container.textContent).toContain('Turn stats'); + expect(dom.container.textContent).not.toContain('Whole turn'); + await act(async () => store().setState({ sessionStatusReady: true })); + expect(dom.container.textContent).toContain('~6 tok/s'); + expect(dom.container.textContent).toContain('100 ↑ · 30 ↓'); + const heading = dom.container.querySelector('button'); + if (!heading) throw new Error('Expected section heading'); + expect(heading.textContent).toBe('Turn stats'); + expect(heading.querySelectorAll('svg').length).toBe(2); + expect(dom.container.querySelectorAll('svg').length).toBe(2); + expect(tokenReads > 0).toBe(true); + expect(messageRequests).toBe(0); + }); + + test('collapsed remount keeps a usable header and reopening reads fresh data', async () => { + await act(async () => store().setState({ session_status: { [sessionId]: { type: 'idle' } } })); + const button = dom.container.querySelector('button'); + if (!button) throw new Error('Expected collapse button'); + await act(async () => button.click()); + await render(false); + await render(); + expect(dom.container.querySelector('button')?.getAttribute('aria-expanded')).toBe('false'); + expect(dom.container.textContent).toContain('Turn stats'); + expect(dom.container.textContent).not.toContain('Whole turn'); + const reopen = dom.container.querySelector('button'); + if (!reopen) throw new Error('Expected reopen button'); + await act(async () => reopen.click()); + expect(dom.container.textContent).toContain('~6 tok/s'); + }); + + test('busy, retry and collapsed updates do not notify records subscribers or aggregate tokens', async () => { + await act(async () => store().setState({ session_status: { [sessionId]: { type: 'idle' } } })); + // Positive control: on idle, part replacement reaches the subscriber and calculator. + tokenReads = 0; + resetSyncPerformanceDiagnostics(); + await act(async () => store().setState({ part: { [assistant.id]: [] } })); + expect(tokenReads > 0).toBe(true); + expect((getSyncPerformanceDiagnostics()?.sessionMessageChangeCallbacks ?? 0) > 0).toBe(true); + + for (const mode of ['busy', 'retry', 'collapsed'] as const) { + await act(async () => { + store().setState({ session_status: { [sessionId]: mode === 'retry' + ? { type: 'retry', attempt: 1, message: 'retry', next: 0 } + : { type: mode === 'busy' ? 'busy' : 'idle' } } }); + useUIStore.getState().setWorkStatusSectionExpanded('telemetry', mode !== 'collapsed'); + }); + resetSyncPerformanceDiagnostics(); + tokenReads = 0; + for (let i = 0; i < 100; i += 1) { + await act(async () => store().setState({ part: { [assistant.id]: [{ id: 'text', sessionID: sessionId, + messageID: assistant.id, type: 'text', text: String(i), time: { start: 2500 } }] } })); + } + expect(tokenReads).toBe(0); + expect(getSyncPerformanceDiagnostics()?.sessionMessageChangeCallbacks).toBe(0); + if (mode === 'collapsed') expect(dom.container.textContent).toBe('Turn stats'); + else expect(dom.container.textContent).toContain('~6 tok/s'); + } + }); + + test('session and directory changes cannot retain another scope, including equal IDs', async () => { + await act(async () => store().setState({ session_status: { [sessionId]: { type: 'idle' } } })); + expect(dom.container.textContent).toContain('~6 tok/s'); + await render(true, directory, 'another-session'); + expect(dom.container.textContent).not.toContain('~6 tok/s'); + await render(true, '/another-repo'); + await act(async () => store('/another-repo').setState({ session_status: { [sessionId]: { type: 'busy' } } })); + expect(dom.container.textContent).not.toContain('~6 tok/s'); + await render(true, directory); + expect(dom.container.textContent).toContain('~6 tok/s'); + }); + + test('same-ID corrections, partial history and reverts replace rather than cache stale stats', async () => { + await act(async () => store().setState({ session_status: { [sessionId]: { type: 'idle' } }, message: { [sessionId]: [assistant] } })); + expect(dom.container.textContent).not.toContain('Whole turn'); + await act(async () => store().setState({ message: { [sessionId]: [user, assistant] } })); + expect(dom.container.textContent).toContain('~6 tok/s'); + const corrected = { ...assistant, tokens: { ...assistant.tokens, output: 90 } }; + await act(async () => store().setState({ message: { [sessionId]: [user, corrected] } })); + expect(dom.container.textContent).toContain('~20 tok/s'); + await act(async () => store().setState({ session: [{ ...session, revert: { messageID: user.id } }] })); + expect(dom.container.textContent).not.toContain('~20 tok/s'); + await act(async () => store().setState({ session: [session] })); + expect(dom.container.textContent).toContain('~20 tok/s'); + await act(async () => store().setState({ message: {} })); + expect(dom.container.textContent).not.toContain('~20 tok/s'); + }); + + test('runtime identity changes discard retained results even with equal directory and session IDs', async () => { + await act(async () => store().setState({ session_status: { [sessionId]: { type: 'idle' } } })); + await act(async () => store().setState({ session_status: { [sessionId]: { type: 'busy' } } })); + expect(dom.container.textContent).toContain('~6 tok/s'); + Object.defineProperty(window, '__OPENCHAMBER_API_BASE_URL__', { value: 'https://second-runtime.test', configurable: true }); + await render(); + expect(dom.container.textContent).not.toContain('~6 tok/s'); + }); + + test('the heading shows response speed only, never whole-turn speed as a fallback', async () => { + await act(async () => store().setState({ sessionStatusReady: true, part: { [assistant.id]: [ + { id: 'text', type: 'text', sessionID: sessionId, messageID: assistant.id, text: 'Final reply', time: { start: 3000, end: 5000 } }, + ] } })); + expect(dom.container.querySelector('button')?.textContent).toBe('Turn stats~10 tok/s'); + expect(dom.container.textContent).toContain('Response~10 tok/s'); + expect(dom.container.textContent).toContain('Whole turn~6 tok/s'); + await act(async () => store().setState({ part: { [assistant.id]: [] } })); + expect(dom.container.querySelector('button')?.textContent).toBe('Turn stats'); + expect(dom.container.textContent).not.toContain('Response'); + expect(dom.container.textContent).toContain('Whole turn~6 tok/s'); + }); + + test('every metric has a full-row focus target and hover waits 750ms', async () => { + const earlier = { ...assistant, id: 'earlier', time: { created: 1100, completed: 1900 } }; + await act(async () => store().setState({ sessionStatusReady: true, + message: { [sessionId]: [user, earlier, assistant] }, part: { + earlier: [{ id: 'earlier-text', type: 'text', sessionID: sessionId, messageID: earlier.id, text: 'Earlier', time: { start: 1200, end: 1800 } }], + [assistant.id]: [{ id: 'text', type: 'text', sessionID: sessionId, messageID: assistant.id, text: 'Final reply', time: { start: 3000, end: 5000 } }], + }, + })); + const triggers = dom.container.querySelectorAll('[data-slot="tooltip-trigger"]'); + expect(triggers.length).toBe(9); + for (const trigger of triggers) expect(trigger.tabIndex).toBe(0); + expect(dom.container.querySelectorAll('[title]').length).toBe(0); + const response = triggers[0]; + await act(async () => { + response.dispatchEvent(new window.PointerEvent('pointerover', { bubbles: true, pointerType: 'mouse' })); + response.dispatchEvent(new window.MouseEvent('mouseover', { bubbles: true })); + response.dispatchEvent(new window.MouseEvent('mouseenter', { bubbles: true })); + response.dispatchEvent(new window.MouseEvent('mousemove', { bubbles: true })); + await new Promise((resolve) => setTimeout(resolve, 650)); + }); + expect(document.querySelector('[data-slot="tooltip-content"]')).toBeNull(); + expect(response.hasAttribute('data-popup-open')).toBe(false); + await act(async () => { await new Promise((resolve) => setTimeout(resolve, 150)); }); + expect(response.hasAttribute('data-popup-open')).toBe(true); + expect(document.querySelector('[data-slot="tooltip-content"]')?.textContent).toContain('How fast the final text arrived'); + expect(dom.container.querySelector('[data-slot="tooltip-content"]')).toBeNull(); + }); + + test('keyboard focus exposes the cost explanation without adding an icon or native title', async () => { + await act(async () => store().setState({ sessionStatusReady: true })); + const triggers = dom.container.querySelectorAll('[data-slot="tooltip-trigger"]'); + const cost = triggers[triggers.length - 1]; + await act(async () => cost.focus()); + expect(document.querySelector('[data-slot="tooltip-content"]')?.textContent).toContain('Cost reported by the provider'); + expect(cost.querySelector('svg')).toBeNull(); + expect(cost.hasAttribute('title')).toBe(false); + }); +}); diff --git a/packages/ui/src/components/chat/work-status/WorkStatusTelemetrySection.tsx b/packages/ui/src/components/chat/work-status/WorkStatusTelemetrySection.tsx new file mode 100644 index 00000000..6ec24dfe --- /dev/null +++ b/packages/ui/src/components/chat/work-status/WorkStatusTelemetrySection.tsx @@ -0,0 +1,185 @@ +import React from 'react'; +import { getCurrentIntlLocale, useI18n } from '@/lib/i18n'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; +import { useDirectorySync, useSessionMessageRecords, useSyncDirectory, useSyncRuntime } from '@/sync/sync-context'; +import { normalizePath } from '@/lib/pathNormalization'; +import { useUIStore } from '@/stores/useUIStore'; +import { + WorkStatusCollapsibleSection, + WorkStatusRow, + WorkStatusValue, +} from './WorkStatusPrimitives'; +import { useReportWorkStatusPresence } from './presenceContext'; +import { + formatTelemetryDuration, + formatTelemetryTokens, + formatThroughputRate, + getLatestCompletedTurnStats, + type CompletedTurnStats, +} from './telemetry'; + +type Props = { + sessionId: string | null; + directory: string | null; +}; + +/** One hover/focus target covers both the label and its value. */ +const TelemetryRow: React.FC<{ label: string; description: string; value: React.ReactNode }> = ({ label, description, value }) => ( + + +
+ +
+
+ +

{label}

+

{description}

+
+
+); + +export const WorkStatusTelemetrySection: React.FC = ({ sessionId, directory }) => { + const { t } = useI18n(); + const expanded = useUIStore( + React.useCallback((state) => state.workStatusExpandedSections['telemetry'] ?? true, []), + ); + const { runtimeKey } = useSyncRuntime(); + const syncDirectory = useSyncDirectory(); + const scope = JSON.stringify([runtimeKey, normalizePath(directory ?? syncDirectory), sessionId]); + const status = useDirectorySync( + React.useCallback((state) => sessionId + ? state.session_status[sessionId]?.type ?? (state.sessionStatusReady ? 'idle' : 'unknown') + : 'unknown', [sessionId]), + directory ?? undefined, + ); + const eligibleForStats = Boolean(sessionId && expanded && status === 'idle'); + + const records = useSessionMessageRecords( + sessionId ?? '', + directory ?? undefined, + { enabled: eligibleForStats }, + ); + + const computed = React.useMemo(() => { + if (!eligibleForStats) return null; + return getLatestCompletedTurnStats(records); + }, [eligibleForStats, records]); + + // Retain only one committed result, never message history or a global ID cache. + const [retained, setRetained] = React.useState<{ scope: string; stats: CompletedTurnStats | null } | null>(null); + React.useEffect(() => { + if (eligibleForStats) { + setRetained({ scope, stats: computed }); + } else { + setRetained((previous) => previous?.scope === scope && status !== 'unknown' ? previous : null); + } + }, [scope, status, eligibleForStats, computed]); + const stats = eligibleForStats ? computed : status !== 'unknown' && retained?.scope === scope ? retained.stats : null; + const summary = stats && stats.responseTokensPerSecond !== null + ? formatThroughputRate(stats.responseTokensPerSecond) + : undefined; + + useReportWorkStatusPresence('telemetry', Boolean(sessionId)); + + if (!sessionId) return null; + + return ( + + {stats ? ( + <> + {stats.responseTokensPerSecond !== null ? ( + {formatThroughputRate(stats.responseTokensPerSecond)}} + /> + ) : null} + {stats.tokensPerSecond !== null ? ( + {formatThroughputRate(stats.tokensPerSecond)}} + /> + ) : null} + + {stats.totalLlmDurationMs !== null ? ( + {formatTelemetryDuration(stats.totalLlmDurationMs)}} + /> + ) : null} + + {stats.totalToolDurationMs !== null ? ( + {formatTelemetryDuration(stats.totalToolDurationMs)}} + /> + ) : null} + + {stats.avgTtftMs !== null ? ( + {formatTelemetryDuration(stats.avgTtftMs)}} + /> + ) : null} + + {stats.stepsCount > 1 ? ( + {stats.stepsCount}} + /> + ) : null} + + {stats.inputTokens !== null && stats.outputTokens !== null && stats.reasoningTokens !== null && stats.totalGeneratedTokens !== null ? ( + + {t('chat.workStatus.telemetry.tokens.inOut', { + input: formatTelemetryTokens(stats.inputTokens), + output: formatTelemetryTokens(stats.totalGeneratedTokens), + })} + + )} + /> + ) : null} + + {stats.cacheHitPercent !== null ? ( + = 50 ? 'success' : 'default'}> + {`${stats.cacheHitPercent}%`} + + )} + /> + ) : null} + + {stats.cost !== null ? ( + {`$${stats.cost.toFixed(3).replace(/0+$/, '').replace(/\.$/, '')}`}} + /> + ) : null} + + ) : null} + + ); +}; diff --git a/packages/ui/src/components/chat/work-status/sections.test.ts b/packages/ui/src/components/chat/work-status/sections.test.ts index 9998e46e..3273257f 100644 --- a/packages/ui/src/components/chat/work-status/sections.test.ts +++ b/packages/ui/src/components/chat/work-status/sections.test.ts @@ -111,9 +111,9 @@ describe('sanitizeWorkStatusHiddenSections', () => { expect(sanitizeWorkStatusHiddenSections(['usage', 'usage'])).toEqual(['usage']); }); - test('treats a non-array payload as no preference', () => { - expect(sanitizeWorkStatusHiddenSections(undefined)).toEqual([]); - expect(sanitizeWorkStatusHiddenSections('usage')).toEqual([]); - expect(sanitizeWorkStatusHiddenSections({ usage: true })).toEqual([]); + test('treats a non-array payload as default hidden preference', () => { + expect(sanitizeWorkStatusHiddenSections(undefined)).toEqual(['telemetry']); + expect(sanitizeWorkStatusHiddenSections('usage')).toEqual(['telemetry']); + expect(sanitizeWorkStatusHiddenSections({ usage: true })).toEqual(['telemetry']); }); }); diff --git a/packages/ui/src/components/chat/work-status/sections.ts b/packages/ui/src/components/chat/work-status/sections.ts index f6ac8d76..41601d5f 100644 --- a/packages/ui/src/components/chat/work-status/sections.ts +++ b/packages/ui/src/components/chat/work-status/sections.ts @@ -14,6 +14,7 @@ export const WORK_STATUS_SECTION_IDS = [ 'session', 'repository', 'usage', + 'telemetry', 'subagents', 'tasks', 'mcp', @@ -23,16 +24,17 @@ export const WORK_STATUS_SECTION_IDS = [ type WorkStatusSectionId = (typeof WORK_STATUS_SECTION_IDS)[number]; -export const WORK_STATUS_SECTION_LABEL_KEYS: Record = { +export const WORK_STATUS_SECTION_LABEL_KEYS = { session: 'chat.workStatus.section.session', repository: 'chat.workStatus.section.project', usage: 'chat.workStatus.section.usage', + telemetry: 'chat.workStatus.section.telemetry', subagents: 'chat.workStatus.section.subagents', tasks: 'chat.workStatus.section.tasks', mcp: 'chat.workStatus.section.mcp', pinned: 'chat.workStatus.section.pinned', contextSources: 'chat.workStatus.section.contextBreakdown', -}; +} as const satisfies Record; const KNOWN_IDS = new Set(WORK_STATUS_SECTION_IDS); @@ -40,9 +42,8 @@ const isWorkStatusSectionId = (value: unknown): value is WorkStatusSectionId => typeof value === 'string' && KNOWN_IDS.has(value); /** - * Hidden sections are stored, not visible ones: everything is on by default, so - * an empty list means "the user has changed nothing" and a section added later - * appears without touching anyone's saved settings. + * Hidden sections are stored, not visible ones. Telemetry is opt-in; legacy + * lists must be normalized before use so adding it does not enable it. */ export const isWorkStatusSectionVisible = ( hidden: readonly string[] | null | undefined, @@ -75,11 +76,16 @@ export const getWorkStatusPanelPresentation = ({ showEmptyState: contentMounted && allSectionsHidden, }); -export const sanitizeWorkStatusHiddenSections = (value: unknown): WorkStatusSectionId[] => { - if (!Array.isArray(value)) return []; +const WORK_STATUS_DEFAULT_HIDDEN_SECTIONS = [ + 'telemetry', +] as const satisfies readonly WorkStatusSectionId[]; + +export const sanitizeWorkStatusHiddenSections = (value: unknown, explicit = true): WorkStatusSectionId[] => { + if (!Array.isArray(value)) return [...WORK_STATUS_DEFAULT_HIDDEN_SECTIONS]; const seen = new Set(); for (const entry of value) { if (isWorkStatusSectionId(entry)) seen.add(entry); } + if (!explicit) seen.add('telemetry'); return [...seen]; }; diff --git a/packages/ui/src/components/chat/work-status/telemetry.test.ts b/packages/ui/src/components/chat/work-status/telemetry.test.ts new file mode 100644 index 00000000..577c5a94 --- /dev/null +++ b/packages/ui/src/components/chat/work-status/telemetry.test.ts @@ -0,0 +1,204 @@ +import { describe, expect, test } from 'bun:test'; +import type { AssistantMessage, Part, TextPart, UserMessage } from '@opencode-ai/sdk/v2'; +import { formatTelemetryDuration, formatTelemetryTokens, formatThroughputRate, getLatestCompletedTurnStats, mergeTimeIntervals, sumIntervalsDuration } from './telemetry'; + +const user: UserMessage = { id: 'u1', sessionID: 'session-1', role: 'user', time: { created: 0 }, agent: 'build', model: { providerID: 'test', modelID: 'test' } }; +const assistant = (overrides: Partial = {}): AssistantMessage => ({ + id: 'a1', sessionID: 'session-1', role: 'assistant', parentID: user.id, + agent: 'build', mode: 'build', providerID: 'test', modelID: 'test', path: { cwd: '/repo', root: '/repo' }, + time: { created: 1000, completed: 5000 }, cost: 0, + tokens: { input: 100, output: 100, reasoning: 0, cache: { read: 0, write: 0 } }, + ...overrides, +}); +const tool = (start: number, end: number): Part => ({ + id: `tool-${start}`, sessionID: user.sessionID, messageID: 'a1', type: 'tool', tool: 'bash', callID: 'call', + state: { status: 'completed', input: {}, output: '', title: 'test', metadata: {}, time: { start, end } }, +}); +const text = (start: number): TextPart => ({ id: `text-${start}`, sessionID: user.sessionID, messageID: 'a1', type: 'text', text: '', time: { start } }); +const turn = (info = assistant(), parts: Part[] = []) => [{ info: user, parts: [] }, { info, parts }]; + +describe('turn telemetry', () => { + test('merges unsorted parallel, nested, adjoining and invalid tool intervals', () => { + expect(mergeTimeIntervals([])).toEqual([]); + const merged = mergeTimeIntervals([[3000, 4000], [1000, 3000], [1500, 2500], [6000, 7000], [NaN, 1], [9, 8]]); + expect(merged).toEqual([[1000, 4000], [6000, 7000]]); + expect(sumIntervalsDuration(merged)).toBe(4000); + }); + + test('formats durations, counts and approximate throughput', () => { + expect(formatTelemetryDuration(0)).toBe('0.0s'); + expect(formatTelemetryDuration(1234)).toBe('1.2s'); + expect(formatTelemetryDuration(84000)).toBe('1m24s'); + expect(formatTelemetryTokens(0)).toBe('0'); + expect(formatTelemetryTokens(500)).toBe('500'); + expect(formatTelemetryTokens(1234)).toBe('1.2K'); + expect(formatTelemetryTokens(1500000)).toBe('1.5M'); + expect(formatThroughputRate(52.3)).toBe('~52 tok/s'); + }); + + test('aggregates a multi-step turn, subtracting the tool union and including reasoning tokens', () => { + const records = turn(assistant({ + time: { created: 10000, completed: 20000 }, cost: 0.01, + tokens: { input: 1000, output: 200, reasoning: 300, cache: { read: 2000, write: 0 } }, + }), [text(11500), tool(13000, 15000), tool(14000, 16000)]); + records.push({ info: assistant({ id: 'a2', time: { created: 21000, completed: 24000 }, cost: 0.005, + tokens: { input: 1500, output: 100, reasoning: 0, cache: { read: 0, write: 0 } }, + }), parts: [text(21500)] }); + const stats = getLatestCompletedTurnStats(records); + expect(stats).toEqual({ stepsCount: 2, lastAssistantMessageId: 'a2', totalToolDurationMs: 3000, + totalLlmDurationMs: 10000, outputTokens: 300, reasoningTokens: 300, totalGeneratedTokens: 600, + inputTokens: 2500, cost: 0.015, tokensPerSecond: 60, responseTokensPerSecond: null, avgTtftMs: 1000, cacheHitPercent: 44 }); + }); + + test('uses only the latest user-bounded turn', () => { + const records = [...turn(), ...turn(assistant({ id: 'new' }))]; + expect(getLatestCompletedTurnStats(records)?.stepsCount).toBe(1); + expect(getLatestCompletedTurnStats(records)?.lastAssistantMessageId).toBe('new'); + }); + + test('does not publish unfinished or truncated turns, or substitute older results', () => { + expect(getLatestCompletedTurnStats(null)).toBeNull(); + expect(getLatestCompletedTurnStats([])).toBeNull(); + expect(getLatestCompletedTurnStats([{ info: assistant(), parts: [] }])).toBeNull(); + expect(getLatestCompletedTurnStats([...turn(), { info: user, parts: [] }])).toBeNull(); + expect(getLatestCompletedTurnStats([...turn(), ...turn(assistant({ time: { created: 1000 } }))])).toBeNull(); + expect(getLatestCompletedTurnStats([ + ...turn(assistant({ time: { created: 1000 } })), { info: assistant({ id: 'a2' }), parts: [] }, + ])).toBeNull(); + }); + + test('recomputes after history materializes and after same-ID message or part corrections', () => { + const info = assistant(); + expect(getLatestCompletedTurnStats([{ info, parts: [] }])).toBeNull(); + expect(getLatestCompletedTurnStats(turn(info))?.tokensPerSecond).toBe(25); + expect(getLatestCompletedTurnStats(turn({ ...info, tokens: { ...info.tokens, output: 200 } }))?.tokensPerSecond).toBe(50); + expect(getLatestCompletedTurnStats(turn(info, [tool(2000, 4000)]))?.tokensPerSecond).toBe(50); + // A second directory/runtime may reuse IDs but must never reuse the result. + expect(getLatestCompletedTurnStats(turn(info))?.tokensPerSecond).toBe(25); + }); + + test('missing usage in one step invalidates whole-turn usage, not valid durations', () => { + const missing = assistant({ id: 'a2', time: { created: 5000, completed: 6000 } }); + Reflect.deleteProperty(missing, 'tokens'); + Reflect.deleteProperty(missing, 'cost'); + const stats = getLatestCompletedTurnStats([...turn(), { info: missing, parts: [] }]); + expect(stats?.stepsCount).toBe(2); + expect(stats?.totalLlmDurationMs).toBe(5000); + expect(stats?.tokensPerSecond).toBeNull(); + expect(stats?.inputTokens).toBeNull(); + expect(stats?.cost).toBeNull(); + }); + + test('missing reasoning is not treated as zero and invalid token counts are not summed', () => { + const info = assistant(); + Reflect.deleteProperty(info.tokens, 'reasoning'); + expect(getLatestCompletedTurnStats(turn(info))?.tokensPerSecond).toBeNull(); + for (const output of [-1, NaN, Infinity]) { + expect(getLatestCompletedTurnStats(turn(assistant({ tokens: { ...assistant().tokens, output } })))?.totalGeneratedTokens).toBeNull(); + } + }); + + test('preserves genuine zero usage, cache hits and cost', () => { + const stats = getLatestCompletedTurnStats(turn(assistant({ tokens: { ...assistant().tokens, output: 0 } }))); + expect(stats?.tokensPerSecond).toBe(0); + expect(stats?.cost).toBe(0); + expect(stats?.cacheHitPercent).toBe(0); + }); + + test('includes failed tools and chooses the earliest text or reasoning timestamp', () => { + const failed: Part = { id: 'failed', sessionID: user.sessionID, messageID: 'a1', type: 'tool', tool: 'bash', callID: 'failed', + state: { status: 'error', input: {}, error: 'failed', time: { start: 2500, end: 4000 } } }; + const reasoning: Part = { id: 'reasoning', sessionID: user.sessionID, messageID: 'a1', type: 'reasoning', text: '', time: { start: 1200 } }; + const stats = getLatestCompletedTurnStats(turn(assistant(), [text(1600), reasoning, tool(2000, 3000), failed])); + expect(stats?.totalToolDurationMs).toBe(2000); + expect(stats?.totalLlmDurationMs).toBe(2000); + expect(stats?.avgTtftMs).toBe(200); + }); + + for (const [start, end] of [[0, 2000], [2000, 6000], [3000, 2000], [NaN, 3000]]) { + test(`invalid tool interval ${start}..${end} omits duration-dependent metrics`, () => { + const stats = getLatestCompletedTurnStats(turn(assistant(), [tool(start, end)])); + expect(stats?.totalToolDurationMs).toBeNull(); + expect(stats?.totalLlmDurationMs).toBeNull(); + expect(stats?.tokensPerSecond).toBeNull(); + expect(stats?.outputTokens).toBe(100); + }); + } + + test('unfinished tools and missing tool timing cannot produce a rate', () => { + const unfinished: Part = { id: 'pending', sessionID: user.sessionID, messageID: 'a1', type: 'tool', tool: 'bash', callID: 'pending', + state: { status: 'pending', input: {}, raw: '' } }; + const missing = tool(2000, 3000); + if (missing.type !== 'tool') throw new Error('Expected tool fixture'); + Reflect.deleteProperty(missing.state, 'time'); + expect(getLatestCompletedTurnStats(turn(assistant(), [unfinished]))?.tokensPerSecond).toBeNull(); + expect(getLatestCompletedTurnStats(turn(assistant(), [missing]))?.tokensPerSecond).toBeNull(); + }); + + test('invalid step time does not silently remove that step from totals', () => { + const stats = getLatestCompletedTurnStats([...turn(), { info: assistant({ id: 'a2', time: { created: 6000, completed: 5000 } }), parts: [] }]); + expect(stats?.stepsCount).toBe(2); + expect(stats?.totalGeneratedTokens).toBe(200); + expect(stats?.totalLlmDurationMs).toBeNull(); + expect(stats?.tokensPerSecond).toBeNull(); + }); + + test('separates final text delivery from whole-turn throughput on the measured tool-heavy shape', () => { + const records = turn(assistant({ + time: { created: 1000, completed: 38438 }, + tokens: { ...assistant().tokens, output: 223 }, + }), [tool(19950, 38438)]); + records.push({ info: assistant({ id: 'final', time: { created: 40000, completed: 45598 }, + tokens: { ...assistant().tokens, output: 338 }, + }), parts: [{ ...text(42661), text: 'Final answer', time: { start: 42661, end: 45442 } }] }); + const stats = getLatestCompletedTurnStats(records); + expect(Math.round(stats?.tokensPerSecond ?? 0)).toBe(23); + expect(Math.round(stats?.responseTokensPerSecond ?? 0)).toBe(122); + }); + + test('measures the final text only, excluding reasoning tokens and their time', () => { + const stats = getLatestCompletedTurnStats(turn(assistant({ tokens: { ...assistant().tokens, output: 260, reasoning: 100 } }), [ + { id: 'reasoning', sessionID: user.sessionID, messageID: 'a1', type: 'reasoning', text: 'Thinking', time: { start: 1200, end: 2000 } }, + { ...text(2500), text: 'Final answer', time: { start: 2500, end: 4500 } }, + ])); + expect(stats?.responseTokensPerSecond).toBe(130); + expect(stats?.tokensPerSecond).toBe(90); + }); + + test('unions overlapping text intervals without mutating the authoritative parts', () => { + const parts = [ + { ...text(2000), text: 'First', time: { start: 2000, end: 3500 } }, + { ...text(3000), text: 'Second', time: { start: 3000, end: 4000 } }, + ]; + const stats = getLatestCompletedTurnStats(turn(assistant(), parts)); + expect(stats?.responseTokensPerSecond).toBe(50); + expect(parts[0].time.end).toBe(3500); + }); + + test('missing, partial or invalid response timing never falls back to whole-turn speed', () => { + const invalidParts: Part[][] = [ + [], [{ ...text(2000), text: 'No end' }], + [{ ...text(2000), text: 'Bad end', time: { start: 2000, end: 1000 } }], + [{ ...text(2000), text: 'Late end', time: { start: 2000, end: 6000 } }], + [{ ...text(2000), text: 'Zero span', time: { start: 2000, end: 2000 } }], + [{ ...text(2000), text: 'Bad time', time: { start: NaN, end: 4000 } }], + [{ ...text(2000), text: 'Synthetic', synthetic: true, time: { start: 2000, end: 4000 } }], + [{ ...text(2000), text: 'Tool preface', time: { start: 2000, end: 3000 } }, tool(3000, 4000)], + [{ ...text(2000), text: 'Timed', time: { start: 2000, end: 3000 } }, { ...text(3000), text: 'Untimed' }], + ]; + for (const parts of invalidParts) { + const stats = getLatestCompletedTurnStats(turn(assistant(), parts)); + expect(stats?.responseTokensPerSecond).toBeNull(); + expect(stats?.tokensPerSecond !== null).toBe(true); + } + }); + + test('response speed needs valid output usage and a successful final reply', () => { + const parts = [{ ...text(2000), text: 'Final reply', time: { start: 2000, end: 4000 } }]; + const missingUsage = assistant(); + Reflect.deleteProperty(missingUsage.tokens, 'output'); + expect(getLatestCompletedTurnStats(turn(missingUsage, parts))?.responseTokensPerSecond).toBeNull(); + expect(getLatestCompletedTurnStats(turn(assistant({ error: { name: 'MessageAbortedError', data: { message: 'Stopped' } } }), parts))?.responseTokensPerSecond).toBeNull(); + expect(getLatestCompletedTurnStats(turn(assistant({ time: { created: NaN, completed: 5000 } }), parts))?.responseTokensPerSecond).toBeNull(); + }); +}); diff --git a/packages/ui/src/components/chat/work-status/telemetry.ts b/packages/ui/src/components/chat/work-status/telemetry.ts new file mode 100644 index 00000000..0fc94088 --- /dev/null +++ b/packages/ui/src/components/chat/work-status/telemetry.ts @@ -0,0 +1,291 @@ +import type { Message, Part } from '@opencode-ai/sdk/v2'; +import { computeCacheHitRate } from '@/stores/utils/tokenUtils'; + +type SessionMessageRecord = { + info: Message; + parts: Part[]; +}; + +type CompletedStepStats = { + toolDurationMs: number | null; + adjustedLlmDurationMs: number | null; + ttftMs: number | null; + inputTokens: number | null; + outputTokens: number | null; + reasoningTokens: number | null; + cacheReadTokens: number | null; + cacheWriteTokens: number | null; + cost: number | null; +}; + +export type CompletedTurnStats = { + lastAssistantMessageId: string; + stepsCount: number; + totalLlmDurationMs: number | null; + totalToolDurationMs: number | null; + avgTtftMs: number | null; + tokensPerSecond: number | null; + responseTokensPerSecond: number | null; + inputTokens: number | null; + outputTokens: number | null; + reasoningTokens: number | null; + totalGeneratedTokens: number | null; + cacheHitPercent: number | null; + cost: number | null; +}; + +/** + * Merge an array of [start, end] time intervals into a disjoint union of intervals. + * Correctly accounts for parallel / overlapping tool executions without double-counting. + */ +export function mergeTimeIntervals(intervals: readonly (readonly [number, number])[]): Array<[number, number]> { + if (intervals.length === 0) return []; + + const valid: Array<[number, number]> = []; + for (const [start, end] of intervals) { + if (Number.isFinite(start) && Number.isFinite(end) && end >= start) { + valid.push([start, end]); + } + } + + valid.sort((a, b) => a[0] - b[0]); + if (valid.length === 0) return []; + + const merged: Array<[number, number]> = [valid[0]]; + + for (let i = 1; i < valid.length; i += 1) { + const current = valid[i]; + const last = merged[merged.length - 1]; + + if (current[0] <= last[1]) { + last[1] = Math.max(last[1], current[1]); + } else { + merged.push(current); + } + } + + return merged; +} + +/** + * Sum the total duration spanned by an array of disjoint intervals. + */ +export function sumIntervalsDuration(intervals: readonly (readonly [number, number])[]): number { + return intervals.reduce((sum, [start, end]) => sum + (end - start), 0); +} + +export const formatTelemetryDuration = (ms: number): string => { + if (!Number.isFinite(ms) || ms <= 0) { + return '0.0s'; + } + if (ms < 60_000) { + return `${(ms / 1000).toFixed(1)}s`; + } + const minutes = Math.floor(ms / 60_000); + const seconds = Math.floor((ms % 60_000) / 1000); + return `${minutes}m${seconds}s`; +}; + +export const formatTelemetryTokens = (tokens: number): string => { + if (!Number.isFinite(tokens) || tokens <= 0) { + return '0'; + } + if (tokens >= 1_000_000) { + return `${(tokens / 1_000_000).toFixed(1)}M`; + } + if (tokens >= 1_000) { + return `${(tokens / 1_000).toFixed(1)}K`; + } + return String(Math.round(tokens)); +}; + +export const formatThroughputRate = (tps: number): string => { + return `~${Math.round(tps)} tok/s`; +}; + +const nonnegative = (value: number | undefined): number | null => + value !== undefined && Number.isFinite(value) && value >= 0 ? value : null; + +const add = (left: number | null, right: number | null): number | null => + left === null || right === null ? null : nonnegative(left + right); + +/** Text delivery rate for the final reply, not throughput of the agent loop. */ +function calculateResponseTokenRate(record: SessionMessageRecord): number | null { + const { info, parts } = record; + if (info.role !== 'assistant' || info.error || parts.some((part) => part.type === 'tool')) return null; + const output = nonnegative(info.tokens?.output); + const { created, completed } = info.time; + if (output === null || completed === undefined || nonnegative(created) === null || nonnegative(completed) === null) return null; + + const intervals: Array<[number, number]> = []; + for (const part of parts) { + if (part.type !== 'text') continue; + // Synthetic/ignored text cannot be matched to the provider's output count. + if (part.synthetic || part.ignored) return null; + if (!part.text) continue; + const start = part.time?.start; + const end = part.time?.end; + if (start === undefined || end === undefined || !Number.isFinite(start) || !Number.isFinite(end) + || start < created || end > completed || end <= start) return null; + intervals.push([start, end]); + } + const duration = sumIntervalsDuration(mergeTimeIntervals(intervals)); + return duration > 0 ? nonnegative(output / (duration / 1000)) : null; +} + +/** + * Calculate stats for a single completed assistant step. + */ +function calculateCompletedStepStats(record: SessionMessageRecord): CompletedStepStats | null { + const { info, parts } = record; + if (info.role !== 'assistant') return null; + + const { created } = info.time; + const completed = info.time.completed; + + if (completed === undefined) return null; + + const validWindow = nonnegative(created) !== null && nonnegative(completed) !== null && completed >= created; + const totalDurationMs = validWindow ? nonnegative(completed - created) : null; + + // An unfinished or invalid tool makes duration-dependent metrics unknown. + const rawToolIntervals: Array<[number, number]> = []; + let validTools = validWindow; + for (const part of parts) { + if (part.type !== 'tool') continue; + if (part.state.status !== 'completed' && part.state.status !== 'error') { + validTools = false; + continue; + } + const start = part.state.time?.start; + const end = part.state.time?.end; + if (!Number.isFinite(start) || !Number.isFinite(end) || start < created || end > completed || end < start) { + validTools = false; + continue; + } + rawToolIntervals.push([start, end]); + } + + const toolDurationMs = validTools ? nonnegative(sumIntervalsDuration(mergeTimeIntervals(rawToolIntervals))) : null; + const adjustedLlmDurationMs = totalDurationMs !== null && toolDurationMs !== null + ? nonnegative(totalDurationMs - toolDurationMs) + : null; + + // Measure TTFT from first text or reasoning part start timestamp + let ttftMs: number | null = null; + for (const part of parts) { + if (part.type === 'text' || part.type === 'reasoning') { + const partStart = part.time?.start; + if (validWindow && partStart !== undefined && Number.isFinite(partStart) && partStart >= created && partStart <= completed) { + const delta = partStart - created; + ttftMs = ttftMs === null ? delta : Math.min(ttftMs, delta); + } + } + } + + const inputTokens = nonnegative(info.tokens?.input); + const outputTokens = nonnegative(info.tokens?.output); + const reasoningTokens = nonnegative(info.tokens?.reasoning); + const cacheReadTokens = nonnegative(info.tokens?.cache?.read); + const cacheWriteTokens = nonnegative(info.tokens?.cache?.write); + const cost = nonnegative(info.cost); + + return { + toolDurationMs, + adjustedLlmDurationMs, + ttftMs, + inputTokens, + outputTokens, + reasoningTokens, + cacheReadTokens, + cacheWriteTokens, + cost, + }; +} + +/** + * Calculates telemetry metrics for the latest completed turn in the session. + * A turn encompasses all assistant steps since the preceding user message up to the final completed assistant step. + */ +export function getLatestCompletedTurnStats( + records: readonly SessionMessageRecord[] | null | undefined, +): CompletedTurnStats | null { + if (!records || records.length === 0) return null; + + // Only the newest user-bounded turn qualifies. A partial newer turn must not + // be published as complete or silently replaced with an older turn's stats. + const lastCompletedAssistantIdx = records.length - 1; + if (records[lastCompletedAssistantIdx].info.role !== 'assistant') return null; + let turnStartIdx = -1; + for (let i = records.length - 1; i >= 0; i -= 1) { + const record = records[i]; + if (record.info.role === 'user') { + turnStartIdx = i + 1; + break; + } + } + + if (turnStartIdx === -1) return null; + + const stepStatsList: CompletedStepStats[] = []; + for (let i = turnStartIdx; i <= lastCompletedAssistantIdx; i += 1) { + const record = records[i]; + if (record.info.role === 'assistant') { + const stepStats = calculateCompletedStepStats(record); + if (!stepStats) return null; + stepStatsList.push(stepStats); + } + } + + if (stepStatsList.length === 0) return null; + + let totalLlmDurationMs: number | null = 0; + let totalToolDurationMs: number | null = 0; + let totalInputTokens: number | null = 0; + let totalOutputTokens: number | null = 0; + let totalReasoningTokens: number | null = 0; + let totalCacheReadTokens: number | null = 0; + let totalCacheWriteTokens: number | null = 0; + let totalCost: number | null = 0; + let totalTtft: number | null = 0; + + for (const step of stepStatsList) { + totalLlmDurationMs = add(totalLlmDurationMs, step.adjustedLlmDurationMs); + totalToolDurationMs = add(totalToolDurationMs, step.toolDurationMs); + totalInputTokens = add(totalInputTokens, step.inputTokens); + totalOutputTokens = add(totalOutputTokens, step.outputTokens); + totalReasoningTokens = add(totalReasoningTokens, step.reasoningTokens); + totalCacheReadTokens = add(totalCacheReadTokens, step.cacheReadTokens); + totalCacheWriteTokens = add(totalCacheWriteTokens, step.cacheWriteTokens); + totalCost = add(totalCost, step.cost); + totalTtft = add(totalTtft, step.ttftMs); + } + + const avgTtftMs = totalTtft === null ? null : totalTtft / stepStatsList.length; + + const totalGeneratedTokens = add(totalOutputTokens, totalReasoningTokens); + const tokensPerSecond = totalGeneratedTokens !== null && totalLlmDurationMs !== null && totalLlmDurationMs > 0 + ? nonnegative(totalGeneratedTokens / (totalLlmDurationMs / 1000)) + : null; + + const cacheHit = totalInputTokens !== null && totalCacheReadTokens !== null && totalCacheWriteTokens !== null ? computeCacheHitRate({ + input: totalInputTokens, + cache: { read: totalCacheReadTokens, write: totalCacheWriteTokens }, + }) : null; + + return { + lastAssistantMessageId: records[lastCompletedAssistantIdx].info.id, + stepsCount: stepStatsList.length, + totalLlmDurationMs, + totalToolDurationMs, + avgTtftMs, + tokensPerSecond, + responseTokensPerSecond: calculateResponseTokenRate(records[lastCompletedAssistantIdx]), + inputTokens: totalInputTokens, + outputTokens: totalOutputTokens, + reasoningTokens: totalReasoningTokens, + totalGeneratedTokens, + cacheHitPercent: cacheHit?.hasInput ? Math.round(cacheHit.percent) : null, + cost: totalCost, + }; +} diff --git a/packages/ui/src/lib/api/types.ts b/packages/ui/src/lib/api/types.ts index c94ded88..016ceb58 100644 --- a/packages/ui/src/lib/api/types.ts +++ b/packages/ui/src/lib/api/types.ts @@ -698,6 +698,9 @@ export interface ProjectEntry { } export interface SettingsPayload { + workStatusPanelEnabled?: boolean; + workStatusHiddenSections?: string[]; + workStatusHiddenSectionsExplicit?: boolean; themeId?: string; useSystemTheme?: boolean; themeVariant?: 'light' | 'dark'; diff --git a/packages/ui/src/lib/appearanceAutoSave.ts b/packages/ui/src/lib/appearanceAutoSave.ts index e2cfb83f..4418f328 100644 --- a/packages/ui/src/lib/appearanceAutoSave.ts +++ b/packages/ui/src/lib/appearanceAutoSave.ts @@ -10,6 +10,7 @@ type AppearanceSlice = { streamingAutoFollowEnabled: boolean; workStatusPanelEnabled: boolean; workStatusHiddenSections: string[]; + workStatusHiddenSectionsExplicit: boolean; sessionRecapEnabled: boolean; sessionSuggestionEnabled: boolean; sessionGoalEnabled: boolean; @@ -67,6 +68,7 @@ export const startAppearanceAutoSave = (): void => { streamingAutoFollowEnabled: useUIStore.getState().streamingAutoFollowEnabled, workStatusPanelEnabled: useUIStore.getState().workStatusPanelEnabled, workStatusHiddenSections: useUIStore.getState().workStatusHiddenSections, + workStatusHiddenSectionsExplicit: useUIStore.getState().workStatusHiddenSectionsExplicit, sessionRecapEnabled: useUIStore.getState().sessionRecapEnabled, sessionSuggestionEnabled: useUIStore.getState().sessionSuggestionEnabled, sessionGoalEnabled: useUIStore.getState().sessionGoalEnabled, @@ -111,6 +113,7 @@ export const startAppearanceAutoSave = (): void => { streamingAutoFollowEnabled: state.streamingAutoFollowEnabled, workStatusPanelEnabled: state.workStatusPanelEnabled, workStatusHiddenSections: state.workStatusHiddenSections, + workStatusHiddenSectionsExplicit: state.workStatusHiddenSectionsExplicit, sessionRecapEnabled: state.sessionRecapEnabled, sessionSuggestionEnabled: state.sessionSuggestionEnabled, sessionGoalEnabled: state.sessionGoalEnabled, @@ -156,8 +159,10 @@ export const startAppearanceAutoSave = (): void => { } // Compared by content: the store hands back a new array on every change, // so an identity check would push a write on unrelated store updates. - if (current.workStatusHiddenSections.join('\u0000') !== previous.workStatusHiddenSections.join('\u0000')) { + if (current.workStatusHiddenSections.join('\u0000') !== previous.workStatusHiddenSections.join('\u0000') + || current.workStatusHiddenSectionsExplicit !== previous.workStatusHiddenSectionsExplicit) { diff.workStatusHiddenSections = current.workStatusHiddenSections; + diff.workStatusHiddenSectionsExplicit = current.workStatusHiddenSectionsExplicit; } if (current.showReasoningTraces !== previous.showReasoningTraces) { diff.showReasoningTraces = current.showReasoningTraces; diff --git a/packages/ui/src/lib/desktop.ts b/packages/ui/src/lib/desktop.ts index 9e157773..bbf81739 100644 --- a/packages/ui/src/lib/desktop.ts +++ b/packages/ui/src/lib/desktop.ts @@ -79,6 +79,8 @@ export type DesktopSettings = { workStatusPanelEnabled?: boolean; /** Work-status panel sections the user switched off. */ workStatusHiddenSections?: string[]; + /** True when the hidden-section list was explicitly chosen by the user. */ + workStatusHiddenSectionsExplicit?: boolean; collapsibleThinkingBlocks?: boolean; showDeletionDialog?: boolean; nativeNotificationsEnabled?: boolean; diff --git a/packages/ui/src/lib/i18n/messages.test.ts b/packages/ui/src/lib/i18n/messages.test.ts index c51992fd..2c43672f 100644 --- a/packages/ui/src/lib/i18n/messages.test.ts +++ b/packages/ui/src/lib/i18n/messages.test.ts @@ -44,4 +44,27 @@ describe('i18n dictionaries', () => { expect(dictionary['common.language.japanese']).toBeTruthy(); } }); + + test('telemetry translations retain the numeric token placeholders', () => { + for (const dictionary of Object.values(localeDictionaries)) { + expect(dictionary['chat.workStatus.telemetry.tokens.inOut']).toContain('{input}'); + expect(dictionary['chat.workStatus.telemetry.tokens.inOut']).toContain('{output}'); + for (const parameter of ['input', 'output', 'reasoning']) { + expect(dictionary['chat.workStatus.telemetry.tokensDescription']).toContain(`{${parameter}}`); + } + } + }); + + test('all telemetry rows have translated explanations and compact labels', () => { + const metrics = ['responseSpeed', 'speed', 'llmDuration', 'toolDuration', 'ttft', 'steps', 'tokens', 'cacheHit', 'cost'] as const; + for (const [locale, dictionary] of Object.entries(localeDictionaries)) { + for (const metric of metrics) { + const label = dictionary[`chat.workStatus.telemetry.${metric}`]; + const description = dictionary[`chat.workStatus.telemetry.${metric}Description`]; + expect(label.length <= 16).toBe(true); + expect(description.length > 30).toBe(true); + if (locale !== 'en') expect(description === enDict[`chat.workStatus.telemetry.${metric}Description`]).toBe(false); + } + } + }); }); diff --git a/packages/ui/src/lib/i18n/messages/de.ts b/packages/ui/src/lib/i18n/messages/de.ts index 2f010d06..b208adc3 100644 --- a/packages/ui/src/lib/i18n/messages/de.ts +++ b/packages/ui/src/lib/i18n/messages/de.ts @@ -3245,6 +3245,26 @@ export const dict = { 'chat.workStatus.action.openPr': 'Pull Request öffnen', 'chat.workStatus.action.openSubagent': '{name} öffnen', 'chat.workStatus.section.usage': 'Nutzung', + 'chat.workStatus.section.telemetry': 'Turn-Statistiken', + 'chat.workStatus.telemetry.responseSpeed': 'Antwort', + 'chat.workStatus.telemetry.responseSpeedDescription': 'Wie schnell der abschließende Text ankam. Ohne anfängliche Wartezeit, Denken und frühere Werkzeugaufrufe. Eine Schätzung aus Textzeitstempeln, keine Geschwindigkeitsmessung des Anbieters.', + 'chat.workStatus.telemetry.speed': 'Anfrage', + 'chat.workStatus.telemetry.llmDuration': 'Modellzeit', + 'chat.workStatus.telemetry.llmDurationDescription': 'Zeit aller Modellschritte einschließlich Warten auf Antworten. Die Werkzeuglaufzeit ist abgezogen. Das ist nicht nur die Zeit zur Texterzeugung.', + 'chat.workStatus.telemetry.toolDuration': 'Werkzeugzeit', + 'chat.workStatus.telemetry.toolDurationDescription': 'Laufzeit der Werkzeuge einschließlich fehlgeschlagener Aufrufe. Parallel laufende Werkzeuge zählen zeitlich nur einmal.', + 'chat.workStatus.telemetry.ttft': 'Mittlere TTFT', + 'chat.workStatus.telemetry.ttftDescription': 'Mittlere Wartezeit bis zum ersten Text oder Denkabschnitt jedes Modellschritts. Fehlt bei einem Schritt der Startzeitstempel, wird kein Wert angezeigt. Das ist bei reinen Werkzeugaufrufen häufig der Fall.', + 'chat.workStatus.telemetry.steps': 'Schritte', + 'chat.workStatus.telemetry.stepsDescription': 'Wie oft das Modell für diesen Prompt aufgerufen wurde. Werkzeugergebnisse lesen und den nächsten Schritt entscheiden erfordert meist einen weiteren Aufruf.', + 'chat.workStatus.telemetry.tokens': 'Tokens', + 'chat.workStatus.telemetry.tokens.inOut': '{input} ↑ · {output} ↓', + 'chat.workStatus.telemetry.speedDescription': 'Erzeugte Tokens aller Schritte einschließlich Denken, geteilt durch die Zeit ohne Werkzeugausführung. Warten auf das Modell zählt mit, daher können viele kurze Aufrufe den Wert senken.', + 'chat.workStatus.telemetry.tokensDescription': '↑ Eingabetokens ohne Cache: {input}. ↓ Erzeugte Tokens: {output} für Text und Werkzeugaufrufe plus {reasoning} zum Denken. Summen über alle Schritte dieses Prompts.', + 'chat.workStatus.telemetry.cacheHit': 'Cache', + 'chat.workStatus.telemetry.cacheHitDescription': 'Anteil der Eingabetokens, die über alle Schritte aus dem Prompt-Cache wiederverwendet wurden. Das kann Kosten und Wartezeit senken, ist aber kein Geschwindigkeitswert.', + 'chat.workStatus.telemetry.cost': 'Kosten', + 'chat.workStatus.telemetry.costDescription': 'Vom Anbieter gemeldete Kosten aller Modellschritte dieses Prompts in US-Dollar. Separate Subagent-Sitzungen sind nicht enthalten. Null kann ein kostenloses Modell oder fehlende Kostenangaben bedeuten.', 'chat.workStatus.goal.open': 'Ziel verwalten', 'chat.workStatus.goal.pause': 'Pausieren', 'chat.workStatus.goal.resume': 'Fortsetzen', diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts index bcde7971..f8ba5012 100644 --- a/packages/ui/src/lib/i18n/messages/en.ts +++ b/packages/ui/src/lib/i18n/messages/en.ts @@ -3247,6 +3247,26 @@ export const dict = { 'chat.workStatus.action.openPr': 'Open pull request', 'chat.workStatus.action.openSubagent': 'Open {name}', 'chat.workStatus.section.usage': 'Usage', + 'chat.workStatus.section.telemetry': 'Turn stats', + 'chat.workStatus.telemetry.responseSpeed': 'Response', + 'chat.workStatus.telemetry.responseSpeedDescription': 'How fast the final text arrived. Excludes the initial wait, reasoning, and earlier tool calls. An estimate from text timestamps, not a provider speed measurement.', + 'chat.workStatus.telemetry.speed': 'Whole turn', + 'chat.workStatus.telemetry.speedDescription': 'Tokens generated across all steps, including reasoning, divided by time with tool execution removed. Waiting for the model still counts, so many short tool calls can lower this number.', + 'chat.workStatus.telemetry.llmDuration': 'Model time', + 'chat.workStatus.telemetry.llmDurationDescription': 'Time spent on all model steps, including waiting for responses. Tool execution time is removed. This is not just time spent generating text.', + 'chat.workStatus.telemetry.toolDuration': 'Tool time', + 'chat.workStatus.telemetry.toolDurationDescription': 'Time spent running tools, including failed calls. Tools running at the same time are counted once, not added together.', + 'chat.workStatus.telemetry.ttft': 'Average TTFT', + 'chat.workStatus.telemetry.ttftDescription': 'Average wait before the first text or reasoning starts in each model step. Hidden when any step lacks a start timestamp, as tool-only steps often do.', + 'chat.workStatus.telemetry.steps': 'Steps', + 'chat.workStatus.telemetry.stepsDescription': 'How many times the model was called for this prompt. Reading tool results and deciding what to do next usually takes another step.', + 'chat.workStatus.telemetry.tokens': 'Tokens', + 'chat.workStatus.telemetry.tokens.inOut': '{input} ↑ · {output} ↓', + 'chat.workStatus.telemetry.tokensDescription': '↑ Input without cached tokens: {input}. ↓ Generated tokens: {output} for text and tool calls, plus {reasoning} for reasoning. Totals cover all steps of this prompt.', + 'chat.workStatus.telemetry.cacheHit': 'Cache', + 'chat.workStatus.telemetry.cacheHitDescription': 'Share of input tokens reused from the prompt cache across all steps. Reusing context can reduce cost and waiting, but this is not a speed score.', + 'chat.workStatus.telemetry.cost': 'Cost', + 'chat.workStatus.telemetry.costDescription': 'Cost reported by the provider for all model steps of this prompt, in US dollars. Excludes separate subagent sessions. Zero can mean a free model or a provider that reports no charge.', 'chat.workStatus.goal.open': 'Manage goal', 'chat.workStatus.goal.pause': 'Pause', 'chat.workStatus.goal.resume': 'Resume', diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts index 47889130..8b9da925 100644 --- a/packages/ui/src/lib/i18n/messages/es.ts +++ b/packages/ui/src/lib/i18n/messages/es.ts @@ -3248,6 +3248,26 @@ export const dict: Record = { 'chat.workStatus.action.openPr': 'Abrir pull request', 'chat.workStatus.action.openSubagent': 'Abrir {name}', 'chat.workStatus.section.usage': 'Uso', + 'chat.workStatus.section.telemetry': 'Estadísticas del turno', + 'chat.workStatus.telemetry.responseSpeed': 'Respuesta', + 'chat.workStatus.telemetry.responseSpeedDescription': 'La velocidad a la que llegó el texto final. Excluye la espera inicial, el razonamiento y las llamadas anteriores a herramientas. Es una estimación basada en las marcas de tiempo del texto, no una medición del proveedor.', + 'chat.workStatus.telemetry.speed': 'Solicitud', + 'chat.workStatus.telemetry.llmDuration': 'Modelo', + 'chat.workStatus.telemetry.llmDurationDescription': 'Tiempo de todos los pasos del modelo, incluida la espera de respuestas. Se resta la ejecución de herramientas. No es solo el tiempo de generación de texto.', + 'chat.workStatus.telemetry.toolDuration': 'Herramientas', + 'chat.workStatus.telemetry.toolDurationDescription': 'Tiempo de ejecución de herramientas, incluidas las llamadas fallidas. Las herramientas que se ejecutan a la vez cuentan una sola vez.', + 'chat.workStatus.telemetry.ttft': 'TTFT medio', + 'chat.workStatus.telemetry.ttftDescription': 'Espera media hasta el primer texto o razonamiento de cada paso. Se oculta si falta la marca de inicio de algún paso, algo habitual en pasos que solo llaman a herramientas.', + 'chat.workStatus.telemetry.steps': 'Pasos', + 'chat.workStatus.telemetry.stepsDescription': 'Cuántas veces se llamó al modelo para este prompt. Leer el resultado de una herramienta y decidir qué hacer suele requerir otro paso.', + 'chat.workStatus.telemetry.tokens': 'Tokens', + 'chat.workStatus.telemetry.tokens.inOut': '{input} ↑ · {output} ↓', + 'chat.workStatus.telemetry.speedDescription': 'Tokens generados en todos los pasos, incluido el razonamiento, divididos por el tiempo sin ejecución de herramientas. La espera del modelo sí cuenta, por lo que muchas llamadas cortas pueden reducir este valor.', + 'chat.workStatus.telemetry.tokensDescription': '↑ Entrada sin tokens en caché: {input}. ↓ Generados: {output} para texto y llamadas a herramientas, más {reasoning} de razonamiento. Totales de todos los pasos de este prompt.', + 'chat.workStatus.telemetry.cacheHit': 'Caché', + 'chat.workStatus.telemetry.cacheHitDescription': 'Proporción de tokens de entrada reutilizados de la caché del prompt en todos los pasos. Reutilizar el contexto puede reducir el costo y la espera, pero no es una medida de velocidad.', + 'chat.workStatus.telemetry.cost': 'Costo', + 'chat.workStatus.telemetry.costDescription': 'Costo comunicado por el proveedor para todos los pasos de este prompt, en dólares estadounidenses. No incluye sesiones separadas de subagentes. Cero puede indicar un modelo gratuito o un proveedor que no informa del cobro.', 'chat.workStatus.goal.open': 'Gestionar objetivo', 'chat.workStatus.goal.pause': 'Pausar', 'chat.workStatus.goal.resume': 'Reanudar', diff --git a/packages/ui/src/lib/i18n/messages/fr.ts b/packages/ui/src/lib/i18n/messages/fr.ts index bc55cbcc..5964867f 100644 --- a/packages/ui/src/lib/i18n/messages/fr.ts +++ b/packages/ui/src/lib/i18n/messages/fr.ts @@ -3245,6 +3245,26 @@ export const dict = { 'chat.workStatus.action.openPr': 'Ouvrir la pull request', 'chat.workStatus.action.openSubagent': 'Ouvrir {name}', 'chat.workStatus.section.usage': 'Utilisation', + 'chat.workStatus.section.telemetry': 'Stats du tour', + 'chat.workStatus.telemetry.responseSpeed': 'Réponse', + 'chat.workStatus.telemetry.responseSpeedDescription': 'La vitesse à laquelle le texte final est arrivé. Sans attente initiale, raisonnement ni appels précédents aux outils. Une estimation basée sur les horodatages du texte, pas une mesure du fournisseur.', + 'chat.workStatus.telemetry.speed': 'Requête', + 'chat.workStatus.telemetry.llmDuration': 'Modèle', + 'chat.workStatus.telemetry.llmDurationDescription': 'Durée de toutes les étapes du modèle, attente des réponses comprise. Le temps des outils est soustrait. Ce ne sont pas uniquement les secondes de génération du texte.', + 'chat.workStatus.telemetry.toolDuration': 'Outils', + 'chat.workStatus.telemetry.toolDurationDescription': 'Temps passé à exécuter les outils, y compris les appels échoués. Les outils exécutés en parallèle ne sont comptés qu’une fois.', + 'chat.workStatus.telemetry.ttft': 'TTFT moyen', + 'chat.workStatus.telemetry.ttftDescription': 'Attente moyenne avant le premier texte ou raisonnement de chaque étape. Masquée si une étape manque d’horodatage de début, ce qui arrive souvent pour les appels aux outils sans texte.', + 'chat.workStatus.telemetry.steps': 'Étapes', + 'chat.workStatus.telemetry.stepsDescription': 'Nombre d’appels au modèle pour ce prompt. Lire les résultats d’un outil et décider de la suite demande généralement une nouvelle étape.', + 'chat.workStatus.telemetry.tokens': 'Jetons', + 'chat.workStatus.telemetry.tokens.inOut': '{input} ↑ · {output} ↓', + 'chat.workStatus.telemetry.speedDescription': 'Jetons générés à toutes les étapes, raisonnement compris, divisés par la durée hors exécution des outils. L’attente du modèle compte, donc de nombreux appels courts peuvent réduire ce chiffre.', + 'chat.workStatus.telemetry.tokensDescription': '↑ Entrée hors cache : {input}. ↓ Jetons générés : {output} pour le texte et les appels aux outils, plus {reasoning} pour le raisonnement. Totaux de toutes les étapes de ce prompt.', + 'chat.workStatus.telemetry.cacheHit': 'Cache', + 'chat.workStatus.telemetry.cacheHitDescription': 'Part des jetons d’entrée réutilisés depuis le cache du prompt, sur toutes les étapes. Réutiliser le contexte peut réduire le coût et l’attente, mais ce n’est pas un indice de vitesse.', + 'chat.workStatus.telemetry.cost': 'Coût', + 'chat.workStatus.telemetry.costDescription': 'Coût indiqué par le fournisseur pour toutes les étapes de ce prompt, en dollars américains. Les sessions séparées des sous-agents sont exclues. Zéro peut signifier un modèle gratuit ou un fournisseur sans indication de coût.', 'chat.workStatus.goal.open': 'Gérer l’objectif', 'chat.workStatus.goal.pause': 'Mettre en pause', 'chat.workStatus.goal.resume': 'Reprendre', diff --git a/packages/ui/src/lib/i18n/messages/ja.ts b/packages/ui/src/lib/i18n/messages/ja.ts index d72c7dd8..1f6ea593 100644 --- a/packages/ui/src/lib/i18n/messages/ja.ts +++ b/packages/ui/src/lib/i18n/messages/ja.ts @@ -3247,6 +3247,26 @@ export const dict: Record = { 'chat.workStatus.action.openPr': 'プルリクエストを開く', 'chat.workStatus.action.openSubagent': '{name} を開く', 'chat.workStatus.section.usage': '使用量', + 'chat.workStatus.section.telemetry': 'ターンの統計', + 'chat.workStatus.telemetry.responseSpeed': '回答', + 'chat.workStatus.telemetry.responseSpeedDescription': '最終テキストが届いた速さです。開始前の待ち時間、推論、先行するツール呼び出しは含みません。テキストの時刻から求めた推定値で、プロバイダー側の速度測定ではありません。', + 'chat.workStatus.telemetry.speed': 'リクエスト全体', + 'chat.workStatus.telemetry.llmDuration': 'モデル時間', + 'chat.workStatus.telemetry.llmDurationDescription': '応答待ちを含む全モデルステップの時間です。ツール実行時間は差し引いています。テキスト生成だけの時間ではありません。', + 'chat.workStatus.telemetry.toolDuration': 'ツール時間', + 'chat.workStatus.telemetry.toolDurationDescription': '失敗した呼び出しも含むツールの実行時間です。同時に動いたツールの時間は重複して加算しません。', + 'chat.workStatus.telemetry.ttft': '平均 TTFT', + 'chat.workStatus.telemetry.ttftDescription': '各ステップで最初のテキストや推論が始まるまでの平均待ち時間です。開始時刻がないステップがあれば表示しません。ツール呼び出しのみのステップでは時刻がないことがあります。', + 'chat.workStatus.telemetry.steps': 'ステップ数', + 'chat.workStatus.telemetry.stepsDescription': 'このプロンプトでモデルを呼び出した回数です。ツールの結果を読み、次の処理を決める際は通常もう一度呼び出します。', + 'chat.workStatus.telemetry.tokens': 'トークン', + 'chat.workStatus.telemetry.tokens.inOut': '{input} ↑ · {output} ↓', + 'chat.workStatus.telemetry.speedDescription': '推論を含む全ステップの生成トークン数を、ツール実行を除いた時間で割った値です。モデルの待ち時間は含むため、短い呼び出しが多いと低くなります。', + 'chat.workStatus.telemetry.tokensDescription': '↑ キャッシュを除く入力: {input}。↓ 生成: テキストとツール呼び出し {output}、推論 {reasoning}。このプロンプトの全ステップの合計です。', + 'chat.workStatus.telemetry.cacheHit': 'キャッシュ', + 'chat.workStatus.telemetry.cacheHitDescription': '全ステップの入力トークンのうち、プロンプトキャッシュから再利用した割合です。費用や待ち時間を減らせる場合がありますが、速度の指標ではありません。', + 'chat.workStatus.telemetry.cost': '費用', + 'chat.workStatus.telemetry.costDescription': 'このプロンプトの全モデルステップについてプロバイダーが報告した米ドル建ての費用です。別のサブエージェントセッションは含みません。ゼロは無料モデル、または費用の報告がない場合があります。', 'chat.workStatus.goal.open': '目標を管理', 'chat.workStatus.goal.pause': '一時停止', 'chat.workStatus.goal.resume': '再開', diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index 12b09b7b..0a3f7aa8 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -3247,6 +3247,26 @@ export const dict: Record = { 'chat.workStatus.action.openPr': '풀 리퀘스트 열기', 'chat.workStatus.action.openSubagent': '{name} 열기', 'chat.workStatus.section.usage': '사용량', + 'chat.workStatus.section.telemetry': '턴 통계', + 'chat.workStatus.telemetry.responseSpeed': '응답', + 'chat.workStatus.telemetry.responseSpeedDescription': '최종 텍스트가 도착한 속도입니다. 시작 전 대기, 추론, 이전 도구 호출은 제외합니다. 텍스트 시간 기록으로 계산한 추정치이며 제공자 측 속도 측정값은 아닙니다.', + 'chat.workStatus.telemetry.speed': '전체 요청', + 'chat.workStatus.telemetry.llmDuration': '모델 시간', + 'chat.workStatus.telemetry.llmDurationDescription': '응답 대기를 포함한 모든 모델 단계의 시간입니다. 도구 실행 시간은 뺍니다. 텍스트 생성 시간만을 뜻하지는 않습니다.', + 'chat.workStatus.telemetry.toolDuration': '도구 시간', + 'chat.workStatus.telemetry.toolDurationDescription': '실패한 호출을 포함한 도구 실행 시간입니다. 동시에 실행된 도구의 시간은 중복해서 더하지 않습니다.', + 'chat.workStatus.telemetry.ttft': '평균 TTFT', + 'chat.workStatus.telemetry.ttftDescription': '각 모델 단계에서 첫 텍스트나 추론이 시작되기까지의 평균 대기 시간입니다. 시작 시간이 없는 단계가 있으면 표시하지 않습니다. 도구만 호출하는 단계에서 흔히 발생합니다.', + 'chat.workStatus.telemetry.steps': '단계', + 'chat.workStatus.telemetry.stepsDescription': '이 프롬프트에서 모델을 호출한 횟수입니다. 도구 결과를 읽고 다음 작업을 결정하려면 보통 한 단계가 더 필요합니다.', + 'chat.workStatus.telemetry.tokens': '토큰', + 'chat.workStatus.telemetry.tokens.inOut': '{input} ↑ · {output} ↓', + 'chat.workStatus.telemetry.speedDescription': '추론을 포함한 모든 단계의 생성 토큰 수를 도구 실행 시간을 뺀 시간으로 나눈 값입니다. 모델 대기 시간은 포함되므로 짧은 호출이 많으면 낮아질 수 있습니다.', + 'chat.workStatus.telemetry.tokensDescription': '↑ 캐시를 제외한 입력 토큰: {input}. ↓ 생성 토큰: 텍스트와 도구 호출 {output}, 추론 {reasoning}. 이 프롬프트의 모든 단계 합계입니다.', + 'chat.workStatus.telemetry.cacheHit': '캐시 적중률', + 'chat.workStatus.telemetry.cacheHitDescription': '모든 단계의 입력 토큰 중 프롬프트 캐시에서 재사용한 비율입니다. 컨텍스트 재사용은 비용과 대기를 줄일 수 있지만 속도 점수는 아닙니다.', + 'chat.workStatus.telemetry.cost': '비용', + 'chat.workStatus.telemetry.costDescription': '제공자가 보고한 이 프롬프트의 모든 모델 단계 비용이며 미국 달러 기준입니다. 별도 하위 에이전트 세션은 제외합니다. 무료 모델이거나 제공자가 비용을 보고하지 않으면 0일 수 있습니다.', 'chat.workStatus.goal.open': '목표 관리', 'chat.workStatus.goal.pause': '일시정지', 'chat.workStatus.goal.resume': '재개', diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts index 2353e4ac..8c2d23bf 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -3264,6 +3264,26 @@ export const dict: Record = { 'chat.workStatus.action.openPr': 'Otwórz pull request', 'chat.workStatus.action.openSubagent': 'Otwórz {name}', 'chat.workStatus.section.usage': 'Zużycie', + 'chat.workStatus.section.telemetry': 'Statystyki tury', + 'chat.workStatus.telemetry.responseSpeed': 'Odpowiedź', + 'chat.workStatus.telemetry.responseSpeedDescription': 'Jak szybko docierał końcowy tekst. Bez początkowego oczekiwania, rozumowania i wcześniejszych wywołań narzędzi. To szacunek z czasów tekstu, a nie pomiar po stronie dostawcy.', + 'chat.workStatus.telemetry.speed': 'Całe żądanie', + 'chat.workStatus.telemetry.llmDuration': 'Czas modelu', + 'chat.workStatus.telemetry.llmDurationDescription': 'Czas wszystkich kroków modelu wraz z oczekiwaniem na odpowiedzi. Czas narzędzi jest odjęty. To nie tylko czas generowania tekstu.', + 'chat.workStatus.telemetry.toolDuration': 'Narzędzia', + 'chat.workStatus.telemetry.toolDurationDescription': 'Czas wykonywania narzędzi, także nieudanych wywołań. Narzędzia działające równolegle liczymy czasowo tylko raz.', + 'chat.workStatus.telemetry.ttft': 'Średni TTFT', + 'chat.workStatus.telemetry.ttftDescription': 'Średnie oczekiwanie na pierwszy tekst lub rozumowanie w każdym kroku. Ukryte, gdy choć jeden krok nie ma czasu rozpoczęcia, co często dotyczy kroków z samymi narzędziami.', + 'chat.workStatus.telemetry.steps': 'Kroki', + 'chat.workStatus.telemetry.stepsDescription': 'Liczba wywołań modelu dla tego promptu. Odczytanie wyniku narzędzia i decyzja o dalszym działaniu zwykle wymaga kolejnego kroku.', + 'chat.workStatus.telemetry.tokens': 'Tokeny', + 'chat.workStatus.telemetry.tokens.inOut': '{input} ↑ · {output} ↓', + 'chat.workStatus.telemetry.speedDescription': 'Tokeny wygenerowane we wszystkich krokach, także rozumowania, podzielone przez czas bez wykonywania narzędzi. Oczekiwanie na model nadal się liczy, więc wiele krótkich wywołań obniża ten wynik.', + 'chat.workStatus.telemetry.tokensDescription': '↑ Wejście bez tokenów z pamięci podręcznej: {input}. ↓ Wygenerowane: {output} dla tekstu i wywołań narzędzi oraz {reasoning} dla rozumowania. Sumy ze wszystkich kroków tego promptu.', + 'chat.workStatus.telemetry.cacheHit': 'Pamięć podr.', + 'chat.workStatus.telemetry.cacheHitDescription': 'Udział tokenów wejściowych użytych ponownie z pamięci podręcznej promptu we wszystkich krokach. Może to zmniejszyć koszt i oczekiwanie, ale nie jest miarą szybkości.', + 'chat.workStatus.telemetry.cost': 'Koszt', + 'chat.workStatus.telemetry.costDescription': 'Koszt wszystkich kroków tego promptu zgłoszony przez dostawcę, w dolarach amerykańskich. Bez oddzielnych sesji subagentów. Zero może oznaczać darmowy model lub brak informacji o opłacie.', 'chat.workStatus.goal.open': 'Zarządzaj celem', 'chat.workStatus.goal.pause': 'Wstrzymaj', 'chat.workStatus.goal.resume': 'Wznów', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index 6dae93be..50352759 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -3248,6 +3248,26 @@ export const dict: Record = { 'chat.workStatus.action.openPr': 'Abrir pull request', 'chat.workStatus.action.openSubagent': 'Abrir {name}', 'chat.workStatus.section.usage': 'Uso', + 'chat.workStatus.section.telemetry': 'Estatísticas do turno', + 'chat.workStatus.telemetry.responseSpeed': 'Resposta', + 'chat.workStatus.telemetry.responseSpeedDescription': 'A velocidade com que o texto final chegou. Exclui a espera inicial, o raciocínio e as chamadas anteriores de ferramentas. É uma estimativa pelos horários do texto, não uma medição do provedor.', + 'chat.workStatus.telemetry.speed': 'Solicitação', + 'chat.workStatus.telemetry.llmDuration': 'Modelo', + 'chat.workStatus.telemetry.llmDurationDescription': 'Tempo de todas as etapas do modelo, incluindo a espera pelas respostas. O tempo das ferramentas é descontado. Não é apenas o tempo de geração do texto.', + 'chat.workStatus.telemetry.toolDuration': 'Ferramentas', + 'chat.workStatus.telemetry.toolDurationDescription': 'Tempo de execução das ferramentas, incluindo chamadas que falharam. Ferramentas executadas ao mesmo tempo contam uma vez só.', + 'chat.workStatus.telemetry.ttft': 'TTFT médio', + 'chat.workStatus.telemetry.ttftDescription': 'Espera média até o primeiro texto ou raciocínio de cada etapa. Não aparece se faltar o horário de início de alguma etapa, algo comum em chamadas apenas de ferramentas.', + 'chat.workStatus.telemetry.steps': 'Etapas', + 'chat.workStatus.telemetry.stepsDescription': 'Quantas vezes o modelo foi chamado para este prompt. Ler o resultado de uma ferramenta e decidir o próximo passo geralmente exige outra chamada.', + 'chat.workStatus.telemetry.tokens': 'Tokens', + 'chat.workStatus.telemetry.tokens.inOut': '{input} ↑ · {output} ↓', + 'chat.workStatus.telemetry.speedDescription': 'Tokens gerados em todas as etapas, incluindo raciocínio, divididos pelo tempo sem execução de ferramentas. A espera pelo modelo conta, então muitas chamadas curtas podem reduzir este valor.', + 'chat.workStatus.telemetry.tokensDescription': '↑ Entrada sem tokens em cache: {input}. ↓ Gerados: {output} para texto e chamadas de ferramentas, mais {reasoning} de raciocínio. Totais de todas as etapas deste prompt.', + 'chat.workStatus.telemetry.cacheHit': 'Cache', + 'chat.workStatus.telemetry.cacheHitDescription': 'Parcela dos tokens de entrada reutilizados do cache do prompt em todas as etapas. Reutilizar o contexto pode reduzir custo e espera, mas não é uma medida de velocidade.', + 'chat.workStatus.telemetry.cost': 'Custo', + 'chat.workStatus.telemetry.costDescription': 'Custo informado pelo provedor para todas as etapas deste prompt, em dólares americanos. Não inclui sessões separadas de subagentes. Zero pode indicar um modelo gratuito ou um provedor que não informa a cobrança.', 'chat.workStatus.goal.open': 'Gerenciar objetivo', 'chat.workStatus.goal.pause': 'Pausar', 'chat.workStatus.goal.resume': 'Retomar', diff --git a/packages/ui/src/lib/i18n/messages/tr.ts b/packages/ui/src/lib/i18n/messages/tr.ts index 76275cb1..63b5232c 100644 --- a/packages/ui/src/lib/i18n/messages/tr.ts +++ b/packages/ui/src/lib/i18n/messages/tr.ts @@ -3162,6 +3162,26 @@ export const dict = { 'chat.workStatus.action.openPr': 'Pull request\'i aç', 'chat.workStatus.action.openSubagent': '{name} öğesini aç', 'chat.workStatus.section.usage': 'Kullanım', + 'chat.workStatus.section.telemetry': 'Tur istatistikleri', + 'chat.workStatus.telemetry.responseSpeed': 'Yanıt', + 'chat.workStatus.telemetry.responseSpeedDescription': 'Son metnin ne hızla geldiği. İlk bekleme, akıl yürütme ve önceki araç çağrıları dahil değildir. Metin zamanlarından hesaplanan bir tahmindir, sağlayıcı tarafındaki hız ölçümü değildir.', + 'chat.workStatus.telemetry.speed': 'Tüm istek', + 'chat.workStatus.telemetry.llmDuration': 'Model süresi', + 'chat.workStatus.telemetry.llmDurationDescription': 'Yanıt bekleme dahil tüm model adımlarının süresi. Araç çalışma süresi çıkarılır. Yalnızca metin üretme süresi değildir.', + 'chat.workStatus.telemetry.toolDuration': 'Araç süresi', + 'chat.workStatus.telemetry.toolDurationDescription': 'Başarısız çağrılar dahil araçların çalışma süresi. Aynı anda çalışan araçların süreleri bir kez sayılır.', + 'chat.workStatus.telemetry.ttft': 'Ortalama TTFT', + 'chat.workStatus.telemetry.ttftDescription': 'Her model adımında ilk metin veya akıl yürütme başlayana kadar ortalama bekleme. Bir adımın başlangıç zamanı yoksa gösterilmez; yalnızca araç çağıran adımlarda bu sık görülür.', + 'chat.workStatus.telemetry.steps': 'Adımlar', + 'chat.workStatus.telemetry.stepsDescription': 'Bu istem için modelin kaç kez çağrıldığı. Araç sonucunu okuyup sıradaki işi belirlemek genellikle yeni bir adım gerektirir.', + 'chat.workStatus.telemetry.tokens': 'Tokenlar', + 'chat.workStatus.telemetry.tokens.inOut': '{input} ↑ · {output} ↓', + 'chat.workStatus.telemetry.speedDescription': 'Akıl yürütme dahil tüm adımlarda üretilen tokenların, araç çalışması çıkarılmış süreye bölümü. Modeli bekleme süresi sayılır; çok sayıda kısa çağrı bu değeri düşürebilir.', + 'chat.workStatus.telemetry.tokensDescription': '↑ Önbellek hariç girdi: {input}. ↓ Üretilen tokenlar: metin ve araç çağrıları için {output}, akıl yürütme için {reasoning}. Bu istemin tüm adımlarının toplamıdır.', + 'chat.workStatus.telemetry.cacheHit': 'Önbellek', + 'chat.workStatus.telemetry.cacheHitDescription': 'Tüm adımlarda istem önbelleğinden yeniden kullanılan girdi tokenlarının oranı. Bağlamı yeniden kullanmak maliyeti ve beklemeyi azaltabilir, ancak bu bir hız puanı değildir.', + 'chat.workStatus.telemetry.cost': 'Maliyet', + 'chat.workStatus.telemetry.costDescription': 'Sağlayıcının bu istemin tüm model adımları için bildirdiği ABD doları tutarı. Ayrı alt ajan oturumları dahil değildir. Sıfır, ücretsiz model veya ücret bildirmeyen sağlayıcı anlamına gelebilir.', 'chat.workStatus.goal.open': 'Hedefi yönet', 'chat.workStatus.goal.pause': 'Duraklat', 'chat.workStatus.goal.resume': 'Devam et', diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index 8f06b3be..7b1090c2 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -3248,6 +3248,26 @@ export const dict: Record = { 'chat.workStatus.action.openPr': 'Відкрити pull request', 'chat.workStatus.action.openSubagent': 'Відкрити {name}', 'chat.workStatus.section.usage': 'Використання', + 'chat.workStatus.section.telemetry': 'Статистика ходу', + 'chat.workStatus.telemetry.responseSpeed': 'Відповідь', + 'chat.workStatus.telemetry.responseSpeedDescription': 'Як швидко надходив фінальний текст. Без очікування на початок, міркувань і попередніх викликів інструментів. Це оцінка за часовими мітками тексту, а не вимір швидкості на сервері провайдера.', + 'chat.workStatus.telemetry.speed': 'Увесь запит', + 'chat.workStatus.telemetry.llmDuration': 'Час моделі', + 'chat.workStatus.telemetry.llmDurationDescription': 'Час усіх кроків моделі, включно з очікуванням відповідей. Час виконання інструментів віднято. Це не лише час генерації тексту.', + 'chat.workStatus.telemetry.toolDuration': 'Час інструментів', + 'chat.workStatus.telemetry.toolDurationDescription': 'Час виконання інструментів, включно з невдалими викликами. Паралельне виконання рахується один раз, а не додається кілька разів.', + 'chat.workStatus.telemetry.ttft': 'Середній TTFT', + 'chat.workStatus.telemetry.ttftDescription': 'Середнє очікування до початку тексту або міркувань на кожному кроці моделі. Не показуємо, якщо хоча б один крок не має часової мітки початку, як часто буває з викликами лише інструментів.', + 'chat.workStatus.telemetry.steps': 'Кроки', + 'chat.workStatus.telemetry.stepsDescription': 'Скільки разів зверталися до моделі для цього промпту. Прочитати результат інструмента й вирішити, що робити далі, зазвичай означає ще один крок.', + 'chat.workStatus.telemetry.tokens': 'Токени', + 'chat.workStatus.telemetry.tokens.inOut': '{input} ↑ · {output} ↓', + 'chat.workStatus.telemetry.speedDescription': 'Згенеровані токени всіх кроків, включно з міркуваннями, поділені на час без виконання інструментів. Очікування моделі залишається, тому багато коротких викликів можуть знижувати цей показник.', + 'chat.workStatus.telemetry.tokensDescription': '↑ Вхідні токени без кешованих: {input}. ↓ Згенеровані: {output} для тексту й викликів інструментів та {reasoning} для міркувань. Суми охоплюють усі кроки цього промпту.', + 'chat.workStatus.telemetry.cacheHit': 'Кеш', + 'chat.workStatus.telemetry.cacheHitDescription': 'Частка вхідних токенів, повторно використаних із кешу промпту на всіх кроках. Повторне використання контексту може зменшити вартість і очікування, але це не оцінка швидкості.', + 'chat.workStatus.telemetry.cost': 'Вартість', + 'chat.workStatus.telemetry.costDescription': 'Вартість усіх кроків моделі для цього промпту за даними провайдера, у доларах США. Окремі сесії субагентів не включено. Нуль може означати безкоштовну модель або провайдера, який не повідомляє про оплату.', 'chat.workStatus.goal.open': 'Керувати ціллю', 'chat.workStatus.goal.pause': 'Пауза', 'chat.workStatus.goal.resume': 'Відновити', diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index 7316dde3..ee593026 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -3248,6 +3248,26 @@ export const dict: Record = { 'chat.workStatus.action.openPr': '打开拉取请求', 'chat.workStatus.action.openSubagent': '打开 {name}', 'chat.workStatus.section.usage': '用量', + 'chat.workStatus.section.telemetry': '轮次统计', + 'chat.workStatus.telemetry.responseSpeed': '回答速度', + 'chat.workStatus.telemetry.responseSpeedDescription': '最终文本到达的速度。不含开始前的等待、推理和之前的工具调用。这是根据文本时间戳估算的速度,不是提供商测得的生成速度。', + 'chat.workStatus.telemetry.speed': '整个请求', + 'chat.workStatus.telemetry.llmDuration': '模型耗时', + 'chat.workStatus.telemetry.llmDurationDescription': '所有模型步骤的耗时,包括等待回答的时间。已扣除工具执行时间,并不只是生成文本的时间。', + 'chat.workStatus.telemetry.toolDuration': '工具耗时', + 'chat.workStatus.telemetry.toolDurationDescription': '工具执行所用的时间,包括失败的调用。多个工具同时运行的时间只计算一次,不重复相加。', + 'chat.workStatus.telemetry.ttft': '平均首字延迟', + 'chat.workStatus.telemetry.ttftDescription': '每个模型步骤开始输出文本或推理前的平均等待时间。如果有任何步骤缺少开始时间戳,就不显示。只调用工具的步骤经常没有这项数据。', + 'chat.workStatus.telemetry.steps': '步骤', + 'chat.workStatus.telemetry.stepsDescription': '处理这条提示时调用模型的次数。读取工具结果并决定下一步通常需要再次调用模型。', + 'chat.workStatus.telemetry.tokens': 'Token', + 'chat.workStatus.telemetry.tokens.inOut': '{input} ↑ · {output} ↓', + 'chat.workStatus.telemetry.speedDescription': '所有步骤生成的 token 数,包括推理,除以扣除工具执行后的时间。等待模型的时间仍计入,因此多次短工具调用可能拉低这个数值。', + 'chat.workStatus.telemetry.tokensDescription': '↑ 不含缓存的输入 token:{input}。↓ 生成的 token:文本和工具调用 {output},推理 {reasoning}。统计这条提示的所有步骤。', + 'chat.workStatus.telemetry.cacheHit': '缓存命中率', + 'chat.workStatus.telemetry.cacheHitDescription': '所有步骤中从提示缓存复用的输入 token 比例。复用上下文可能降低费用和等待时间,但这不是速度评分。', + 'chat.workStatus.telemetry.cost': '费用', + 'chat.workStatus.telemetry.costDescription': '提供商报告的这条提示所有模型步骤的费用,单位为美元。不含独立子代理会话。零可能表示免费模型,也可能是提供商未报告费用。', 'chat.workStatus.goal.open': '管理目标', 'chat.workStatus.goal.pause': '暂停', 'chat.workStatus.goal.resume': '继续', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.ts b/packages/ui/src/lib/i18n/messages/zh-TW.ts index e89bd46d..687d520e 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts @@ -3247,6 +3247,26 @@ export const dict: Record = { 'chat.workStatus.action.openPr': '開啟提取請求', 'chat.workStatus.action.openSubagent': '開啟 {name}', 'chat.workStatus.section.usage': '用量', + 'chat.workStatus.section.telemetry': '輪次統計', + 'chat.workStatus.telemetry.responseSpeed': '回答速度', + 'chat.workStatus.telemetry.responseSpeedDescription': '最終文字到達的速度。不含開始前的等待、推理和先前的工具呼叫。這是根據文字時間戳記估算的速度,不是供應商測得的生成速度。', + 'chat.workStatus.telemetry.speed': '整個請求', + 'chat.workStatus.telemetry.llmDuration': '模型耗時', + 'chat.workStatus.telemetry.llmDurationDescription': '所有模型步驟的耗時,包括等待回答的時間。已扣除工具執行時間,並不只是生成文字的時間。', + 'chat.workStatus.telemetry.toolDuration': '工具耗時', + 'chat.workStatus.telemetry.toolDurationDescription': '工具執行所用的時間,包括失敗的呼叫。多個工具同時執行的時間只計算一次,不重複相加。', + 'chat.workStatus.telemetry.ttft': '平均首字延遲', + 'chat.workStatus.telemetry.ttftDescription': '每個模型步驟開始輸出文字或推理前的平均等待時間。若任何步驟缺少開始時間戳記,就不顯示。僅呼叫工具的步驟經常沒有這項資料。', + 'chat.workStatus.telemetry.steps': '步驟', + 'chat.workStatus.telemetry.stepsDescription': '處理這則提示時呼叫模型的次數。讀取工具結果並決定下一步通常需要再次呼叫模型。', + 'chat.workStatus.telemetry.tokens': 'Token', + 'chat.workStatus.telemetry.tokens.inOut': '{input} ↑ · {output} ↓', + 'chat.workStatus.telemetry.speedDescription': '所有步驟生成的 token 數,包括推理,除以扣除工具執行後的時間。等待模型的時間仍計入,因此多次短工具呼叫可能拉低這個數值。', + 'chat.workStatus.telemetry.tokensDescription': '↑ 不含快取的輸入 token:{input}。↓ 生成的 token:文字和工具呼叫 {output},推理 {reasoning}。統計這則提示的所有步驟。', + 'chat.workStatus.telemetry.cacheHit': '快取命中率', + 'chat.workStatus.telemetry.cacheHitDescription': '所有步驟中從提示快取重複使用的輸入 token 比例。重複使用上下文可能降低費用和等待時間,但這不是速度評分。', + 'chat.workStatus.telemetry.cost': '費用', + 'chat.workStatus.telemetry.costDescription': '供應商回報的這則提示所有模型步驟的費用,單位為美元。不含獨立子代理工作階段。零可能表示免費模型,也可能是供應商未回報費用。', 'chat.workStatus.goal.open': '管理目標', 'chat.workStatus.goal.pause': '暫停', 'chat.workStatus.goal.resume': '繼續', diff --git a/packages/ui/src/lib/persistence.test.ts b/packages/ui/src/lib/persistence.test.ts index e6eb13d4..39c5554b 100644 --- a/packages/ui/src/lib/persistence.test.ts +++ b/packages/ui/src/lib/persistence.test.ts @@ -835,6 +835,44 @@ describe('updateDesktopSettings', () => { expect(saveCalls.some((changes) => changes.toolJsonViewMode === 'formatted')).toBe(true); }); + test('legacy server lists keep telemetry hidden, while explicit opt-ins survive hydration', async () => { + getWindow(); + for (const explicit of [undefined, false, true]) { + invalidateSettingsCache(); + registerSettingsApi(async (changes) => changes, async () => ({ + settings: { workStatusHiddenSections: ['mcp'], workStatusHiddenSectionsExplicit: explicit, + draftStartersCraftGoalAdded: true, draftStartersScheduleTaskAdded: true }, + source: 'web', + })); + await syncDesktopSettings(); + expect(useUIStore.getState().workStatusHiddenSections).toEqual(explicit ? ['mcp'] : ['mcp', 'telemetry']); + expect(useUIStore.getState().workStatusHiddenSectionsExplicit).toBe(explicit === true); + } + }); + + test('autosaves telemetry opt-in and its list together, then restores them through settings load', async () => { + getWindow(); + invalidateSettingsCache(); + let server: SettingsPayload = { workStatusHiddenSections: [], draftStartersCraftGoalAdded: true, draftStartersScheduleTaskAdded: true }; + const saves: Partial[] = []; + registerSettingsApi(async (changes) => { saves.push(changes); server = { ...server, ...changes }; return changes; }, + async () => ({ settings: server, source: 'web' })); + await syncDesktopSettings(); + expect(useUIStore.getState().workStatusHiddenSections).toEqual(['telemetry']); + startAppearanceAutoSave(); + useUIStore.getState().setWorkStatusSectionVisible('telemetry', true); + await delay(600); + expect(saves.some((changes) => changes.workStatusHiddenSections?.length === 0 && changes.workStatusHiddenSectionsExplicit === true)).toBe(true); + expect(server.workStatusHiddenSections).toEqual([]); + invalidateSettingsCache(); + await syncDesktopSettings(); + expect(useUIStore.getState().workStatusHiddenSections).toEqual([]); + expect(useUIStore.getState().workStatusHiddenSectionsExplicit).toBe(true); + // An unrelated partial save response must not turn an opt-in back off. + await updateDesktopSettings({ workStatusPanelEnabled: useUIStore.getState().workStatusPanelEnabled }); + expect(useUIStore.getState().workStatusHiddenSections).toEqual([]); + }); + test('applies persisted autoSaveEnabled from server settings', async () => { getWindow(); invalidateSettingsCache(); diff --git a/packages/ui/src/lib/persistence.ts b/packages/ui/src/lib/persistence.ts index 40306564..2c4313f1 100644 --- a/packages/ui/src/lib/persistence.ts +++ b/packages/ui/src/lib/persistence.ts @@ -559,6 +559,7 @@ const materializeAuthoritativeUiSettings = (settings: DesktopSettings): DesktopS streamingAutoFollowEnabled: defaults.streamingAutoFollowEnabled, workStatusPanelEnabled: defaults.workStatusPanelEnabled, workStatusHiddenSections: defaults.workStatusHiddenSections, + workStatusHiddenSectionsExplicit: defaults.workStatusHiddenSectionsExplicit, sessionRecapEnabled: defaults.sessionRecapEnabled, sessionSuggestionEnabled: defaults.sessionSuggestionEnabled, sessionGoalEnabled: defaults.sessionGoalEnabled, @@ -661,9 +662,10 @@ const applyDesktopUiPreferences = (settings: DesktopSettings) => { store.setWorkStatusPanelEnabled(settings.workStatusPanelEnabled); } if (Array.isArray(settings.workStatusHiddenSections)) { - const next = sanitizeWorkStatusHiddenSections(settings.workStatusHiddenSections); - if (next.join('\u0000') !== store.workStatusHiddenSections.join('\u0000')) { - store.setWorkStatusHiddenSections(next); + const explicit = settings.workStatusHiddenSectionsExplicit === true; + const next = sanitizeWorkStatusHiddenSections(settings.workStatusHiddenSections, explicit); + if (next.join('\u0000') !== store.workStatusHiddenSections.join('\u0000') || explicit !== store.workStatusHiddenSectionsExplicit) { + useUIStore.setState({ workStatusHiddenSections: next, workStatusHiddenSectionsExplicit: explicit }); } } if (typeof settings.showReasoningTraces === 'boolean' && settings.showReasoningTraces !== store.showReasoningTraces) { @@ -1216,6 +1218,9 @@ const sanitizeWebSettings = (payload: unknown): DesktopSettings | null => { // accumulate forever as sections get renamed. result.workStatusHiddenSections = sanitizeWorkStatusHiddenSections(candidate.workStatusHiddenSections); } + if (typeof candidate.workStatusHiddenSectionsExplicit === 'boolean') { + result.workStatusHiddenSectionsExplicit = candidate.workStatusHiddenSectionsExplicit; + } if (typeof candidate.showReasoningTraces === 'boolean') { result.showReasoningTraces = candidate.showReasoningTraces; } diff --git a/packages/ui/src/stores/useUIStore.telemetry.test.ts b/packages/ui/src/stores/useUIStore.telemetry.test.ts new file mode 100644 index 00000000..8e8f75d3 --- /dev/null +++ b/packages/ui/src/stores/useUIStore.telemetry.test.ts @@ -0,0 +1,41 @@ +import { afterEach, describe, expect, test } from 'bun:test'; +import { useUIStore } from './useUIStore'; + +const originalOptions = useUIStore.persist.getOptions(); +const originalState = useUIStore.getState(); +afterEach(() => { + useUIStore.persist.setOptions(originalOptions); + useUIStore.setState(originalState, true); +}); + +describe('telemetry settings migration', () => { + for (const version of [18, 19]) { + test(`migrates real v${version} hydration without losing existing hidden sections`, async () => { + useUIStore.persist.setOptions({ storage: { + getItem: () => ({ version, state: { ...useUIStore.getInitialState(), workStatusHiddenSections: ['mcp'] } }), + setItem: () => undefined, + removeItem: () => undefined, + } }); + await useUIStore.persist.rehydrate(); + expect(useUIStore.getState().workStatusHiddenSections).toEqual(['mcp', 'telemetry']); + expect(useUIStore.getState().workStatusHiddenSectionsExplicit).toBe(false); + expect(useUIStore.persist.getOptions().version).toBe(20); + }); + } + + test('explicit opt-in round-trips through the actual persisted projection and hydration', async () => { + let saved: Parameters['setItem']>[1] = { state: useUIStore.getInitialState(), version: 20 }; + useUIStore.persist.setOptions({ storage: { + getItem: () => saved, + setItem: (_name, value) => { saved = value; }, + removeItem: () => undefined, + } }); + useUIStore.setState({ workStatusHiddenSections: ['telemetry', 'mcp'], workStatusHiddenSectionsExplicit: false }); + useUIStore.getState().setWorkStatusSectionVisible('telemetry', true); + useUIStore.persist.setOptions({ storage: { getItem: () => saved, setItem: () => undefined, removeItem: () => undefined } }); + useUIStore.setState({ workStatusHiddenSections: ['telemetry'], workStatusHiddenSectionsExplicit: false }); + await useUIStore.persist.rehydrate(); + expect(useUIStore.getState().workStatusHiddenSections).toEqual(['mcp']); + expect(useUIStore.getState().workStatusHiddenSectionsExplicit).toBe(true); + }); +}); diff --git a/packages/ui/src/stores/useUIStore.ts b/packages/ui/src/stores/useUIStore.ts index 0c07fd7f..26c372bc 100644 --- a/packages/ui/src/stores/useUIStore.ts +++ b/packages/ui/src/stores/useUIStore.ts @@ -790,6 +790,8 @@ interface UIStore { * Persisted to server settings, not just this browser. */ workStatusHiddenSections: string[]; + /** Explicitly chosen hidden-section state. False keeps the default opt-in seed. */ + workStatusHiddenSectionsExplicit: boolean; isSessionSwitcherOpen: boolean; isSessionDropdownOpen: boolean; pendingDiffFile: string | null; @@ -1186,7 +1188,8 @@ export const useUIStore = create()( workStatusPanelVisible: false, workStatusPanelFits: false, workStatusOverlayOpen: false, - workStatusHiddenSections: [], + workStatusHiddenSections: ['telemetry'], + workStatusHiddenSectionsExplicit: false, isSessionSwitcherOpen: false, isSessionDropdownOpen: false, pendingDiffFile: null, @@ -1809,6 +1812,7 @@ export const useUIStore = create()( const isHidden = hidden.includes(sectionId); if (visible === !isHidden) return state; return { + workStatusHiddenSectionsExplicit: true, workStatusHiddenSections: visible ? hidden.filter((entry) => entry !== sectionId) : [...hidden, sectionId], @@ -1817,7 +1821,7 @@ export const useUIStore = create()( }, setWorkStatusHiddenSections: (sectionIds) => { - set({ workStatusHiddenSections: [...new Set(sectionIds)] }); + set({ workStatusHiddenSections: [...new Set(sectionIds)], workStatusHiddenSectionsExplicit: true }); }, setContextRailSurfaceVisible: (surfaceId, visible) => { @@ -2710,13 +2714,25 @@ export const useUIStore = create()( { name: 'ui-store', storage: createDeferredSafeJSONStorage(), - version: 19, + version: 20, migrate: (persistedState, version) => { if (!persistedState || typeof persistedState !== 'object') { return persistedState; } const state = persistedState as Record; + // v19 -> v20: lists written before telemetry existed are not opt-ins. + if (version < 20 && state.workStatusHiddenSectionsExplicit !== true) { + if (Array.isArray(state.workStatusHiddenSections)) { + if (!state.workStatusHiddenSections.includes('telemetry')) { + state.workStatusHiddenSections.push('telemetry'); + } + } else { + state.workStatusHiddenSections = ['telemetry']; + } + state.workStatusHiddenSectionsExplicit = false; + } + // v15 -> v16: the main-area surface concept is gone from persistence // (the chat always owns the desktop main area; panel surfaces have // their own state). Drop the historic fields so a stored non-chat @@ -2964,6 +2980,7 @@ export const useUIStore = create()( workStatusScrollTop: state.workStatusScrollTop, workStatusPanelEnabled: state.workStatusPanelEnabled, workStatusHiddenSections: state.workStatusHiddenSections, + workStatusHiddenSectionsExplicit: state.workStatusHiddenSectionsExplicit, isSessionSwitcherOpen: state.isSessionSwitcherOpen, sidebarSection: state.sidebarSection, settingsPage: state.settingsPage, diff --git a/packages/ui/src/sync/DOCUMENTATION.md b/packages/ui/src/sync/DOCUMENTATION.md index 49b5459f..e2b03d13 100644 --- a/packages/ui/src/sync/DOCUMENTATION.md +++ b/packages/ui/src/sync/DOCUMENTATION.md @@ -195,6 +195,7 @@ Rules: 4. Async commits are generation-checked. Runtime switches, forced refreshes, eviction, and disposal must reject stale completion. 5. Prefetch coverage and persisted directory data are runtime-scoped. Legacy persisted directory entries may seed startup continuity, but they are not live truth. 6. Message and part materialization preserves references for unchanged records and maintains direct message-to-parts lookup. Consumers subscribe to the selected session's records rather than broad message/part containers. + Directory `sessionStatusReady` records successful status-snapshot authority independently of bootstrap's general readiness. Before that flag or an explicit session status arrives, telemetry treats an omitted status as unknown. A failed status request cannot grant idle authority; the flag is not persisted. 7. Pagination demand must carry the selected session's effective directory. It must not fall back to the sync provider directory because the visible session may belong to another worktree. 8. The ref-stable loader is disposed only after the current task when its provider unmounts. This lets React Strict Mode's development setup → cleanup → setup probe retain a usable loader for child effects, while real disposal still invalidates the preceding lifecycle's work. 9. Transcript arrays are chronological by `message.time.created`, with message ID used only as a deterministic equal-time tie-breaker. Message IDs are identity and reconciliation keys, not chronology: OpenCode's fixed-width sortable timestamp prefix rolls over, so a newer `msg_000...` can follow an older `msg_fff...`. Fetch, pagination, materialization, optimistic insertion, events, reconnect inspection, rendering, and revert/undo/redo must preserve this contract. diff --git a/packages/ui/src/sync/bootstrap.test.ts b/packages/ui/src/sync/bootstrap.test.ts index eaa8661e..9d332caa 100644 --- a/packages/ui/src/sync/bootstrap.test.ts +++ b/packages/ui/src/sync/bootstrap.test.ts @@ -3,11 +3,11 @@ import type { OpencodeClient, Project } from "@opencode-ai/sdk/v2/client" import { bootstrapDirectory } from "./bootstrap" import { INITIAL_STATE, type State } from "./types" -const createSdk = (options?: { commandList?: () => Promise<{ data: unknown[] }> }) => ({ +const createSdk = (options?: { commandList?: () => Promise<{ data: unknown[] }>; sessionStatus?: () => Promise<{ data: State['session_status'] }> }) => ({ project: { current: async () => ({ data: { id: "project-a" } }) }, config: { get: async () => ({ data: {} }) }, path: { get: async () => ({ data: { state: "", config: "", worktree: "/repo", directory: "/repo", home: "/home" } }) }, - session: { status: async () => ({ data: {} }) }, + session: { status: options?.sessionStatus ?? (async () => ({ data: {} })) }, command: { list: options?.commandList ?? (async () => ({ data: [] })) }, mcp: { status: async () => ({ data: {} }) }, lsp: { status: async () => ({ data: [] }) }, @@ -65,6 +65,7 @@ describe("bootstrapDirectory", () => { expect(await bootstrapping).toBe("complete") expect(state.status).toBe("complete") + expect(state.sessionStatusReady).toBe(true) expect(deferredStarted).toBe(false) await new Promise((resolve) => setTimeout(resolve, 0)) expect(deferredStarted).toBe(true) @@ -108,4 +109,18 @@ describe("bootstrapDirectory", () => { expect(result).toBe("stale") expect(commits).toBe(0) }) + + test("a failed status request cannot grant idle authority even when bootstrap completes", async () => { + let state = createState() + const result = await bootstrapDirectory({ + directory: '/repo', + sdk: createSdk({ sessionStatus: async () => { throw new Error('status unavailable') } }), + getState: () => state, + set: (patch) => { state = { ...state, ...patch } }, + global: { config: {}, projects: [project] }, + loadSessions: async () => undefined, + }) + expect(result).toBe('complete') + expect(state.sessionStatusReady).toBe(undefined) + }) }) diff --git a/packages/ui/src/sync/bootstrap.ts b/packages/ui/src/sync/bootstrap.ts index 2622f607..0f8eab6a 100644 --- a/packages/ui/src/sync/bootstrap.ts +++ b/packages/ui/src/sync/bootstrap.ts @@ -172,7 +172,7 @@ export async function bootstrapDirectory(input: { if (next) commit({ project: next }) }), ), - retry(() => sdk.session.status().then((x) => commit({ session_status: unwrap(x, "session.status") }))), + retry(() => sdk.session.status().then((x) => commit({ session_status: unwrap(x, "session.status"), sessionStatusReady: true }))), ]) if (input.isStale?.()) return "stale" diff --git a/packages/ui/src/sync/sync-context.tsx b/packages/ui/src/sync/sync-context.tsx index 45e33cde..ed424e2d 100644 --- a/packages/ui/src/sync/sync-context.tsx +++ b/packages/ui/src/sync/sync-context.tsx @@ -742,6 +742,7 @@ async function resyncDirectorySessionStatuses( if (nextStatuses === null) return null applySessionStatusSnapshot(store, nextStatuses, candidateSessionIds, mode) if (mode === "authoritative") { + store.setState({ sessionStatusReady: true }) applyGlobalSessionStatusSnapshot(directory, nextStatuses, candidateSessionIds) // An authoritative snapshot that settles sessions previously observed // busy/retry can leave their trailing assistant message and tool parts diff --git a/packages/ui/src/sync/types.ts b/packages/ui/src/sync/types.ts index 22f1301a..e5bdd34d 100644 --- a/packages/ui/src/sync/types.ts +++ b/packages/ui/src/sync/types.ts @@ -56,6 +56,8 @@ export type State = { sessionEventRevision?: Record sessionDeletedRevision?: Record session_status: Record + /** A successful status snapshot makes omitted sessions authoritatively idle. */ + sessionStatusReady?: boolean session_diff: Record todo: Record permission: Record diff --git a/packages/web/server/lib/opencode/settings-helpers.js b/packages/web/server/lib/opencode/settings-helpers.js index 8b245548..42133f44 100644 --- a/packages/web/server/lib/opencode/settings-helpers.js +++ b/packages/web/server/lib/opencode/settings-helpers.js @@ -203,6 +203,9 @@ export const createSettingsHelpers = (dependencies) => { ...new Set(candidate.workStatusHiddenSections.filter((entry) => typeof entry === 'string' && entry.length > 0)), ]; } + if (typeof candidate.workStatusHiddenSectionsExplicit === 'boolean') { + result.workStatusHiddenSectionsExplicit = candidate.workStatusHiddenSectionsExplicit; + } if (typeof candidate.desktopLanAccessEnabled === 'boolean') { result.desktopLanAccessEnabled = candidate.desktopLanAccessEnabled; } diff --git a/packages/web/server/lib/opencode/settings-helpers.test.js b/packages/web/server/lib/opencode/settings-helpers.test.js index 0e0f5435..16ab53ed 100644 --- a/packages/web/server/lib/opencode/settings-helpers.test.js +++ b/packages/web/server/lib/opencode/settings-helpers.test.js @@ -69,6 +69,19 @@ const createTestHelpersWithRealSanitizers = () => { }; describe('settings helpers', () => { + it('round-trips telemetry opt-in with the hidden list and preserves it across unrelated writes', () => { + const helpers = createTestHelpers(); + const legacy = helpers.sanitizeSettingsUpdate({ workStatusHiddenSections: [] }); + expect(legacy.workStatusHiddenSectionsExplicit).toBeUndefined(); + const changes = helpers.sanitizeSettingsUpdate({ workStatusHiddenSections: [], workStatusHiddenSectionsExplicit: true }); + const saved = helpers.mergePersistedSettings(legacy, changes); + const reloaded = helpers.formatSettingsResponse(JSON.parse(JSON.stringify(saved))); + expect(reloaded.workStatusHiddenSections).toEqual([]); + expect(reloaded.workStatusHiddenSectionsExplicit).toBe(true); + const next = helpers.mergePersistedSettings(reloaded, helpers.sanitizeSettingsUpdate({ workStatusPanelEnabled: false })); + expect(helpers.formatSettingsResponse(next).workStatusHiddenSectionsExplicit).toBe(true); + expect(helpers.sanitizeSettingsUpdate({ workStatusHiddenSectionsExplicit: 'true' }).workStatusHiddenSectionsExplicit).toBeUndefined(); + }); it('imports from the packed @openchamber/web tarball without escaping the published package', async () => { const tempRoot = mkdtempSync(join(tmpdir(), 'settings-helpers-pack-')); const packDir = join(tempRoot, 'pack'); From c2f36fb5e71531fc308d84e48e0bcf789ad4dde6 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sun, 6 Sep 2026 02:14:18 +0300 Subject: [PATCH 04/94] fix(terminal): replay snapshot history at the PTY size it was drawn for Opening the terminal panel sometimes showed stray fragments on the prompt row: zsh's end-of-line mark and pieces of the prompt path. The shell had laid its output out for one PTY width, but the client replayed that history into an emulator of another width (an early size estimate, a remount, or a renderer rebuild after fonts loaded). ghostty-web's reflow then left fragments the shell's SIGWINCH redraw never clears. The server now reports the PTY cols/rows in every snapshot, the transport carries them through projections and accepted resizes, and the viewport replays a sized snapshot chunk at that size before returning to the fitted size. The container-based size estimate only seeds newly spawned shells and is no longer sent to a running PTY. Tests cover the sized replay, the store chunk size, the transport projection, and the server snapshot; verified in a production build by reloading with the panel open and switching tabs at a changed width. --- .../layout/ProjectActionsButton.tsx | 5 +- .../terminal/TerminalViewport.test.tsx | 57 ++++++++++++++++++- .../components/terminal/TerminalViewport.tsx | 50 ++++++++++++++-- .../components/views/TerminalView.test.tsx | 3 +- .../ui/src/components/views/TerminalView.tsx | 13 ++++- packages/ui/src/lib/api/types.ts | 3 + packages/ui/src/lib/terminalApi.test.ts | 32 +++++++++++ packages/ui/src/lib/terminalApi.ts | 31 +++++++++- .../ui/src/stores/useTerminalStore.test.ts | 14 +++++ packages/ui/src/stores/useTerminalStore.ts | 22 +++++-- .../web/server/lib/terminal/DOCUMENTATION.md | 2 +- packages/web/server/lib/terminal/runtime.js | 4 ++ .../web/server/lib/terminal/runtime.test.js | 13 ++++- patches/ghostty-web+0.4.0.patch | 48 ---------------- 14 files changed, 227 insertions(+), 70 deletions(-) delete mode 100644 patches/ghostty-web+0.4.0.patch diff --git a/packages/ui/src/components/layout/ProjectActionsButton.tsx b/packages/ui/src/components/layout/ProjectActionsButton.tsx index fd611469..c9422b5e 100644 --- a/packages/ui/src/components/layout/ProjectActionsButton.tsx +++ b/packages/ui/src/components/layout/ProjectActionsButton.tsx @@ -17,6 +17,7 @@ import { useDeviceInfo } from '@/lib/device'; import { isDesktopShell } from '@/lib/desktop'; import { useUIStore } from '@/stores/useUIStore'; import { useTerminalStore } from '@/stores/useTerminalStore'; +import { terminalSnapshotSize } from '@/lib/terminalApi'; import { extractAnnouncedUrls, extractProjectActionUrl } from '@/lib/terminalPreview'; import { setAnnouncedDevServers } from '@/lib/browser/announcedServers'; import { useThemeSystem } from '@/contexts/useThemeSystem'; @@ -641,7 +642,7 @@ export const ProjectActionsButton = ({ onEvent: (event) => { if (!matchesActionExecution(tabDirectory, tab.id, currentExecutionId)) return; if (event.type === 'snapshot') { - useTerminalStore.getState().replaceBuffer(tabDirectory, tab.id, event.data ?? '', event.sequence ?? 0); + useTerminalStore.getState().replaceBuffer(tabDirectory, tab.id, event.data ?? '', event.sequence ?? 0, terminalSnapshotSize(event)); if (event.status === 'running') { useTerminalStore.getState().setTabLifecycle(tabDirectory, tab.id, 'running', { expectedExecutionId: currentExecutionId }); } @@ -851,7 +852,7 @@ export const ProjectActionsButton = ({ if (!matchesActionExecution(executionDirectory, tabId, adoptedExecutionId)) return; if (event.purpose?.type === 'project-action' && event.purpose.executionId !== adoptedExecutionId) return; if (event.type === 'snapshot') { - useTerminalStore.getState().replaceBuffer(executionDirectory, tabId, event.data ?? '', event.sequence ?? 0); + useTerminalStore.getState().replaceBuffer(executionDirectory, tabId, event.data ?? '', event.sequence ?? 0, terminalSnapshotSize(event)); useTerminalStore.getState().setConnecting(executionDirectory, tabId, false, { expectedExecutionId: adoptedExecutionId }); if (event.purpose?.type === 'project-action') { useTerminalStore.getState().setTabPurpose(executionDirectory, tabId, { type: 'project-action', actionId: event.purpose.actionId, executionId: event.purpose.executionId }); diff --git a/packages/ui/src/components/terminal/TerminalViewport.test.tsx b/packages/ui/src/components/terminal/TerminalViewport.test.tsx index b7f83b7c..7ee8e582 100644 --- a/packages/ui/src/components/terminal/TerminalViewport.test.tsx +++ b/packages/ui/src/components/terminal/TerminalViewport.test.tsx @@ -5,15 +5,21 @@ import { Window } from 'happy-dom'; import { useTerminalStore, type TerminalChunk } from '@/stores/useTerminalStore'; -const terminalEvents: Array<{ type: 'write'; data: string } | { type: 'reset' }> = []; +type TerminalEvent = + | { type: 'write'; data: string } + | { type: 'reset' } + | { type: 'resize'; cols: number; rows: number }; +const terminalEvents: TerminalEvent[] = []; class GhosttyTerminalDouble { public options: { cursorBlink: boolean }; public cols = 80; public rows = 24; - constructor(options: { cursorBlink?: boolean }) { + constructor(options: { cursorBlink?: boolean; cols?: number; rows?: number }) { this.options = { cursorBlink: options.cursorBlink ?? false }; + this.cols = options.cols ?? 80; + this.rows = options.rows ?? 24; } loadAddon() {} @@ -25,6 +31,11 @@ class GhosttyTerminalDouble { terminalEvents.push({ type: 'write', data }); callback?.(); } + resize(cols: number, rows: number) { + this.cols = cols; + this.rows = rows; + terminalEvents.push({ type: 'resize', cols, rows }); + } reset() { terminalEvents.push({ type: 'reset' }); } @@ -252,4 +263,46 @@ describe('TerminalViewport chunk replay integration', () => { expect(terminalEvents.filter((event) => event.type === 'write' && event.data === replacementReplayPayload)).toHaveLength(1); expect(terminalEvents.filter((event) => event.type === 'write' && event.data === 'tail-live\n')).toHaveLength(1); }); + + test('would fail if snapshot history drawn for another PTY size were replayed at the fitted size', async () => { + // A zsh prompt drawn for a 94-column PTY: the `%` end-of-line mark plus + // padding fills exactly one 94-column row. Written into an 80-column + // emulator it wraps and the mark survives as a stray fragment. + const history = `%${' '.repeat(93)}\r \r~ ❯ `; + const chunks: TerminalChunk[] = [ + { id: 1, data: history, byteLength: history.length, size: { cols: 94, rows: 56 } }, + { id: 2, data: 'live\n', byteLength: 5 }, + ]; + + await renderViewport(root, chunks); + await flushGhosttyLoad(); + + // Default-background resets inside the history are rewritten before the + // write, so identify the history write by the prompt it carries. + const relevant = terminalEvents + .filter((event) => event.type === 'resize' || (event.type === 'write' && (event.data.includes('~ ❯') || event.data === 'live\n'))) + .map((event) => (event.type === 'write' && event.data.includes('~ ❯') ? { type: 'write', data: 'history' } : event)); + expect(relevant).toEqual([ + { type: 'resize', cols: 94, rows: 56 }, + { type: 'write', data: 'history' }, + { type: 'resize', cols: 80, rows: 24 }, + { type: 'write', data: 'live\n' }, + ]); + + terminalEvents.length = 0; + await renderViewport(root, [...chunks, { id: 3, data: 'more\n', byteLength: 5 }]); + expect(terminalEvents).toEqual([{ type: 'write', data: 'more\n' }]); + }); + + test('would fail if a snapshot drawn at the fitted size still bounced the emulator through a resize', async () => { + const chunks: TerminalChunk[] = [ + { id: 1, data: 'prompt ❯ ', byteLength: 11, size: { cols: 80, rows: 24 } }, + ]; + + await renderViewport(root, chunks); + await flushGhosttyLoad(); + + expect(terminalEvents.filter((event) => event.type === 'resize')).toHaveLength(0); + expect(replayWriteEvents(['prompt ❯ '])).toEqual([{ type: 'write', data: 'prompt ❯ ' }]); + }); }); diff --git a/packages/ui/src/components/terminal/TerminalViewport.tsx b/packages/ui/src/components/terminal/TerminalViewport.tsx index f5342a3f..436f141c 100644 --- a/packages/ui/src/components/terminal/TerminalViewport.tsx +++ b/packages/ui/src/components/terminal/TerminalViewport.tsx @@ -97,7 +97,14 @@ type Props = { sessionKey: string; chunks: TerminalChunk[]; onInput: (data: string) => void; + /** Fitted size: the emulator has this size, so the PTY should follow. */ onResize: (cols: number, rows: number) => void; + /** + * Size estimated from the container before Ghostty has measured anything. + * Good enough to spawn a shell early, not authoritative: an existing PTY + * must not be resized to it. Falls back to `onResize` when omitted. + */ + onProvisionalSize?: (cols: number, rows: number) => void; theme: TerminalTheme; monoFont: MonoFontOption; fontFamily: string; @@ -109,7 +116,7 @@ type Props = { }; const TerminalViewport = React.forwardRef(({ - sessionKey, chunks, onInput, onResize, theme, monoFont, fontFamily, fontSize, className, + sessionKey, chunks, onInput, onResize, onProvisionalSize, theme, monoFont, fontFamily, fontSize, className, enableTouchScroll = false, autoFocus = true, isVisible = true, }, ref) => { const containerRef = React.useRef(null); @@ -117,6 +124,7 @@ const TerminalViewport = React.forwardRef(({ const fitRef = React.useRef(null); const inputRef = React.useRef(onInput); const resizeRef = React.useRef(onResize); + const provisionalSizeCallbackRef = React.useRef(onProvisionalSize); const lastSizeRef = React.useRef(null); const provisionalSizeRef = React.useRef(null); const lastChunkRef = React.useRef(null); @@ -133,6 +141,7 @@ const TerminalViewport = React.forwardRef(({ const [rendererGeneration, setRendererGeneration] = React.useState(0); inputRef.current = onInput; resizeRef.current = onResize; + provisionalSizeCallbackRef.current = onProvisionalSize; visibleRef.current = isVisible; safeResetRef.current = getGhosttySafeResetSequence(theme.background); @@ -141,7 +150,7 @@ const TerminalViewport = React.forwardRef(({ if (!container) return; const size = getProvisionalTerminalSize(container, fontFamily, fontSize); provisionalSizeRef.current = size; - if (size) resizeRef.current(size.cols, size.rows); + if (size) (provisionalSizeCallbackRef.current ?? resizeRef.current)(size.cols, size.rows); }, [fontFamily, fontSize]); const fit = React.useCallback(() => { @@ -327,18 +336,51 @@ const TerminalViewport = React.forwardRef(({ terminal.options.cursorBlink = isVisible && document.hasFocus() && container.contains(document.activeElement); }, [isVisible, ready]); + /** + * Snapshot history was laid out by the shell for the PTY size recorded on the + * chunk. Writing it into an emulator of another width wraps or joins lines the + * shell never wrapped, and the shell's later SIGWINCH redraw only repaints + * from its own cursor row down, so the stray fragments stay on screen. Replay + * such a chunk at its own size and let the emulator reflow back to the fitted + * size; a subsequent PTY resize (when the sizes differ) makes the shell redraw + * on top of a consistent screen. + * + * Only valid while nothing is queued: the write must not overtake bytes that + * are still waiting for the emulator. + */ + const writeReplayAtDrawnSize = React.useCallback((terminal: GhosttyTerminal, chunk: TerminalChunk): boolean => { + if (!chunk.size || writingRef.current || writeQueueRef.current) return false; + const rewritten = rewriteGhosttyDefaultBackgroundResets( + chunk.replayData ?? chunk.data, + outputRewriteCarryRef.current, + safeResetRef.current, + ); + outputRewriteCarryRef.current = rewritten.carry; + if (!rewritten.data) return true; + const fitted = { cols: terminal.cols, rows: terminal.rows }; + const resizeForReplay = chunk.size.cols !== fitted.cols || chunk.size.rows !== fitted.rows; + if (resizeForReplay) terminal.resize(chunk.size.cols, chunk.size.rows); + try { + terminal.write(rewritten.data); + } finally { + if (resizeForReplay) terminal.resize(fitted.cols, fitted.rows); + } + return true; + }, []); + React.useEffect(() => { const terminal = terminalRef.current; if (!terminal) return; const { reset, replay, pending } = selectTerminalChunkReplay(chunks, lastChunkRef.current); if (reset) recreateRenderer(); if (pending.length === 0) return; - writeQueueRef.current += pending + const queued = replay && writeReplayAtDrawnSize(terminal, pending[0]) ? pending.slice(1) : pending; + writeQueueRef.current += queued .map((chunk) => replay ? (chunk.replayData ?? chunk.data) : chunk.data) .join(''); lastChunkRef.current = chunks.at(-1)?.id ?? null; flush(); - }, [chunks, flush, ready, recreateRenderer]); + }, [chunks, flush, ready, recreateRenderer, writeReplayAtDrawnSize]); React.useEffect(() => { if (!autoFocus || !isVisible) return; diff --git a/packages/ui/src/components/views/TerminalView.test.tsx b/packages/ui/src/components/views/TerminalView.test.tsx index c340f7e7..54416586 100644 --- a/packages/ui/src/components/views/TerminalView.test.tsx +++ b/packages/ui/src/components/views/TerminalView.test.tsx @@ -376,7 +376,7 @@ describe('TerminalView project action tab indicator', () => { }); connectBehavior = (_sessionId, handlers) => { void Promise.resolve().then(() => { - handlers.onEvent({ type: 'snapshot', data: snapshotData, sequence: 7, status: 'running' }); + handlers.onEvent({ type: 'snapshot', data: snapshotData, sequence: 7, status: 'running', cols: 94, rows: 56 }); }); return { close: () => undefined }; }; @@ -391,6 +391,7 @@ describe('TerminalView project action tab indicator', () => { expect(createSessionCalls.length).toBe(0); expect(readBufferContent('/repo', actionTab.id)).toBe(snapshotData); expect(useTerminalStore.getState().getBuffer('/repo', actionTab.id).lastSequence).toBe(7); + expect(useTerminalStore.getState().getBuffer('/repo', actionTab.id).chunks[0]?.size).toEqual({ cols: 94, rows: 56 }); expect(replaceCount).toBe(1); }); diff --git a/packages/ui/src/components/views/TerminalView.tsx b/packages/ui/src/components/views/TerminalView.tsx index 889303fe..056d3731 100644 --- a/packages/ui/src/components/views/TerminalView.tsx +++ b/packages/ui/src/components/views/TerminalView.tsx @@ -17,7 +17,7 @@ import { Icon } from "@/components/icon/Icon"; import type { IconName } from '@/components/icon/icons'; import { useDeviceInfo } from '@/lib/device'; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; -import { isTerminalCwdMissingError } from '@/lib/terminalApi'; +import { isTerminalCwdMissingError, terminalSnapshotSize } from '@/lib/terminalApi'; import { extractTerminalPreviewUrl, isTerminalPreviewUrlAvailable } from '@/lib/terminalPreview'; import { useI18n } from '@/lib/i18n'; import { PROJECT_ACTION_ICONS } from '@/lib/projectActions'; @@ -321,7 +321,7 @@ export const TerminalView: React.FC = ({ visible, directory } setIsReconnectPending(false); focusTerminalWhenWindowActive(); - replaceBuffer(directory, tabId, event.data ?? '', event.sequence ?? 0); + replaceBuffer(directory, tabId, event.data ?? '', event.sequence ?? 0, terminalSnapshotSize(event)); scanTerminalPreviewOutput(directory, tabId, event.data ?? ''); if (event.status === 'exited') setTabLifecycle(directory, tabId, 'exited'); break; @@ -763,6 +763,14 @@ export const TerminalView: React.FC = ({ visible, directory } [activeModifier, focusTerminalController, isReconnectPending, setActiveModifier, t, terminal] ); + // The estimate only seeds the size a brand-new shell is spawned with. A + // running PTY keeps its size until Ghostty has fitted the viewport for + // real; resizing it to an estimate makes the shell redraw for a width the + // emulator never shows. + const handleProvisionalSize = React.useCallback((cols: number, rows: number) => { + lastViewportSizeRef.current = { cols, rows }; + }, []); + const handleViewportResize = React.useCallback( (cols: number, rows: number) => { const previous = lastViewportSizeRef.current; @@ -1145,6 +1153,7 @@ export const TerminalView: React.FC = ({ visible, directory } chunks={bufferChunks} onInput={handleViewportInput} onResize={handleViewportResize} + onProvisionalSize={handleProvisionalSize} theme={xtermTheme} monoFont={monoFont} fontFamily={resolvedFontStack} diff --git a/packages/ui/src/lib/api/types.ts b/packages/ui/src/lib/api/types.ts index 016ceb58..dc0a40a9 100644 --- a/packages/ui/src/lib/api/types.ts +++ b/packages/ui/src/lib/api/types.ts @@ -45,6 +45,9 @@ export interface TerminalStreamEvent { sequence?: number; data?: string; replayData?: string; + /** PTY size the snapshot history was drawn for; only `snapshot` events carry it. */ + cols?: number; + rows?: number; status?: 'running' | 'exited' | 'error'; exitCode?: number; signal?: number | null; diff --git a/packages/ui/src/lib/terminalApi.test.ts b/packages/ui/src/lib/terminalApi.test.ts index 01141946..166b6b24 100644 --- a/packages/ui/src/lib/terminalApi.test.ts +++ b/packages/ui/src/lib/terminalApi.test.ts @@ -22,6 +22,8 @@ type WireMessage = { v?: number; d?: string; r?: string; + cols?: number; + rows?: number; history?: string; status?: TerminalStreamEvent['status']; exitCode?: number; @@ -120,6 +122,36 @@ describe('terminal transport', () => { } }); + test('carries the PTY size through snapshots, projection replays, and accepted resizes', async () => { + const socket = new FakeSocket(); + const transport = new TerminalTransport({ refreshAuth: async () => '', openSocket: () => socket }); + const sizes: Array<[number | undefined, number | undefined]> = []; + transport.subscribe('term-1', { onEvent: (event) => { if (event.type === 'snapshot') sizes.push([event.cols, event.rows]); } }); + await tick(); + socket.open(); + await tick(); + + socket.emit({ t: 'snapshot', v: 3, s: 'term-1', q: 1, history: 'prompt', status: 'running', cols: 94, rows: 56 }); + await tick(); + expect(sizes).toEqual([[94, 56]]); + + const lateSizes: Array<[number | undefined, number | undefined]> = []; + transport.subscribe('term-1', { onEvent: (event) => { if (event.type === 'snapshot') lateSizes.push([event.cols, event.rows]); } }); + expect(lateSizes).toEqual([[94, 56]]); + + transport.noteResize('term-1', 80, 24); + const afterResize: Array<[number | undefined, number | undefined]> = []; + transport.subscribe('term-1', { onEvent: (event) => { if (event.type === 'snapshot') afterResize.push([event.cols, event.rows]); } }); + expect(afterResize).toEqual([[80, 24]]); + + socket.emit({ t: 'snapshot', v: 3, s: 'term-2', q: 0, history: '', status: 'running' }); + const legacy: Array<[number | undefined, number | undefined]> = []; + transport.subscribe('term-2', { onEvent: (event) => { if (event.type === 'snapshot') legacy.push([event.cols, event.rows]); } }); + await tick(); + expect(legacy).toEqual([]); + transport.dispose(); + }); + test('hydrates simultaneous subscribers and rejects duplicate sequences', async () => { const socket = new FakeSocket(); const transport = new TerminalTransport({ refreshAuth: async () => '', openSocket: () => socket }); diff --git a/packages/ui/src/lib/terminalApi.ts b/packages/ui/src/lib/terminalApi.ts index 2ae2036e..5622fbe5 100644 --- a/packages/ui/src/lib/terminalApi.ts +++ b/packages/ui/src/lib/terminalApi.ts @@ -1,4 +1,5 @@ import type { CreateTerminalOptions, TerminalError, TerminalHandlers, TerminalServerSession, TerminalSession, TerminalSessionPurpose, TerminalShellOption, TerminalStreamEvent } from './api/types'; +import type { TerminalChunkSize } from '@/stores/useTerminalStore'; import { openRuntimeWebSocket } from './relay/runtime-socket'; import type { RelayTunnelSocketMessageEvent, RelayTunnelWebSocket } from './relay/tunnel-client'; import { runtimeFetch } from './runtime-fetch'; @@ -15,6 +16,9 @@ type Subscriber = { handlers: TerminalHandlers; lastSequence: number }; type TerminalProjection = { sequence: number; history: string; + /** Current PTY size: what the server reported at attach, updated by every accepted resize. */ + cols?: number; + rows?: number; status: TerminalStreamEvent['status']; mode?: TerminalSession['mode']; purpose?: TerminalSessionPurpose; @@ -84,6 +88,7 @@ const terminalMessageSchema = z.discriminatedUnion('t', [ z.object({ t: z.literal('snapshot'), s: z.string(), q: z.number().int().nonnegative().default(0), history: z.string().default(''), status: terminalStatusSchema, + cols: z.number().int().positive().optional(), rows: z.number().int().positive().optional(), exitCode: z.number().nullish().transform(value => value ?? undefined), signal: z.number().nullable().optional(), runtime: terminalRuntimeSchema.optional(), ptyBackend: z.string().optional(), ...terminalMessageMetadata, }), @@ -124,6 +129,10 @@ export class TerminalRequestError extends Error { } } +/** The PTY size a snapshot's history was drawn for, when the server reported one. */ +export const terminalSnapshotSize = (event: Pick): TerminalChunkSize | undefined => + event.cols !== undefined && event.rows !== undefined ? { cols: event.cols, rows: event.rows } : undefined; + export const isTerminalCwdMissingError = (error: unknown): boolean => error instanceof TerminalRequestError && error.code === TERMINAL_CWD_MISSING_CODE; @@ -187,7 +196,7 @@ export class TerminalTransport { const projection = this.projections.get(sessionId); if (projection) { subscriber.lastSequence = projection.sequence; - handlers.onEvent({ type: 'snapshot', sequence: projection.sequence, data: projection.history, status: projection.status, mode: projection.mode, purpose: projection.purpose, exitCode: projection.exitCode, signal: projection.signal, runtime: projection.runtime, ptyBackend: projection.ptyBackend }); + handlers.onEvent({ type: 'snapshot', sequence: projection.sequence, data: projection.history, cols: projection.cols, rows: projection.rows, status: projection.status, mode: projection.mode, purpose: projection.purpose, exitCode: projection.exitCode, signal: projection.signal, runtime: projection.runtime, ptyBackend: projection.ptyBackend }); } const socketWasOpen = this.socket?.readyState === SOCKET_OPEN; this.ensureConnected().then(() => { @@ -251,6 +260,17 @@ export class TerminalTransport { this.projections.delete(sessionId); } + /** + * Records a resize the server accepted, so a projection snapshot replayed to + * a later subscriber (tab switch, remount) still names the size the + * terminal's current screen is drawn for. + */ + noteResize(sessionId: string, cols: number, rows: number): void { + const projection = this.projections.get(sessionId); + if (!projection) return; + this.projections.set(sessionId, { ...projection, cols, rows }); + } + private async ensureConnected(): Promise { if (this.disposed) throw new Error('Terminal runtime changed'); if (this.socket?.readyState === SOCKET_OPEN) return; @@ -358,6 +378,8 @@ export class TerminalTransport { const projection: TerminalProjection = { sequence: message.q ?? 0, history: message.history ?? '', + cols: message.cols, + rows: message.rows, status: message.status, mode: message.mode, purpose: message.purpose, @@ -369,7 +391,7 @@ export class TerminalTransport { this.projections.set(message.s, projection); for (const sub of subscribers) { sub.lastSequence = projection.sequence; - sub.handlers.onEvent({ type: 'snapshot', sequence: projection.sequence, data: projection.history, status: projection.status, mode: projection.mode, purpose: projection.purpose, exitCode: projection.exitCode, signal: projection.signal, runtime: projection.runtime, ptyBackend: projection.ptyBackend }); + sub.handlers.onEvent({ type: 'snapshot', sequence: projection.sequence, data: projection.history, cols: projection.cols, rows: projection.rows, status: projection.status, mode: projection.mode, purpose: projection.purpose, exitCode: projection.exitCode, signal: projection.signal, runtime: projection.runtime, ptyBackend: projection.ptyBackend }); } return; } @@ -492,7 +514,10 @@ async function command(path: string, method: string, body?: unknown): Promise { await command(`/api/terminal/${sessionId}/resize`, 'POST', { cols, rows }); } +export async function resizeTerminal(sessionId: string, cols: number, rows: number): Promise { + await command(`/api/terminal/${sessionId}/resize`, 'POST', { cols, rows }); + transport.noteResize(sessionId, cols, rows); +} export async function updateTerminalAppearance(sessionId: string, appearance: Pick): Promise { await command(`/api/terminal/${sessionId}/appearance`, 'POST', appearance); } export async function closeTerminal(sessionId: string): Promise { await command(`/api/terminal/${sessionId}`, 'DELETE'); transport.forget(sessionId); } export async function restartTerminalSession(currentSessionId: string, options: CreateTerminalOptions): Promise { return (await command(`/api/terminal/${currentSessionId}/restart`, 'POST', options)).json() as Promise; } diff --git a/packages/ui/src/stores/useTerminalStore.test.ts b/packages/ui/src/stores/useTerminalStore.test.ts index bcbaf37e..df7398cb 100644 --- a/packages/ui/src/stores/useTerminalStore.test.ts +++ b/packages/ui/src/stores/useTerminalStore.test.ts @@ -423,6 +423,20 @@ describe('terminal state reconciliation', () => { expect(buffer(tabId).chunks).toBe(previous); }); + test('records the PTY size a snapshot was drawn for and treats a size change as a new snapshot', () => { + const tabId = setup(); + useTerminalStore.getState().replaceBuffer('/repo', tabId, 'prompt', 8, { cols: 94, rows: 56 }); + expect(buffer(tabId).chunks[0].size).toEqual({ cols: 94, rows: 56 }); + const previous = buffer(tabId).chunks; + useTerminalStore.getState().replaceBuffer('/repo', tabId, 'prompt', 8, { cols: 94, rows: 56 }); + expect(buffer(tabId).chunks).toBe(previous); + useTerminalStore.getState().replaceBuffer('/repo', tabId, 'prompt', 8, { cols: 80, rows: 24 }); + expect(buffer(tabId).chunks).not.toBe(previous); + expect(buffer(tabId).chunks[0].size).toEqual({ cols: 80, rows: 24 }); + useTerminalStore.getState().appendToBuffer('/repo', tabId, ' live', 9); + expect(buffer(tabId).chunks[1].size).toBe(undefined); + }); + test('caps multibyte scrollback by UTF-8 bytes', () => { const tabId = setup(); useTerminalStore.getState().appendToBuffer('/repo', tabId, '界'.repeat(200_000), 1); diff --git a/packages/ui/src/stores/useTerminalStore.ts b/packages/ui/src/stores/useTerminalStore.ts index 689ae912..d0723b0d 100644 --- a/packages/ui/src/stores/useTerminalStore.ts +++ b/packages/ui/src/stores/useTerminalStore.ts @@ -7,11 +7,20 @@ import { getSafeSessionStorage } from '@/stores/utils/safeStorage'; import type { TerminalServerSession } from '@/lib/api/types'; import { normalizeTerminalDirectory } from '@/lib/pathNormalization'; +export type TerminalChunkSize = { cols: number; rows: number }; + export interface TerminalChunk { id: number; data: string; replayData?: string; byteLength: number; + /** + * PTY size this chunk was drawn for. Only snapshot history carries it: the + * viewport replays such a chunk at this size and then re-fits, because + * shell output laid out for one width turns into stray fragments when it is + * written into an emulator of another width. + */ + size?: TerminalChunkSize; } /** @@ -99,7 +108,7 @@ interface TerminalStore { setTabSessionId: (directory: string, tabId: string, sessionId: string | null, options?: { expectedExecutionId?: string | null }) => void; setTabLifecycle: (directory: string, tabId: string, lifecycle: TerminalTabLifecycle, options?: { expectedExecutionId?: string | null }) => void; setConnecting: (directory: string, tabId: string, isConnecting: boolean, options?: { expectedExecutionId?: string | null }) => void; - replaceBuffer: (directory: string, tabId: string, content: string, sequence: number) => void; + replaceBuffer: (directory: string, tabId: string, content: string, sequence: number, size?: TerminalChunkSize) => void; appendToBuffer: (directory: string, tabId: string, chunk: string, sequence?: number, replayData?: string) => void; setTabPreviewUrl: (directory: string, tabId: string, url: string | null, options?: { locked?: boolean; autoOpened?: boolean; expectedExecutionId?: string | null }) => void; markPreviewAutoOpened: (directory: string, tabId: string) => void; @@ -976,7 +985,7 @@ export const useTerminalStore = create()( }); }, - replaceBuffer: (directory: string, tabId: string, content: string, sequence: number) => { + replaceBuffer: (directory: string, tabId: string, content: string, sequence: number, size?: TerminalChunkSize) => { const key = normalizeDirectory(directory); set((state) => { const existing = state.sessions.get(key); @@ -985,17 +994,22 @@ export const useTerminalStore = create()( const buffer = state.buffers.get(entryKey) ?? EMPTY_TERMINAL_BUFFER; if (buffer.lastSequence > sequence) return state; const retained = trimToBufferLimit(content); + const previousSize = buffer.chunks[0]?.size; if ( buffer.lastSequence === sequence && buffer.byteLength === retained.byteLength && - buffer.chunks.map((chunk) => chunk.data).join('') === retained.text + buffer.chunks.map((chunk) => chunk.data).join('') === retained.text && + previousSize?.cols === size?.cols && + previousSize?.rows === size?.rows ) { return state; } const chunkId = state.nextChunkId; const buffers = new Map(state.buffers); buffers.set(entryKey, { - chunks: retained.text ? [{ id: chunkId, data: retained.text, byteLength: retained.byteLength }] : [], + chunks: retained.text + ? [{ id: chunkId, data: retained.text, byteLength: retained.byteLength, ...(size ? { size } : {}) }] + : [], byteLength: retained.byteLength, lastSequence: sequence, }); diff --git a/packages/web/server/lib/terminal/DOCUMENTATION.md b/packages/web/server/lib/terminal/DOCUMENTATION.md index aabf863a..4ecd3b24 100644 --- a/packages/web/server/lib/terminal/DOCUMENTATION.md +++ b/packages/web/server/lib/terminal/DOCUMENTATION.md @@ -9,7 +9,7 @@ `/api/terminal/ws` is the only terminal data transport. It uses v3 binary JSON control frames and is opened through `openRuntimeWebSocket`, preserving direct, Electron proxy, URL-token authentication, and private-relay routing. - `attach` registers a connection for one terminal. One socket may attach to many terminals. -- Every attach and reconnect begins with an authoritative `snapshot` containing bounded history and the current sequence. +- Every attach and reconnect begins with an authoritative `snapshot` containing bounded history, the current sequence, and the PTY `cols`/`rows` the history was drawn for. The client replays the history at that size before fitting its viewport; replaying shell output at another width leaves stray fragments that the shell's own SIGWINCH redraw never clears. - A current socket that closes or errors before its initial `open` invalidates its URL-scoped auth token before retrying, so retries mint a fresh token instead of backing off against a rejected upgrade. Hidden or offline clients wait 60 seconds and wake promptly on visibility/online recovery. - `output`, `exit`, and `restarted` carry monotonically increasing per-terminal sequences. Output carries raw live bytes plus replay-safe bytes with terminal query exchanges removed. - Attach registers before capturing the snapshot, buffers concurrent events, drops events represented by the snapshot sequence, then enters live delivery. diff --git a/packages/web/server/lib/terminal/runtime.js b/packages/web/server/lib/terminal/runtime.js index 6f37b6bc..026e58ba 100644 --- a/packages/web/server/lib/terminal/runtime.js +++ b/packages/web/server/lib/terminal/runtime.js @@ -184,6 +184,10 @@ export function createTerminalRuntime({ const snapshot = (session) => ({ t: 'snapshot', v: 3, s: session.id, q: session.sequence, history: session.history, + // The PTY size the history was drawn for: a client replays history at this + // size before fitting its own viewport, so shell output wrapped for one + // width never gets re-laid-out at another. + cols: session.cols, rows: session.rows, status: session.status, exitCode: session.exitCode, signal: session.signal, mode: session.mode ?? INTERACTIVE_TERMINAL_MODE, purpose: getSessionPurpose(session), runtime, ptyBackend: session.backend, diff --git a/packages/web/server/lib/terminal/runtime.test.js b/packages/web/server/lib/terminal/runtime.test.js index 8793d59e..456ff61c 100644 --- a/packages/web/server/lib/terminal/runtime.test.js +++ b/packages/web/server/lib/terminal/runtime.test.js @@ -682,8 +682,8 @@ describe('terminal runtime', () => { sockets.push(first.socket); first.socket.send(createTerminalWsControlFrame({ t: 'attach', v: 3, s: 'term-live' })); first.socket.send(createTerminalWsControlFrame({ t: 'attach', v: 3, s: 'term-second' })); - expect(await first.next('snapshot', 'term-live')).toMatchObject({ s: 'term-live', q: 0, history: '', status: 'running' }); - expect(await first.next('snapshot', 'term-second')).toMatchObject({ s: 'term-second', q: 0, history: '', status: 'running' }); + expect(await first.next('snapshot', 'term-live')).toMatchObject({ s: 'term-live', q: 0, history: '', status: 'running', cols: 80, rows: 24 }); + expect(await first.next('snapshot', 'term-second')).toMatchObject({ s: 'term-second', q: 0, history: '', status: 'running', cols: 80, rows: 24 }); first.socket.send(createTerminalWsControlFrame({ t: 'write', v: 3, s: 'term-live', d: 'echo ok\r' })); first.socket.send(createTerminalWsControlFrame({ t: 'write', v: 3, s: 'term-second', d: 'pwd\r' })); first.socket.send(createTerminalWsControlFrame({ t: 'write', v: 3, s: 'term-live', d: 'echo next\r' })); @@ -707,10 +707,17 @@ describe('terminal runtime', () => { expect(secondClosed.status).toBe(200); first.socket.close(); + // A reconnecting client replays history at the size the PTY currently has. + const resized = await fetch(`${base}/api/terminal/term-live/resize`, { + method: 'POST', headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ cols: 120, rows: 40 }), + }); + expect(resized.status).toBe(200); + const second = await openTerminalSocket(socketUrl); sockets.push(second.socket); second.socket.send(createTerminalWsControlFrame({ t: 'attach', v: 3, s: 'term-live' })); - expect(await second.next('snapshot')).toMatchObject({ s: 'term-live', q: 2, history: 'ok\r\n', status: 'running' }); + expect(await second.next('snapshot')).toMatchObject({ s: 'term-live', q: 2, history: 'ok\r\n', status: 'running', cols: 120, rows: 40 }); processes[0].emitExit(7); expect(await second.next('exit')).toMatchObject({ s: 'term-live', q: 3, exitCode: 7 }); diff --git a/patches/ghostty-web+0.4.0.patch b/patches/ghostty-web+0.4.0.patch deleted file mode 100644 index 7c6becb4..00000000 --- a/patches/ghostty-web+0.4.0.patch +++ /dev/null @@ -1,48 +0,0 @@ -diff --git a/node_modules/ghostty-web/dist/ghostty-web.js b/node_modules/ghostty-web/dist/ghostty-web.js -index 0000000000000000000000000000000000000000..1111111111111111111111111111111111111111 100644 ---- a/node_modules/ghostty-web/dist/ghostty-web.js -+++ b/node_modules/ghostty-web/dist/ghostty-web.js -@@ -1538,1 +1538,1 @@ -- if (A.grapheme_len > 0 && ((k = this.currentBuffer) != null && k.getGraphemeString) ? N = this.currentBuffer.getGraphemeString(g, B) : N = String.fromCodePoint(A.codepoint || 32), this.ctx.fillText(N, w, s), A.flags & e.FAINT && (this.ctx.globalAlpha = 1), A.flags & e.UNDERLINE) { -+ if (A.grapheme_len > 0 && ((k = this.currentBuffer) != null && k.getGraphemeString) ? N = this.currentBuffer.getGraphemeString(g, B) : N = A.codepoint == null || A.codepoint <= 0 || A.codepoint > 1114111 || A.codepoint >= 55296 && A.codepoint <= 57343 ? " " : String.fromCodePoint(A.codepoint), this.renderBlockChar(A.codepoint || 32, E, C, I) || this.ctx.fillText(N, w, s), A.flags & e.FAINT && (this.ctx.globalAlpha = 1), A.flags & e.UNDERLINE) { -@@ -1557,5 +1557,40 @@ - } -+ renderBlockChar(A, B, g, E) { -+ const C = this.metrics.height, I = E / 2, D = C / 2; -+ switch (A) { -+ case 9600: this.ctx.fillRect(B, g, E, D); return !0; -+ case 9601: this.ctx.fillRect(B, g + C * 7 / 8, E, C / 8); return !0; -+ case 9602: this.ctx.fillRect(B, g + C * 3 / 4, E, C / 4); return !0; -+ case 9603: this.ctx.fillRect(B, g + C * 5 / 8, E, C * 3 / 8); return !0; -+ case 9604: this.ctx.fillRect(B, g + D, E, D); return !0; -+ case 9605: this.ctx.fillRect(B, g + C * 3 / 8, E, C * 5 / 8); return !0; -+ case 9606: this.ctx.fillRect(B, g + C / 4, E, C * 3 / 4); return !0; -+ case 9607: this.ctx.fillRect(B, g + C / 8, E, C * 7 / 8); return !0; -+ case 9608: this.ctx.fillRect(B, g, E, C); return !0; -+ case 9609: this.ctx.fillRect(B, g, E * 7 / 8, C); return !0; -+ case 9610: this.ctx.fillRect(B, g, E * 3 / 4, C); return !0; -+ case 9611: this.ctx.fillRect(B, g, E * 5 / 8, C); return !0; -+ case 9612: this.ctx.fillRect(B, g, I, C); return !0; -+ case 9613: this.ctx.fillRect(B, g, E * 3 / 8, C); return !0; -+ case 9614: this.ctx.fillRect(B, g, E / 4, C); return !0; -+ case 9615: this.ctx.fillRect(B, g, E / 8, C); return !0; -+ case 9616: this.ctx.fillRect(B + I, g, I, C); return !0; -+ case 9620: this.ctx.fillRect(B, g, E, C / 8); return !0; -+ case 9621: this.ctx.fillRect(B + E * 7 / 8, g, E / 8, C); return !0; -+ case 9622: this.ctx.fillRect(B, g + D, I, D); return !0; -+ case 9623: this.ctx.fillRect(B + I, g + D, I, D); return !0; -+ case 9624: this.ctx.fillRect(B, g, I, D); return !0; -+ case 9625: this.ctx.fillRect(B, g, I, C); this.ctx.fillRect(B + I, g + D, I, D); return !0; -+ case 9626: this.ctx.fillRect(B, g, I, D); this.ctx.fillRect(B + I, g + D, I, D); return !0; -+ case 9627: this.ctx.fillRect(B, g, E, D); this.ctx.fillRect(B, g + D, I, D); return !0; -+ case 9628: this.ctx.fillRect(B, g, E, D); this.ctx.fillRect(B + I, g + D, I, D); return !0; -+ case 9629: this.ctx.fillRect(B + I, g, I, D); return !0; -+ case 9630: this.ctx.fillRect(B + I, g, I, D); this.ctx.fillRect(B, g + D, I, D); return !0; -+ case 9631: this.ctx.fillRect(B + I, g, I, C); this.ctx.fillRect(B, g + D, I, D); return !0; -+ default: return !1; -+ } -+ } - /** - * Render cursor - */ - renderCursor(A, B) { From eb6f7b09046fec84fdbd4f8a026d6a016ab3dbdb Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sun, 6 Sep 2026 02:14:18 +0300 Subject: [PATCH 05/94] fix(terminal): update ghostty-web to a build that clears recycled rows ghostty-web 0.4.0 hands rows that scroll into view out of recycled WASM page memory without clearing them, so after a tab or project switch the new emulator showed the previous terminal's text (upstream #138). The fix landed only in prereleases, so pin 0.4.0-next.20 and carry the local block-glyph rendering patch over to the new dist file. Verified in a production build: creating an emulator after disposing a full one no longer exposes its rows, and switching between two projects with live output in each keeps every terminal's content to itself. --- bun.lock | 16 +++--- package.json | 2 +- packages/ui/package.json | 2 +- packages/web/package.json | 2 +- .../ghostty-web+0.4.0-next.20.g1858a59.patch | 55 +++++++++++++++++++ 5 files changed, 66 insertions(+), 11 deletions(-) create mode 100644 patches/ghostty-web+0.4.0-next.20.g1858a59.patch diff --git a/bun.lock b/bun.lock index c5468b47..7cf18484 100644 --- a/bun.lock +++ b/bun.lock @@ -47,7 +47,7 @@ "clsx": "^2.1.1", "cmdk": "^1.1.1", "express": "^5.1.0", - "ghostty-web": "0.4.0", + "ghostty-web": "0.4.0-next.20.g1858a59", "http-proxy-middleware": "^3.0.5", "next-themes": "^0.4.6", "node-pty": "1.2.0-beta.12", @@ -97,7 +97,7 @@ }, "packages/electron": { "name": "@openchamber/electron", - "version": "1.22.1", + "version": "1.22.2", "dependencies": { "@openchamber/web": "workspace:*", "electron-context-menu": "^4.1.2", @@ -134,7 +134,7 @@ }, "packages/ui": { "name": "@openchamber/ui", - "version": "1.22.1", + "version": "1.22.2", "dependencies": { "@aparajita/capacitor-secure-storage": "^8.0.0", "@base-ui/react": "^1.4.0", @@ -185,7 +185,7 @@ "express": "^5.1.0", "fflate": "^0.8.3", "fuse.js": "^7.1.0", - "ghostty-web": "^0.4.0", + "ghostty-web": "0.4.0-next.20.g1858a59", "heic2any": "^0.0.4", "html-to-image": "^1.11.13", "http-proxy-middleware": "^3.0.5", @@ -241,7 +241,7 @@ }, "packages/vscode": { "name": "openchamber", - "version": "1.22.1", + "version": "1.22.2", "dependencies": { "@openchamber/ui": "workspace:*", "@opencode-ai/sdk": "1.18.29", @@ -264,7 +264,7 @@ }, "packages/web": { "name": "@openchamber/web", - "version": "1.22.1", + "version": "1.22.2", "bin": { "openchamber": "./bin/cli.js", }, @@ -323,7 +323,7 @@ "eslint": "^9.33.0", "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.5.0", - "ghostty-web": "0.4.0", + "ghostty-web": "0.4.0-next.20.g1858a59", "globals": "^16.3.0", "next-themes": "^0.4.6", "nodemon": "^3.1.7", @@ -2122,7 +2122,7 @@ "get-tsconfig": ["get-tsconfig@4.13.6", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw=="], - "ghostty-web": ["ghostty-web@0.4.0", "", {}, "sha512-0puDBik2qapbD/QQBW9o5ZHfXnZBqZWx/ctBiVtKZ6ZLds4NYb+wZuw1cRLXZk9zYovIQ908z3rvFhexAvc5Hg=="], + "ghostty-web": ["ghostty-web@0.4.0-next.20.g1858a59", "", {}, "sha512-NXA9H3IJlx+DGJukXbOPQWFkigYdAatTqkoIvM8tvhfbaYoDf3gGKxXLLyXtLYOBt5qzGdEZa294TU36gULjVg=="], "github-from-package": ["github-from-package@0.0.0", "", {}, "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw=="], diff --git a/package.json b/package.json index d7d87dd2..94e6d6f1 100644 --- a/package.json +++ b/package.json @@ -135,7 +135,7 @@ "clsx": "^2.1.1", "cmdk": "^1.1.1", "express": "^5.1.0", - "ghostty-web": "0.4.0", + "ghostty-web": "0.4.0-next.20.g1858a59", "http-proxy-middleware": "^3.0.5", "next-themes": "^0.4.6", "node-pty": "1.2.0-beta.12", diff --git a/packages/ui/package.json b/packages/ui/package.json index 290a1c07..364a9f83 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -61,7 +61,7 @@ "express": "^5.1.0", "fflate": "^0.8.3", "fuse.js": "^7.1.0", - "ghostty-web": "^0.4.0", + "ghostty-web": "0.4.0-next.20.g1858a59", "heic2any": "^0.0.4", "html-to-image": "^1.11.13", "http-proxy-middleware": "^3.0.5", diff --git a/packages/web/package.json b/packages/web/package.json index e9849c17..fedb1052 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -77,7 +77,7 @@ "eslint": "^9.33.0", "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.5.0", - "ghostty-web": "0.4.0", + "ghostty-web": "0.4.0-next.20.g1858a59", "globals": "^16.3.0", "next-themes": "^0.4.6", "nodemon": "^3.1.7", diff --git a/patches/ghostty-web+0.4.0-next.20.g1858a59.patch b/patches/ghostty-web+0.4.0-next.20.g1858a59.patch new file mode 100644 index 00000000..9132eaa1 --- /dev/null +++ b/patches/ghostty-web+0.4.0-next.20.g1858a59.patch @@ -0,0 +1,55 @@ +diff --git a/node_modules/ghostty-web/dist/ghostty-web.js b/node_modules/ghostty-web/dist/ghostty-web.js +index 0000000000000000000000000000000000000000..1111111111111111111111111111111111111111 100644 +--- a/node_modules/ghostty-web/dist/ghostty-web.js ++++ b/node_modules/ghostty-web/dist/ghostty-web.js +@@ -1844,7 +1844,7 @@ + A.flags & G.FAINT && (this.ctx.globalAlpha = 0.5); + const s = C, h = I + this.metrics.baseline; + let N; +- if (A.grapheme_len > 0 && ((k = this.currentBuffer) != null && k.getGraphemeString) ? N = this.currentBuffer.getGraphemeString(g, Q) : N = String.fromCodePoint(A.codepoint || 32), this.ctx.fillText(N, s, h), A.flags & G.FAINT && (this.ctx.globalAlpha = 1), A.flags & G.UNDERLINE) { ++ if (A.grapheme_len > 0 && ((k = this.currentBuffer) != null && k.getGraphemeString) ? N = this.currentBuffer.getGraphemeString(g, Q) : N = A.codepoint == null || A.codepoint <= 0 || A.codepoint > 1114111 || A.codepoint >= 55296 && A.codepoint <= 57343 ? " " : String.fromCodePoint(A.codepoint), this.renderBlockChar(A.codepoint || 32, C, I, D) || this.ctx.fillText(N, s, h), A.flags & G.FAINT && (this.ctx.globalAlpha = 1), A.flags & G.UNDERLINE) { + const t = I + this.metrics.baseline + 2; + this.ctx.strokeStyle = this.ctx.fillStyle, this.ctx.lineWidth = 1, this.ctx.beginPath(), this.ctx.moveTo(C, t), this.ctx.lineTo(C + D, t), this.ctx.stroke(); + } +@@ -1864,6 +1864,41 @@ + } + } + } ++ renderBlockChar(A, B, g, E) { ++ const C = this.metrics.height, I = E / 2, D = C / 2; ++ switch (A) { ++ case 9600: this.ctx.fillRect(B, g, E, D); return !0; ++ case 9601: this.ctx.fillRect(B, g + C * 7 / 8, E, C / 8); return !0; ++ case 9602: this.ctx.fillRect(B, g + C * 3 / 4, E, C / 4); return !0; ++ case 9603: this.ctx.fillRect(B, g + C * 5 / 8, E, C * 3 / 8); return !0; ++ case 9604: this.ctx.fillRect(B, g + D, E, D); return !0; ++ case 9605: this.ctx.fillRect(B, g + C * 3 / 8, E, C * 5 / 8); return !0; ++ case 9606: this.ctx.fillRect(B, g + C / 4, E, C * 3 / 4); return !0; ++ case 9607: this.ctx.fillRect(B, g + C / 8, E, C * 7 / 8); return !0; ++ case 9608: this.ctx.fillRect(B, g, E, C); return !0; ++ case 9609: this.ctx.fillRect(B, g, E * 7 / 8, C); return !0; ++ case 9610: this.ctx.fillRect(B, g, E * 3 / 4, C); return !0; ++ case 9611: this.ctx.fillRect(B, g, E * 5 / 8, C); return !0; ++ case 9612: this.ctx.fillRect(B, g, I, C); return !0; ++ case 9613: this.ctx.fillRect(B, g, E * 3 / 8, C); return !0; ++ case 9614: this.ctx.fillRect(B, g, E / 4, C); return !0; ++ case 9615: this.ctx.fillRect(B, g, E / 8, C); return !0; ++ case 9616: this.ctx.fillRect(B + I, g, I, C); return !0; ++ case 9620: this.ctx.fillRect(B, g, E, C / 8); return !0; ++ case 9621: this.ctx.fillRect(B + E * 7 / 8, g, E / 8, C); return !0; ++ case 9622: this.ctx.fillRect(B, g + D, I, D); return !0; ++ case 9623: this.ctx.fillRect(B + I, g + D, I, D); return !0; ++ case 9624: this.ctx.fillRect(B, g, I, D); return !0; ++ case 9625: this.ctx.fillRect(B, g, I, C); this.ctx.fillRect(B + I, g + D, I, D); return !0; ++ case 9626: this.ctx.fillRect(B, g, I, D); this.ctx.fillRect(B + I, g + D, I, D); return !0; ++ case 9627: this.ctx.fillRect(B, g, E, D); this.ctx.fillRect(B, g + D, I, D); return !0; ++ case 9628: this.ctx.fillRect(B, g, E, D); this.ctx.fillRect(B + I, g + D, I, D); return !0; ++ case 9629: this.ctx.fillRect(B + I, g, I, D); return !0; ++ case 9630: this.ctx.fillRect(B + I, g, I, D); this.ctx.fillRect(B, g + D, I, D); return !0; ++ case 9631: this.ctx.fillRect(B + I, g, I, C); this.ctx.fillRect(B, g + D, I, D); return !0; ++ default: return !1; ++ } ++ } + /** + * Render cursor + */ From 3132d1361acd29bb3786b128ff9f72ee927e6062 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Mon, 7 Sep 2026 02:10:06 +0300 Subject: [PATCH 06/94] fix(sessions): keep missing-worktree relocation manual Remove automatic moves on session activation, terminal failures, and archive restoration while preserving manual moves and worktree deletion. Replace directory listing probes with a stat-only endpoint using Node built-ins, including an isolated module-load regression test for packaged desktop. Validation: focused session, worktree, filesystem, localization, and bridge tests; workspace type-check and lint; web and VS Code builds. Desktop startup and behavior verified by the maintainer. --- .../src/components/session/SessionSidebar.tsx | 6 - .../session/sidebar/DOCUMENTATION.md | 2 +- .../ui/src/components/views/TerminalView.tsx | 15 +- packages/ui/src/lib/i18n/messages/de.ts | 1 - packages/ui/src/lib/i18n/messages/en.ts | 1 - packages/ui/src/lib/i18n/messages/es.ts | 1 - packages/ui/src/lib/i18n/messages/fr.ts | 1 - packages/ui/src/lib/i18n/messages/ja.ts | 1 - packages/ui/src/lib/i18n/messages/ko.ts | 1 - packages/ui/src/lib/i18n/messages/pl.ts | 1 - packages/ui/src/lib/i18n/messages/pt-BR.ts | 1 - packages/ui/src/lib/i18n/messages/tr.ts | 1 - packages/ui/src/lib/i18n/messages/uk.ts | 1 - packages/ui/src/lib/i18n/messages/zh-CN.ts | 1 - packages/ui/src/lib/i18n/messages/zh-TW.ts | 1 - packages/ui/src/lib/opencode/client.test.ts | 17 +- packages/ui/src/lib/opencode/client.ts | 27 +- .../src/lib/worktrees/worktreeManager.test.ts | 19 - .../ui/src/lib/worktrees/worktreeManager.ts | 23 -- packages/ui/src/sync/DOCUMENTATION.md | 49 +-- .../ui/src/sync/__tests__/issue-2039.test.ts | 1 - packages/ui/src/sync/session-actions.test.ts | 374 +----------------- packages/ui/src/sync/session-actions.ts | 154 -------- packages/ui/src/sync/session-ui-store.test.js | 44 +-- packages/ui/src/sync/session-ui-store.ts | 75 +--- packages/vscode/src/DOCUMENTATION.md | 1 + .../src/bridge-localfs-proxy-runtime.test.js | 5 + .../src/bridge-localfs-proxy-runtime.ts | 3 + packages/web/server/lib/fs/DOCUMENTATION.md | 3 + packages/web/server/lib/fs/routes.js | 31 ++ packages/web/server/lib/fs/routes.test.js | 111 +++++- .../web/server/lib/terminal/DOCUMENTATION.md | 2 +- 32 files changed, 211 insertions(+), 763 deletions(-) diff --git a/packages/ui/src/components/session/SessionSidebar.tsx b/packages/ui/src/components/session/SessionSidebar.tsx index 352153ee..48521e5f 100644 --- a/packages/ui/src/components/session/SessionSidebar.tsx +++ b/packages/ui/src/components/session/SessionSidebar.tsx @@ -28,7 +28,6 @@ import { useShallow } from 'zustand/react/shallow'; import { listProjectWorktrees, partitionWorktreesByRegisteredProject, - subscribeWorktreeTopologyChanged, worktreeMapsEqual, } from '@/lib/worktrees/worktreeManager'; import { checkIsGitRepository } from '@/lib/gitApi'; @@ -324,11 +323,6 @@ const SessionSidebarComponent: React.FC = ({ }); }, [isVSCode]); - React.useEffect(() => { - if (isVSCode) return; - return subscribeWorktreeTopologyChanged(() => requestWorktreeDiscovery()); - }, [isVSCode]); - const isDesktopShellRuntime = React.useMemo(() => isDesktopShell(), []); const { isTablet } = useDeviceInfo(); diff --git a/packages/ui/src/components/session/sidebar/DOCUMENTATION.md b/packages/ui/src/components/session/sidebar/DOCUMENTATION.md index f11da0cc..3fdd2981 100644 --- a/packages/ui/src/components/session/sidebar/DOCUMENTATION.md +++ b/packages/ui/src/components/session/sidebar/DOCUMENTATION.md @@ -67,7 +67,7 @@ make every row observe unrelated streaming updates. - Folder membership may contain both a parent session and its descendants. Rendering treats only the highest assigned ancestors as folder roots because their normal session trees already include assigned descendants; persisted membership remains unchanged for cleanup and move semantics. - Sidebar selection holds the clicked row's viewport position across navigation-driven sidebar updates. Wheel or touch input cancels the hold immediately, so programmatic compensation never fights intentional scrolling. - Global session subscriptions are structural: create/delete, title, share, archive, directory, parent, and slug changes invalidate the tree. Recency-only `time.updated` changes do not trigger a rebuild. The separate lifecycle rank invalidates ordering only on `settled ↔ active` transitions, with root sessions ranked among roots and child sessions only among siblings of the same parent. -- A worktree git still registers but whose directory is gone (`prunable` in `git worktree list`) stays in the topology with `worktreeStatus: 'missing'` and a warning icon on its group header. Dropping it would hide every session that lived there, and a hidden session cannot be opened, so it could never be relocated. Opening one of those sessions relocates it to the project root (`recoverMissingSessionDirectory`), and the empty group is removed through the ordinary worktree delete action, which `git worktree remove --force` accepts for a missing directory. Topology refresh stays event-driven: besides `session-created`, the sidebar rediscovers on `subscribeWorktreeTopologyChanged`, which the relocation raises after the server confirmed a directory missing. No idle polling is added. +- A worktree Git still registers but whose directory is gone (`prunable` in `git worktree list`) stays in the topology with `worktreeStatus: 'missing'` and a warning icon on its group header. Its sessions remain accessible for manual movement or archiving through worktree deletion. Opening a session does not move it. The ordinary worktree delete action accepts a missing directory. Topology discovery remains event-driven, including `session-created`, with no idle polling. - Opening the root-session `Move to worktree` submenu force-refreshes the owning project's worktree topology so externally created worktrees appear without a full reload. While that refresh runs, the menu keeps the last known primary/linked topology visible; if the refresh fails, the stale topology remains and the load failure state stays explicit. Failure cleanup never removes or manages an existing destination worktree. - CLI/server-created sessions use the low-frequency OpenChamber control event stream to refresh only the created session directory. The same event retriggers bounded worktree discovery so a newly created external worktree gains ownership without a view reload; it does not re-enable broad session or streaming subscriptions. - Recent membership includes active root sessions immediately even when their last committed `time.updated` falls outside the 48-hour window. Children and archived sessions remain excluded, and inactive roots remain timestamp-based. The active-ID subscription is disabled while the sidebar is hidden and ignores retry/status detail changes, avoiding streaming-frequency rerenders. diff --git a/packages/ui/src/components/views/TerminalView.tsx b/packages/ui/src/components/views/TerminalView.tsx index 056d3731..7f27db70 100644 --- a/packages/ui/src/components/views/TerminalView.tsx +++ b/packages/ui/src/components/views/TerminalView.tsx @@ -17,7 +17,7 @@ import { Icon } from "@/components/icon/Icon"; import type { IconName } from '@/components/icon/icons'; import { useDeviceInfo } from '@/lib/device'; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; -import { isTerminalCwdMissingError, terminalSnapshotSize } from '@/lib/terminalApi'; +import { terminalSnapshotSize } from '@/lib/terminalApi'; import { extractTerminalPreviewUrl, isTerminalPreviewUrlAvailable } from '@/lib/terminalPreview'; import { useI18n } from '@/lib/i18n'; import { PROJECT_ACTION_ICONS } from '@/lib/projectActions'; @@ -40,14 +40,6 @@ const resolveTabIconName = (iconKey: string | null): IconName => { export const TerminalView: React.FC = ({ visible, directory }) => { const { t } = useI18n(); const { terminal, runtime } = useRuntimeAPIs(); - // The server rejects a working directory that no longer exists (a worktree - // deleted outside OpenChamber). The session is what is stranded, not the - // terminal: relocating it to its project changes the effective directory, - // and this view then starts a terminal there on its own. - const recoverCurrentSessionDirectory = React.useCallback(() => { - const sessionId = useSessionUIStore.getState().currentSessionId; - if (sessionId) void useSessionUIStore.getState().recoverMissingSessionDirectory(sessionId); - }, []); const { currentTheme } = useThemeSystem(); const terminalAppearanceRef = React.useRef<{ themeMode: 'light' | 'dark'; terminalBackground: string; terminalForeground: string }>({ themeMode: 'dark', terminalBackground: '', terminalForeground: '' }); terminalAppearanceRef.current = { themeMode: currentTheme.metadata.variant === 'light' ? 'light' : 'dark', terminalBackground: currentTheme.colors.surface.background, terminalForeground: currentTheme.colors.syntax.base.foreground }; @@ -546,7 +538,6 @@ export const TerminalView: React.FC = ({ visible, directory } // this tab stopped owning the request; use current store // ownership so a rejected create cannot leave it spinning. if (directoryRef.current !== directory || activeTabIdRef.current !== tabId) return; - if (isTerminalCwdMissingError(error)) recoverCurrentSessionDirectory(); setConnectionError( error instanceof Error ? error.message @@ -590,7 +581,6 @@ export const TerminalView: React.FC = ({ visible, directory } setTabSessionId, startStream, disconnectStream, - recoverCurrentSessionDirectory, t, terminal, terminalLoginShell, @@ -654,7 +644,6 @@ export const TerminalView: React.FC = ({ visible, directory } || directoryRef.current !== terminalDirectory || activeTabIdRef.current !== tabId ) return; - if (isTerminalCwdMissingError(error)) recoverCurrentSessionDirectory(); setConnectionError( error instanceof Error ? error.message : t('terminalView.error.restartFailed') ); @@ -665,7 +654,7 @@ export const TerminalView: React.FC = ({ visible, directory } } finally { setIsRestarting(false); } - }, [activeTabId, disconnectStream, terminalDirectory, enableTabs, isActionTab, isRestarting, recoverCurrentSessionDirectory, resetTerminalPreviewScan, setTabLifecycle, setTabSessionId, startStream, t, terminal, terminalLoginShell, terminalShell]); + }, [activeTabId, disconnectStream, terminalDirectory, enableTabs, isActionTab, isRestarting, resetTerminalPreviewScan, setTabLifecycle, setTabSessionId, startStream, t, terminal, terminalLoginShell, terminalShell]); const handleHardRestart = React.useCallback(async () => { // Keep semantics: “close tab -> new clean tab”. diff --git a/packages/ui/src/lib/i18n/messages/de.ts b/packages/ui/src/lib/i18n/messages/de.ts index b208adc3..1a9745b1 100644 --- a/packages/ui/src/lib/i18n/messages/de.ts +++ b/packages/ui/src/lib/i18n/messages/de.ts @@ -609,7 +609,6 @@ export const dict = { 'sessions.sidebar.sessionDialogs.worktree.attachedArchived': 'Angefügter Worktree archiviert.', 'sessions.sidebar.sessionDialogs.worktree.attachedArchivedPlural': 'Angefügte Worktrees archiviert.', 'sessions.sidebar.sessionDialogs.worktree.archivedAndRemoteRemoved': 'Archivierte Worktrees und entfernte Remote-Branches.', - 'sessions.missingDirectory.movedToProject': 'Der Ordner dieser Sitzung existiert nicht mehr. Die Sitzung wurde nach {project} verschoben.', 'sessions.sidebar.group.worktreeMissing': 'Worktree-Ordner fehlt', 'sessions.sidebar.sessionDialogs.worktree.label': 'Worktree', 'sessions.sidebar.sessionDialogs.worktree.pathUnavailable': 'Worktree-Pfad nicht verfügbar.', diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts index f8ba5012..5438f330 100644 --- a/packages/ui/src/lib/i18n/messages/en.ts +++ b/packages/ui/src/lib/i18n/messages/en.ts @@ -705,7 +705,6 @@ export const dict = { 'sessions.sidebar.sessionDialogs.worktree.attachedArchived': 'Attached worktree archived.', 'sessions.sidebar.sessionDialogs.worktree.attachedArchivedPlural': 'Attached worktrees archived.', 'sessions.sidebar.sessionDialogs.worktree.archivedAndRemoteRemoved': 'Archived worktrees and removed remote branches.', - 'sessions.missingDirectory.movedToProject': 'This session\'s folder no longer exists. The session was moved to {project}.', 'sessions.sidebar.group.worktreeMissing': 'Worktree folder is missing', 'sessions.sidebar.sessionDialogs.worktree.label': 'Worktree', 'sessions.sidebar.sessionDialogs.worktree.pathUnavailable': 'Worktree path unavailable.', diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts index 8b9da925..9c088b3f 100644 --- a/packages/ui/src/lib/i18n/messages/es.ts +++ b/packages/ui/src/lib/i18n/messages/es.ts @@ -706,7 +706,6 @@ export const dict: Record = { "sessions.sidebar.sessionDialogs.worktree.attachedArchived": "Worktree adjunto archivado.", "sessions.sidebar.sessionDialogs.worktree.attachedArchivedPlural": "Worktrees adjuntos archivados.", "sessions.sidebar.sessionDialogs.worktree.archivedAndRemoteRemoved": "Worktrees archivados y ramas remotas eliminadas.", - "sessions.missingDirectory.movedToProject": "La carpeta de esta sesión ya no existe. La sesión se movió a {project}.", "sessions.sidebar.group.worktreeMissing": "Falta la carpeta del worktree", "sessions.sidebar.sessionDialogs.worktree.label": "Worktree", "sessions.sidebar.sessionDialogs.worktree.pathUnavailable": "Ruta de worktree no disponible.", diff --git a/packages/ui/src/lib/i18n/messages/fr.ts b/packages/ui/src/lib/i18n/messages/fr.ts index 5964867f..c7640993 100644 --- a/packages/ui/src/lib/i18n/messages/fr.ts +++ b/packages/ui/src/lib/i18n/messages/fr.ts @@ -534,7 +534,6 @@ export const dict = { 'sessions.sidebar.sessionDialogs.worktree.attachedArchived': 'Worktree ci-joint archivé.', 'sessions.sidebar.sessionDialogs.worktree.attachedArchivedPlural': 'Worktrees joints archivés.', 'sessions.sidebar.sessionDialogs.worktree.archivedAndRemoteRemoved': 'Worktrees archivés et branches du dépôt distant supprimées.', - 'sessions.missingDirectory.movedToProject': 'Le dossier de cette session n\'existe plus. La session a été déplacée vers {project}.', 'sessions.sidebar.group.worktreeMissing': 'Le dossier du worktree est introuvable', 'sessions.sidebar.sessionDialogs.worktree.label': 'Worktree', 'sessions.sidebar.sessionDialogs.worktree.pathUnavailable': 'Chemin du worktree indisponible.', diff --git a/packages/ui/src/lib/i18n/messages/ja.ts b/packages/ui/src/lib/i18n/messages/ja.ts index 1f6ea593..b9a40728 100644 --- a/packages/ui/src/lib/i18n/messages/ja.ts +++ b/packages/ui/src/lib/i18n/messages/ja.ts @@ -706,7 +706,6 @@ export const dict: Record = { 'sessions.sidebar.sessionDialogs.worktree.attachedArchived': '添付のワークツリーをアーカイブしました。', 'sessions.sidebar.sessionDialogs.worktree.attachedArchivedPlural': '添付のワークツリーをアーカイブしました。', 'sessions.sidebar.sessionDialogs.worktree.archivedAndRemoteRemoved': 'ワークツリーをアーカイブし、リモートブランチを削除しました。', - 'sessions.missingDirectory.movedToProject': 'このセッションのフォルダーは存在しません。セッションを {project} に移動しました。', 'sessions.sidebar.group.worktreeMissing': 'ワークツリーのフォルダーがありません', 'sessions.sidebar.sessionDialogs.worktree.label': 'ワークツリー', 'sessions.sidebar.sessionDialogs.worktree.pathUnavailable': 'ワークツリーパスは利用できません。', diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index 0a3f7aa8..556fa085 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -706,7 +706,6 @@ export const dict: Record = { 'sessions.sidebar.sessionDialogs.worktree.attachedArchived': '첨부됨 워크트리 보관됨.', 'sessions.sidebar.sessionDialogs.worktree.attachedArchivedPlural': '첨부됨 워크트리 보관됨.', 'sessions.sidebar.sessionDialogs.worktree.archivedAndRemoteRemoved': '워크트리가 보관되고 리모트 브랜치가 제거되었습니다.', - 'sessions.missingDirectory.movedToProject': '이 세션의 폴더가 더 이상 존재하지 않습니다. 세션을 {project}(으)로 이동했습니다.', 'sessions.sidebar.group.worktreeMissing': '워크트리 폴더가 없습니다', 'sessions.sidebar.sessionDialogs.worktree.label': '워크트리', 'sessions.sidebar.sessionDialogs.worktree.pathUnavailable': '워크트리 경로를 사용할 수 없습니다.', diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts index 8c2d23bf..daaff795 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -706,7 +706,6 @@ export const dict: Record = { 'sessions.sidebar.sessionDialogs.worktree.attachedArchived': 'Dołączone drzewo pracy zarchiwizowane.', 'sessions.sidebar.sessionDialogs.worktree.attachedArchivedPlural': 'Dołączone drzewa pracy zarchiwizowane.', 'sessions.sidebar.sessionDialogs.worktree.archivedAndRemoteRemoved': 'Zarchiwizowane drzewa pracy i usunięte zdalne gałęzie.', - 'sessions.missingDirectory.movedToProject': 'Folder tej sesji już nie istnieje. Sesja została przeniesiona do {project}.', 'sessions.sidebar.group.worktreeMissing': 'Brak folderu worktree', 'sessions.sidebar.sessionDialogs.worktree.label': 'Drzewo pracy', 'sessions.sidebar.sessionDialogs.worktree.pathUnavailable': 'Ścieżka drzewa pracy niedostępna.', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index 50352759..7d090112 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -706,7 +706,6 @@ export const dict: Record = { "sessions.sidebar.sessionDialogs.worktree.attachedArchived": "Worktree adjunto archivado.", "sessions.sidebar.sessionDialogs.worktree.attachedArchivedPlural": "Worktrees adjuntos archivados.", "sessions.sidebar.sessionDialogs.worktree.archivedAndRemoteRemoved": "Worktrees archivados e branches remotas excluídas.", - "sessions.missingDirectory.movedToProject": "A pasta desta sessão não existe mais. A sessão foi movida para {project}.", "sessions.sidebar.group.worktreeMissing": "A pasta do worktree está ausente", "sessions.sidebar.sessionDialogs.worktree.label": "Worktree", "sessions.sidebar.sessionDialogs.worktree.pathUnavailable": "Caminho de worktree não disponível.", diff --git a/packages/ui/src/lib/i18n/messages/tr.ts b/packages/ui/src/lib/i18n/messages/tr.ts index 63b5232c..3cddde5d 100644 --- a/packages/ui/src/lib/i18n/messages/tr.ts +++ b/packages/ui/src/lib/i18n/messages/tr.ts @@ -687,7 +687,6 @@ export const dict = { 'sessions.sidebar.sessionDialogs.worktree.attachedArchived': 'Bağlı worktree arşivlendi.', 'sessions.sidebar.sessionDialogs.worktree.attachedArchivedPlural': 'Bağlı worktree\'ler arşivlendi.', 'sessions.sidebar.sessionDialogs.worktree.archivedAndRemoteRemoved': 'Worktree\'ler arşivlendi ve uzak branch\'ler kaldırıldı.', - 'sessions.missingDirectory.movedToProject': 'Bu oturumun klasörü artık mevcut değil. Oturum {project} konumuna taşındı.', 'sessions.sidebar.group.worktreeMissing': 'Worktree klasörü eksik', 'sessions.sidebar.sessionDialogs.worktree.label': 'Worktree', 'sessions.sidebar.sessionDialogs.worktree.pathUnavailable': 'Worktree yolu kullanılamıyor.', diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index 7b1090c2..2602f96b 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -706,7 +706,6 @@ export const dict: Record = { "sessions.sidebar.sessionDialogs.worktree.attachedArchived": "Прикріплене worktree заархівовано.", "sessions.sidebar.sessionDialogs.worktree.attachedArchivedPlural": "Прикріплені worktree заархівовано.", "sessions.sidebar.sessionDialogs.worktree.archivedAndRemoteRemoved": "Worktree заархівовано, віддалені гілки видалено.", - "sessions.missingDirectory.movedToProject": "Теки цієї сесії більше не існує. Сесію перенесено до {project}.", "sessions.sidebar.group.worktreeMissing": "Теки worktree немає", "sessions.sidebar.sessionDialogs.worktree.label": "Worktree", "sessions.sidebar.sessionDialogs.worktree.pathUnavailable": "Шлях worktree недоступний.", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index ee593026..2b5ba5d3 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -706,7 +706,6 @@ export const dict: Record = { 'sessions.sidebar.sessionDialogs.worktree.attachedArchived': '关联工作树已归档。', 'sessions.sidebar.sessionDialogs.worktree.attachedArchivedPlural': '关联工作树已归档。', 'sessions.sidebar.sessionDialogs.worktree.archivedAndRemoteRemoved': '工作树已归档且远程分支已移除。', - 'sessions.missingDirectory.movedToProject': '此会话的文件夹已不存在。会话已移至 {project}。', 'sessions.sidebar.group.worktreeMissing': '工作树文件夹缺失', 'sessions.sidebar.sessionDialogs.worktree.label': '工作树', 'sessions.sidebar.sessionDialogs.worktree.pathUnavailable': '工作树路径不可用。', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.ts b/packages/ui/src/lib/i18n/messages/zh-TW.ts index 687d520e..81fd0ae1 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts @@ -719,7 +719,6 @@ export const dict: Record = { 'sessions.sidebar.sessionDialogs.worktree.attachedArchived': '關聯 worktree 已封存。', 'sessions.sidebar.sessionDialogs.worktree.attachedArchivedPlural': '關聯 worktree 已封存。', 'sessions.sidebar.sessionDialogs.worktree.archivedAndRemoteRemoved': 'worktree 已封存且遠端分支已移除。', - 'sessions.missingDirectory.movedToProject': '此工作階段的資料夾已不存在。工作階段已移至 {project}。', 'sessions.sidebar.group.worktreeMissing': '工作樹資料夾遺失', 'sessions.sidebar.sessionDialogs.worktree.label': 'worktree', 'sessions.sidebar.sessionDialogs.worktree.pathUnavailable': 'worktree 路徑無法使用。', diff --git a/packages/ui/src/lib/opencode/client.test.ts b/packages/ui/src/lib/opencode/client.test.ts index 2be89a52..c6c8406c 100644 --- a/packages/ui/src/lib/opencode/client.test.ts +++ b/packages/ui/src/lib/opencode/client.test.ts @@ -99,16 +99,16 @@ beforeEach(() => { }); describe('opencodeClient directory availability', () => { - type ProbeBody = { error?: string; reason?: string; entries?: never[] }; + type ProbeBody = { error: string; reason?: string } | { isDirectory: boolean } | { isFile: boolean; size: number }; const json = (status: number, body: ProbeBody): Response => new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' }, }); test('stats the directory through the OpenChamber filesystem route, never through OpenCode path resolution', async () => { - runtimeFetchResults.push(json(200, { entries: [] })); + runtimeFetchResults.push(json(200, { isDirectory: true })); expect(await opencodeClient.getDirectoryAvailability('/private/deleted-worktree')).toBe('available'); - expect(runtimeFetchCalls).toEqual([{ path: '/api/fs/list', query: { path: '/private/deleted-worktree' } }]); + expect(runtimeFetchCalls).toEqual([{ path: '/api/fs/directory-stat', query: { path: '/private/deleted-worktree' } }]); expect(pathGetCalls).toBe(0); }); @@ -119,10 +119,19 @@ describe('opencodeClient directory availability', () => { runtimeFetchResults.push(json(400, { error: 'Specified path is not a directory', reason: 'not-directory' })); expect(await opencodeClient.getDirectoryAvailability('/private/deleted-worktree')).toBe('missing'); + runtimeFetchResults.push(json(200, { isFile: true, size: 12 })); + expect(await opencodeClient.getDirectoryAvailability('/private/deleted-worktree')).toBe('unknown'); + runtimeFetchResults.push(json(404, { error: 'Not Found' })); expect(await opencodeClient.getDirectoryAvailability('/private/deleted-worktree')).toBe('unknown'); - runtimeFetchResults.push(json(500, { error: 'Failed to list directory' })); + runtimeFetchResults.push(json(500, { error: 'Failed to stat path' })); + expect(await opencodeClient.getDirectoryAvailability('/private/deleted-worktree')).toBe('unknown'); + + runtimeFetchResults.push(json(403, { error: 'Access to directory denied', reason: 'os-permission' })); + expect(await opencodeClient.getDirectoryAvailability('/private/deleted-worktree')).toBe('unknown'); + + runtimeFetchResults.push(json(501, { error: 'Unsupported' })); expect(await opencodeClient.getDirectoryAvailability('/private/deleted-worktree')).toBe('unknown'); runtimeFetchResults.push(new Error('offline')); diff --git a/packages/ui/src/lib/opencode/client.ts b/packages/ui/src/lib/opencode/client.ts index 410e9165..c4480b3c 100644 --- a/packages/ui/src/lib/opencode/client.ts +++ b/packages/ui/src/lib/opencode/client.ts @@ -71,7 +71,7 @@ type SdkResult = { }; type DirectoryAvailability = "available" | "missing" | "unknown"; -const directoryProbeErrorSchema = z.object({ reason: z.string().optional() }); +const directoryProbeErrorSchema = z.object({ reason: z.string().optional(), isDirectory: z.boolean().optional() }); function unwrapSdkData(result: SdkResult, operation: string): T { @@ -597,25 +597,26 @@ class OpencodeService { } /** - * Distinguishes a confirmed-missing directory from an unavailable probe. - * Offline, permission, and other transport failures stay `unknown` so callers - * do not treat a temporary outage as proof the path was deleted. - * - * The probe is OpenChamber's own `/api/fs/list`, which stats the path on the - * server's disk. OpenCode's `/path` cannot answer this question: it echoes - * the requested directory and resolves its project through Git discovery - * that swallows errors, so a deleted worktree still comes back as a valid - * location. A runtime without that route (VS Code) answers `unknown`. - */ + * Distinguishes a confirmed-missing directory from an unavailable probe. + * Offline, permission, and other transport failures stay `unknown` so callers + * do not treat a temporary outage as proof the path was deleted. + * + * The probe is OpenChamber's own `/api/fs/directory-stat`, which asks the + * server to stat the path without listing its contents. OpenCode's `/path` + * cannot answer this question: it echoes the requested directory and resolves + * its project through Git discovery that swallows errors, so a deleted worktree + * still comes back as a valid location. A runtime without that route (VS Code) + * answers `unknown`. + */ async getDirectoryAvailability(directory: string): Promise { const normalized = this.normalizeCandidatePath(directory); if (!normalized) { return "unknown"; } try { - const response = await runtimeFetch("/api/fs/list", { query: { path: normalized } }); - if (response.ok) return "available"; + const response = await runtimeFetch("/api/fs/directory-stat", { query: { path: normalized } }); const body = directoryProbeErrorSchema.safeParse(await response.json().catch(() => null)).data; + if (response.ok && body?.isDirectory === true) return "available"; const reason = parseFilesystemErrorReason(body?.reason); return reason === "not-found" || reason === "not-directory" ? "missing" : "unknown"; } catch { diff --git a/packages/ui/src/lib/worktrees/worktreeManager.test.ts b/packages/ui/src/lib/worktrees/worktreeManager.test.ts index aab8f46e..14c10c11 100644 --- a/packages/ui/src/lib/worktrees/worktreeManager.test.ts +++ b/packages/ui/src/lib/worktrees/worktreeManager.test.ts @@ -117,10 +117,8 @@ const { createWorktree, getLatestWorktreeMetadata, listProjectWorktrees, - notifyWorktreeTopologyChanged, partitionWorktreesByRegisteredProject, removeProjectWorktree, - subscribeWorktreeTopologyChanged, validateWorktreeCreate, worktreeMapsEqual, } = await import('./worktreeManager'); @@ -679,21 +677,4 @@ describe('worktreeManager missing worktrees', () => { expect(worktreeMapsEqual(new Map([['/repo', [ready]]]), new Map([['/repo', [missing]]]))).toBe(false); }); - test('a topology-changed signal drops the cached listing and reaches subscribers', async () => { - const project = { id: 'project-signal', path: '/repo-signal/' }; - listImplementation = async () => []; - await listProjectWorktrees(project, { force: true }); - await listProjectWorktrees(project); - expect(listCalls).toEqual(['/repo-signal']); - - const notified: string[] = []; - const unsubscribe = subscribeWorktreeTopologyChanged((directory) => notified.push(directory)); - notifyWorktreeTopologyChanged('/repo-signal/'); - unsubscribe(); - notifyWorktreeTopologyChanged('/repo-signal'); - - expect(notified).toEqual(['/repo-signal']); - await listProjectWorktrees(project); - expect(listCalls).toEqual(['/repo-signal', '/repo-signal']); - }); }); diff --git a/packages/ui/src/lib/worktrees/worktreeManager.ts b/packages/ui/src/lib/worktrees/worktreeManager.ts index 99caa339..f3c22d80 100644 --- a/packages/ui/src/lib/worktrees/worktreeManager.ts +++ b/packages/ui/src/lib/worktrees/worktreeManager.ts @@ -400,29 +400,6 @@ const invalidateWorktreeList = (projectDirectory: string): void => { _worktreeListCache.delete(projectDirectory); }; -type WorktreeTopologyListener = (projectDirectory: string) => void; -const worktreeTopologyListeners = new Set(); - -/** - * Subscribe to in-app evidence that a project's worktree topology changed - * outside the flows that publish it themselves (a session relocated out of a - * directory the server confirmed missing). The sidebar rediscovers on this - * signal the same way it does for the server's `session-created` event, so - * the topology stays event-driven with no idle polling. - */ -export const subscribeWorktreeTopologyChanged = (listener: WorktreeTopologyListener): (() => void) => { - worktreeTopologyListeners.add(listener); - return () => { - worktreeTopologyListeners.delete(listener); - }; -}; - -export const notifyWorktreeTopologyChanged = (projectDirectory: string): void => { - const normalized = normalizePath(projectDirectory); - invalidateWorktreeList(normalized); - for (const listener of worktreeTopologyListeners) listener(normalized); -}; - const readProjectWorktrees = async (projectDirectory: string): Promise => { const metadataProjectDirectory = await resolveProjectRoot(projectDirectory).catch(() => projectDirectory); const normalizedProjectDirectory = normalizePath(projectDirectory); diff --git a/packages/ui/src/sync/DOCUMENTATION.md b/packages/ui/src/sync/DOCUMENTATION.md index e2b03d13..d5c50cad 100644 --- a/packages/ui/src/sync/DOCUMENTATION.md +++ b/packages/ui/src/sync/DOCUMENTATION.md @@ -288,7 +288,7 @@ Rules: 4. Sending after a revert commits the new branch optimistically: remove the reverted tail and marker before inserting the new message, and restore both if the send is rejected. 5. Composer and queued sends carry their captured runtime, directory, and session through asynchronous preparation. A runtime change cancels the send instead of re-resolving it against the new runtime. Outside VS Code the queue itself is server-owned (`packages/web/server/lib/message-queue/`): the UI hands the server the captured send configuration, resolved text, attachments, and attached context at queue time and the server delivers on idle; the composer only sends a queued message itself after taking it back from the server (`takeForSend`). See the `messageQueueStore.ts` section in `stores/DOCUMENTATION.md`. 6. After session creation, the directory returned by the server is authoritative over the requested draft directory. The server may canonicalize a worktree path, and the first prompt must use the same directory identity as the created session. -7. Regular new-chat drafts that inherit the persisted current/last directory must not create a session against a confirmed-missing path. Fall back to the active project only when OpenCode reports the directory missing; keep explicit worktree targets, in-flight worktree creation, and unknown/offline probes unchanged, and do not persist the fallback until session creation succeeds. A concurrent draft rewrite to that same active-project fallback must not abort session creation. +7. Regular new-chat drafts that inherit the persisted current/last directory must not create a session against a confirmed-missing path. Fall back to the active project only when OpenChamber's directory stat reports the directory missing; keep explicit worktree targets, in-flight worktree creation, and unknown/offline probes unchanged, and do not persist the fallback until session creation succeeds. A concurrent draft rewrite to that same active-project fallback must not abort session creation. 8. A prompt send that fails **after** the request left the client is ambiguous, never a definite failure: the server may already be answering it. Transports tag those errors (`markAmbiguousTransportFailure` in `@/lib/relay/transport-error`; the relay tunnel tags every stream that dies with a request in flight), and `isAmbiguousSendFailure` reads the tag before falling back to status/text heuristics. An ambiguous failure waits for the connection to return, refetches recent messages, and confirms the optimistic message in place instead of rolling it back — rolling it back lets the message queue re-send a prompt the engine is already running, producing two independent AI responses for one user message. 9. `SessionLiveActivity` has three answers and `unknown` is never `idle`. `getSessionLiveActivity` reports `active` when any child store or the global session-status index holds a non-idle status, `idle` only when a child store actually covers the session's directory, and `unknown` otherwise — child stores are evicted for background directories, and the global index keeps only non-idle entries, so absence of a status is not proof of idleness. Callers that gate a destructive action (worktree moves) must refuse on `unknown`. 10. Revert and unrevert cascade through known descendant sessions before mutating the parent. Revert uses the first descendant user message at or after the parent's target timestamp, including equal timestamps because message IDs do not define chronology. A descendant failure is logged and does not block its siblings or the parent. The parent runs last so its shared-directory file snapshot remains authoritative. A busy descendant is aborted before it is reverted, like the parent, so nothing keeps writing past the revert boundary. Redo clears the revert marker on every descendant, including markers the user set on a subagent independently of the parent undo. @@ -341,25 +341,9 @@ feedback stays truthful. Callers whose confirmation can span a runtime switch may pass an `expectedRuntimeKey` captured earlier; ordinary callers are guarded by default. -When the session being restored belongs to a worktree that no longer exists, -writing `time.archived = 0` alone would leave it grouped under a directory the -sidebar can never surface. Restore therefore probes the session's owned -directory with `getDirectoryAvailability` and, only on an exact `missing` -result, relocates it: it resolves the owning OpenCode project's primary -directory by the session's server `projectID` (from `project.list()`, never a -local project ID or the active project), then unarchives and moves the whole -subtree still stranded in the missing directory to that project directory -through `moveSessionToDirectory(..., false)`. `available`, `unknown`, an -availability probe failure, a missing project record, and non-worktree sessions -keep the plain restore path. The subtree is drawn from the global cache so -archived descendants that never materialized in a live child store are still -relocated, and a node is kept while it is archived **or** still owns the -missing directory, so a retry after a partial restore (root already unarchived -but not yet moved) completes the move instead of reporting a false success. -`moveSessionToDirectory` accepts the captured `expectedRuntimeKey` and skips all -local store/routing publication when the runtime changed during the -control-plane request, so the server move can complete without seeding the new -runtime with stale directory state. +`unarchiveSession` clears the archive timestamp in the session's existing directory. It never moves the session, including when that directory is missing. Server failure keeps the session archived locally; confirmation updates the global cache. `unarchiveSessions` preserves partial results and stops committing when its captured runtime changes. + +### Deletion runtime guard Deletion needs this guard more than archiving does. Session IDs are not unique across runtimes, and a committed deletion does more than hide a row: it evicts @@ -383,30 +367,9 @@ reports failure instead of committing. The deletion already accepted by the server stays deleted there; its persisted state is left as harmless stale metadata and the next authoritative load reconciles it. -### Missing directory relocation (active sessions) +### Missing worktree directories -The same directory can disappear under an active session: a worktree removed -by the agent or by hand leaves the session, its tabs, and its prompts pointed -at a path that no longer exists, and the terminal server answers every create -and restart with `Invalid working directory`. `relocateSessionFromMissingDirectory` -(`session-actions.ts`) applies the restore fallback's gate to a live session: -an exact `missing` probe, the destination resolved from the server `projectID`, -and `available`, `unknown`, probe failures, project-root sessions, and sessions -without a project left untouched. Every session of the root's subtree still -stranded in that directory moves with it, root first, so the session the user -is looking at is usable even when a descendant move fails; the result names -the sessions already moved. Moves carry no changes because the source is gone. - -`session-ui-store.recoverMissingSessionDirectory` owns the user-visible side: -one shared attempt per runtime and session, the worktree hint cleared for each -moved session (it is the first thing every directory lookup reads), the current -session re-selected through `setCurrentSession` so the active directory, -project, and OpenCode client follow it, and one toast naming the destination. -It runs from two places: a terminal create/restart rejected with the server's -`TERMINAL_CWD_MISSING` code, and session activation for any session whose -directory is neither a registered project root nor a managed chat directory -(the same probe a reopened draft performs on its inherited directory). VS Code -registers no worktrees, so activation never probes there. +Existing sessions keep their directory when a worktree disappears. Session activation makes no directory-availability probe, and terminal failures and archive restoration never move sessions. Manual movement still goes through `moveSessionToDirectory`. Worktree deletion still archives its sessions before removing the worktree. Missing-worktree groups stay visible with a warning so users can choose either action. ## The golden rule diff --git a/packages/ui/src/sync/__tests__/issue-2039.test.ts b/packages/ui/src/sync/__tests__/issue-2039.test.ts index 763b815d..46ca3c33 100644 --- a/packages/ui/src/sync/__tests__/issue-2039.test.ts +++ b/packages/ui/src/sync/__tests__/issue-2039.test.ts @@ -309,7 +309,6 @@ mock.module("../session-actions", () => ({ unrevertSession: mock(async () => undefined), forkFromMessage: mock(async () => undefined), fetchMessagesForSession: mock(async () => undefined), - relocateSessionFromMissingDirectory: mock(async () => ({ status: "unchanged" })), getSessionLastAssistantModel: () => null, patchSessionMetadata: mock(async () => undefined), abortCurrentOperation: mock(async () => undefined), diff --git a/packages/ui/src/sync/session-actions.test.ts b/packages/ui/src/sync/session-actions.test.ts index a68f5e81..c3080348 100644 --- a/packages/ui/src/sync/session-actions.test.ts +++ b/packages/ui/src/sync/session-actions.test.ts @@ -1112,231 +1112,34 @@ describe("session restore (unarchive)", () => { expect((globalUpsertedSessions[0] as SessionWithDirectory).directory).toBe(worktreeDirectory) }) - test("moves a restored missing-worktree subtree to its matching project directory without changing descendants or cached transcript state", async () => { + test("restores a missing-worktree session in place without relocating it", async () => { const missingWorktreeDirectory = "/projects/main/.worktrees/deleted-branch" - const destinationDirectory = "/projects/main" - const rootMessage = { - id: "message-root", - sessionID: "session-root", - role: "user", - time: { created: 10 }, - } as Message - const rootPart = { id: "part-root", messageID: rootMessage.id, type: "text", text: "root" } as Part - const childMessage = { - id: "message-child", - sessionID: "session-child", - role: "assistant", - time: { created: 11 }, - } as Message - const childPart = { id: "part-child", messageID: childMessage.id, type: "text", text: "child" } as Part - const rootSession = { - id: "session-root", - projectID: "project-main", - directory: missingWorktreeDirectory, - project: { worktree: destinationDirectory }, - time: { created: 1, archived: 2 }, - } as SessionWithDirectory - const childSession = { - id: "session-child", - parentID: "session-root", - projectID: "project-main", - directory: missingWorktreeDirectory, - project: { worktree: destinationDirectory }, - time: { created: 2, archived: 3 }, - } as SessionWithDirectory - globalArchivedSessions.push(rootSession, childSession) - openCodeProjects.push({ id: "project-main", worktree: destinationDirectory } as Project) - directoryAvailability.set(missingWorktreeDirectory, "missing") - sessionUpdateResultsById.set("session-root", { - ...rootSession, - time: { created: 1, updated: 1, archived: 0 }, - }) - sessionUpdateResultsById.set("session-child", { - ...childSession, - time: { created: 2, updated: 2, archived: 0 }, - }) - - const source = createStore({}, { - session: [rootSession, childSession], - sessionTotal: 2, - message: { - "session-root": [rootMessage], - "session-child": [childMessage], - }, - part: { - [rootMessage.id]: [rootPart], - [childMessage.id]: [childPart], - }, - }) - const destination = createStore({}) - const { unarchiveSession, setActionRefs } = await import("./session-actions") - setActionRefs( - mockSdk as unknown as OpencodeClient, - createChildStores([[missingWorktreeDirectory, source], [destinationDirectory, destination]]), - () => missingWorktreeDirectory, - ) - - expect(await unarchiveSession("session-root")).toBe(true) - expect(replyCalls.filter((call) => call.method === "controlPlane.moveSession")).toEqual([ - { - method: "controlPlane.moveSession", - params: { - sessionID: "session-root", - destination: { directory: destinationDirectory }, - moveChanges: false, - }, - }, - { - method: "controlPlane.moveSession", - params: { - sessionID: "session-child", - destination: { directory: destinationDirectory }, - moveChanges: false, - }, - }, - ]) - expect(source.getState().session).toEqual([]) - expect(destination.getState().session.map((session) => ({ - id: session.id, - parentID: (session as SessionWithDirectory).parentID ?? null, - directory: (session as SessionWithDirectory).directory ?? null, - }))).toEqual([ - { id: "session-root", parentID: null, directory: destinationDirectory }, - { id: "session-child", parentID: "session-root", directory: destinationDirectory }, - ]) - expect(destination.getState().message["session-root"]?.[0]?.id).toBe(rootMessage.id) - expect(destination.getState().message["session-child"]?.[0]?.id).toBe(childMessage.id) - expect(destination.getState().part[rootMessage.id]?.[0]?.id).toBe(rootPart.id) - expect(destination.getState().part[childMessage.id]?.[0]?.id).toBe(childPart.id) - expect(destination.getState().session.every((session) => !session.time?.archived)).toBe(true) - expect(registeredSessionDirectories).toEqual([ - { sessionID: "session-root", directory: destinationDirectory }, - { sessionID: "session-child", directory: destinationDirectory }, - ]) - expect(movedSessionDirectories).toEqual([ - { sessionID: "session-root", directory: destinationDirectory }, - { sessionID: "session-child", directory: destinationDirectory }, - ]) - expect(globalUpsertedSessions.map((session) => ({ - id: (session as SessionWithDirectory).id, - parentID: (session as SessionWithDirectory).parentID ?? null, - directory: (session as SessionWithDirectory).directory ?? null, - }))).toEqual([ - { id: "session-root", parentID: null, directory: destinationDirectory }, - { id: "session-child", parentID: "session-root", directory: destinationDirectory }, - ]) - }) - - test("restores missing-worktree descendants from the global cache when their directory store is unavailable", async () => { - const missingWorktreeDirectory = "/projects/main/.worktrees/deleted-branch" - const destinationDirectory = "/projects/main" - const rootSession = { - id: "session-root", - projectID: "proj_main", - directory: missingWorktreeDirectory, - project: { worktree: destinationDirectory }, - time: { created: 1, archived: 2 }, - } as SessionWithDirectory - const childSession = { - id: "session-child", - parentID: rootSession.id, - projectID: "proj_main", - directory: missingWorktreeDirectory, - project: { worktree: destinationDirectory }, - time: { created: 2, archived: 3 }, - } as SessionWithDirectory - globalArchivedSessions.push(rootSession, childSession) - openCodeProjects.push({ id: "proj_main", worktree: destinationDirectory } as Project) - directoryAvailability.set(missingWorktreeDirectory, "missing") - sessionUpdateResultsById.set("session-root", { ...rootSession, time: { created: 1, updated: 1, archived: 0 } }) - sessionUpdateResultsById.set("session-child", { ...childSession, time: { created: 2, updated: 2, archived: 0 } }) - - const destination = createStore({}) - const { unarchiveSession, setActionRefs } = await import("./session-actions") - setActionRefs( - mockSdk as unknown as OpencodeClient, - createChildStores([[destinationDirectory, destination]]), - () => missingWorktreeDirectory, - ) - - expect(await unarchiveSession(rootSession.id)).toBe(true) - expect(replyCalls.filter((call) => call.method === "controlPlane.moveSession").map((call) => call.params.sessionID)) - .toEqual([rootSession.id, childSession.id]) - expect(destination.getState().session.map((session) => session.id)).toEqual([rootSession.id, childSession.id]) - expect(destination.getState().session.every((session) => !session.time?.archived)).toBe(true) - }) - - test("does not publish a missing-worktree move after the runtime changes during the control-plane request", async () => { - const missingWorktreeDirectory = "/projects/main/.worktrees/deleted-branch" - const destinationDirectory = "/projects/main" const session = { - id: "session-runtime-switch", - projectID: "proj_main", + id: "session-root", + projectID: "project-main", directory: missingWorktreeDirectory, - project: { worktree: destinationDirectory }, + project: { worktree: "/projects/main" }, time: { created: 1, archived: 2 }, } as SessionWithDirectory globalArchivedSessions.push(session) - openCodeProjects.push({ id: "proj_main", worktree: destinationDirectory } as Project) directoryAvailability.set(missingWorktreeDirectory, "missing") - sessionUpdateResultsById.set(session.id, { ...session, time: { created: 1, updated: 1, archived: 0 } }) - beforeControlPlaneMoveResolve = () => { - runtimeKey = "new-runtime" - } + sessionUpdateResultsById.set("session-root", { + ...session, + time: { created: 1, updated: 1, archived: 0 }, + }) - const destination = createStore({}) + const store = createStore({}) const { unarchiveSession, setActionRefs } = await import("./session-actions") - setActionRefs( - mockSdk as unknown as OpencodeClient, - createChildStores([[destinationDirectory, destination]]), - () => missingWorktreeDirectory, - ) - - expect(await unarchiveSession(session.id)).toBe(false) - expect(destination.getState().session).toEqual([]) - expect(registeredSessionDirectories).toEqual([]) - expect(globalUpsertedSessions).toEqual([]) - }) - - test("re-moves a root left stranded in a missing worktree after a partial restore", async () => { - const missingWorktreeDirectory = "/projects/main/.worktrees/deleted-branch" - const destinationDirectory = "/projects/main" - // A previous restore attempt already unarchived the root (server echo made - // it active), then the control-plane move failed, leaving it stranded in the - // deleted worktree. The retry must still relocate it, not report a false - // success because the root is no longer archived. - const strandedRoot = { - id: "session-root", - projectID: "proj_main", - directory: missingWorktreeDirectory, - project: { worktree: destinationDirectory }, - time: { created: 1, archived: 0 }, - } as SessionWithDirectory - globalActiveSessions.push(strandedRoot) - openCodeProjects.push({ id: "proj_main", worktree: destinationDirectory } as Project) - directoryAvailability.set(missingWorktreeDirectory, "missing") - sessionUpdateResultsById.set("session-root", { ...strandedRoot, time: { created: 1, updated: 1, archived: 0 } }) - - const destination = createStore({}) - const { unarchiveSession, setActionRefs } = await import("./session-actions") - setActionRefs( - mockSdk as unknown as OpencodeClient, - createChildStores([[destinationDirectory, destination]]), - () => missingWorktreeDirectory, - ) + setActionRefs(mockSdk as unknown as OpencodeClient, createChildStores([[missingWorktreeDirectory, store]]), () => missingWorktreeDirectory) expect(await unarchiveSession("session-root")).toBe(true) - expect(replyCalls.filter((call) => call.method === "controlPlane.moveSession")).toEqual([ - { - method: "controlPlane.moveSession", - params: { - sessionID: "session-root", - destination: { directory: destinationDirectory }, - moveChanges: false, - }, - }, + expect(replyCalls.filter((call) => call.method === "controlPlane.moveSession")).toEqual([]) + expect(store.getState().session).toEqual([]) + expect(registeredSessionDirectories).toEqual([{ sessionID: "session-root", directory: missingWorktreeDirectory }]) + expect(movedSessionDirectories).toEqual([]) + expect(globalUpsertedSessions).toEqual([ + { ...session, time: { created: 1, updated: 1, archived: 0 } }, ]) - expect(destination.getState().session.map((session) => session.id)).toEqual(["session-root"]) }) test("does not move a restored project session that is not a worktree", async () => { @@ -3019,148 +2822,3 @@ describe("dismissOpenPermissionsForSession", () => { } }) }) - -describe("relocateSessionFromMissingDirectory", () => { - const missingWorktree = "/projects/main/.worktrees/gone" - const projectDirectory = "/projects/main" - const worktreeSession = (id: string, parentID: string | null, directory = missingWorktree, archived = 0): Session & { project: { worktree: string } } => ({ - id, - slug: id, - projectID: "project-main", - directory, - title: id, - version: "1", - project: { worktree: projectDirectory }, - time: { created: 1, updated: 1, archived }, - parentID: parentID ?? undefined, - }) - const mainProject: Project = { id: "project-main", worktree: projectDirectory, time: { created: 1, updated: 1 }, sandboxes: [] } - const stores = () => createChildStores([[missingWorktree, createStore({})], [projectDirectory, createStore({})]]) - const movesOf = () => replyCalls - .filter((call) => call.method === "controlPlane.moveSession") - .map((call) => ({ sessionID: call.params.sessionID, destination: call.params.destination, moveChanges: call.params.moveChanges })) - - beforeEach(() => { - replyCalls.length = 0 - registeredSessionDirectories.length = 0 - movedSessionDirectories.length = 0 - globalUpsertedSessions.length = 0 - globalActiveSessions = [] - globalArchivedSessions.length = 0 - openCodeProjects.length = 0 - directoryAvailability.clear() - controlPlaneMoveErrorsById.clear() - beforeDirectoryAvailabilityResolve = null - runtimeKey = "default-runtime" - }) - - test("moves the whole stranded subtree, root first, to the project directory without carrying changes", async () => { - const root = worktreeSession("root", null) - const child = worktreeSession("child", "root") - const archivedChild = worktreeSession("archived-child", "root", missingWorktree, 42) - const elsewhere = worktreeSession("elsewhere", "root", projectDirectory) - globalActiveSessions = [root, child, elsewhere] - globalArchivedSessions.push(archivedChild) - openCodeProjects.push(mainProject) - directoryAvailability.set(missingWorktree, "missing") - const { relocateSessionFromMissingDirectory, setActionRefs } = await import("./session-actions") - setActionRefs(actionSdk, stores(), () => missingWorktree) - - const result = await relocateSessionFromMissingDirectory("root") - - expect(result).toEqual({ - status: "moved", - sourceDirectory: missingWorktree, - destinationDirectory: projectDirectory, - movedSessionIds: ["root", "child", "archived-child"], - }) - expect(movesOf()).toEqual([ - { sessionID: "root", destination: { directory: projectDirectory }, moveChanges: false }, - { sessionID: "child", destination: { directory: projectDirectory }, moveChanges: false }, - { sessionID: "archived-child", destination: { directory: projectDirectory }, moveChanges: false }, - ]) - expect(movedSessionDirectories).toEqual([ - { sessionID: "root", directory: projectDirectory }, - { sessionID: "child", directory: projectDirectory }, - { sessionID: "archived-child", directory: projectDirectory }, - ]) - }) - - for (const availability of ["available", "unknown"] as const) { - test(`leaves the session alone when its directory is ${availability}`, async () => { - globalActiveSessions = [worktreeSession("root", null)] - openCodeProjects.push(mainProject) - directoryAvailability.set(missingWorktree, availability) - const { relocateSessionFromMissingDirectory, setActionRefs } = await import("./session-actions") - setActionRefs(actionSdk, stores(), () => missingWorktree) - - expect(await relocateSessionFromMissingDirectory("root")).toEqual({ status: "unchanged" }) - expect(movesOf()).toEqual([]) - }) - } - - test("leaves a session that already lives in its project directory alone", async () => { - globalActiveSessions = [worktreeSession("root", null, projectDirectory)] - openCodeProjects.push(mainProject) - directoryAvailability.set(projectDirectory, "missing") - const { relocateSessionFromMissingDirectory, setActionRefs } = await import("./session-actions") - setActionRefs(actionSdk, stores(), () => projectDirectory) - - expect(await relocateSessionFromMissingDirectory("root")).toEqual({ status: "unchanged" }) - expect(movesOf()).toEqual([]) - }) - - test("never relocates to the filesystem root OpenCode reports for its global project", async () => { - const chatDirectory = "/Users/tester/.config/openchamber/chats/2026-09-05/session-gone" - const chat = { ...worktreeSession("chat", null, chatDirectory), projectID: "global", project: { worktree: "/" } } - globalActiveSessions = [chat] - openCodeProjects.push({ id: "global", worktree: "/", time: { created: 1, updated: 1 }, sandboxes: [] }) - directoryAvailability.set(chatDirectory, "missing") - const { relocateSessionFromMissingDirectory, setActionRefs } = await import("./session-actions") - setActionRefs(actionSdk, stores(), () => chatDirectory) - - expect(await relocateSessionFromMissingDirectory("chat")).toEqual({ status: "unchanged" }) - expect(movesOf()).toEqual([]) - }) - - test("leaves the session alone when OpenCode knows no project for it", async () => { - globalActiveSessions = [worktreeSession("root", null)] - directoryAvailability.set(missingWorktree, "missing") - const { relocateSessionFromMissingDirectory, setActionRefs } = await import("./session-actions") - setActionRefs(actionSdk, stores(), () => missingWorktree) - - expect(await relocateSessionFromMissingDirectory("root")).toEqual({ status: "unchanged" }) - expect(movesOf()).toEqual([]) - }) - - test("reports the sessions already moved when a descendant move fails", async () => { - globalActiveSessions = [worktreeSession("root", null), worktreeSession("child", "root")] - openCodeProjects.push(mainProject) - directoryAvailability.set(missingWorktree, "missing") - controlPlaneMoveErrorsById.set("child", new Error("destination busy")) - const { relocateSessionFromMissingDirectory, setActionRefs } = await import("./session-actions") - setActionRefs(actionSdk, stores(), () => missingWorktree) - - const result = await relocateSessionFromMissingDirectory("root") - - expect(result.status).toBe("failed") - expect(result.status === "failed" ? result.movedSessionIds : null).toEqual(["root"]) - expect(movedSessionDirectories).toEqual([{ sessionID: "root", directory: projectDirectory }]) - }) - - test("publishes nothing when the runtime changes while the directory is being probed", async () => { - globalActiveSessions = [worktreeSession("root", null)] - openCodeProjects.push(mainProject) - directoryAvailability.set(missingWorktree, "missing") - const { switchRuntimeEndpoint } = await import("../lib/runtime-switch") - beforeDirectoryAvailabilityResolve = () => { - switchRuntimeEndpoint({ apiBaseUrl: "http://other.test", runtimeKey: "other-runtime" }) - } - const { relocateSessionFromMissingDirectory, setActionRefs } = await import("./session-actions") - setActionRefs(actionSdk, stores(), () => missingWorktree) - - expect(await relocateSessionFromMissingDirectory("root")).toEqual({ status: "stale" }) - expect(movesOf()).toEqual([]) - expect(movedSessionDirectories).toEqual([]) - }) -}) diff --git a/packages/ui/src/sync/session-actions.ts b/packages/ui/src/sync/session-actions.ts index c170425b..c29e7ab1 100644 --- a/packages/ui/src/sync/session-actions.ts +++ b/packages/ui/src/sync/session-actions.ts @@ -1531,134 +1531,6 @@ function commitArchivedSessions(sessions: Session[], directory: string): void { */ const UNARCHIVED_TIMESTAMP = 0 -async function getProjectPrimaryDirectory(projectID?: string): Promise { - if (!projectID) return null - - try { - const result = await sdk().project.list() - const projects = assertSdkData(result, "project.list") - const projectDirectory = projects.find((candidate) => candidate.id === projectID)?.worktree?.trim() - return projectDirectory ? normalizePath(projectDirectory) ?? projectDirectory : null - } catch { - return null - } -} - -type MissingWorktreeRelocation = { sourceDirectory: string; destinationDirectory: string } - -const isFilesystemRoot = (directory: string): boolean => directory === "/" || /^[A-Za-z]:\/?$/.test(directory) - -async function resolveMissingWorktreeRelocation( - session: Session & { project?: { worktree?: string | null } | null }, -): Promise { - const ownedDirectory = resolveSessionOwnedDirectory(session) - const projectWorktree = session.project?.worktree?.trim() - if (!ownedDirectory || !projectWorktree) return null - - let availability: Awaited> - try { - availability = await opencodeClient.getDirectoryAvailability(ownedDirectory) - } catch { - return null - } - if (availability !== "missing") return null - - const projectDirectory = await getProjectPrimaryDirectory(session.projectID) - if (!projectDirectory || projectDirectory === ownedDirectory) return null - // OpenCode files a directory outside any Git repository under its global - // project, whose "worktree" is the filesystem root. That is not a home for - // a session; a managed chat whose directory vanished stays where it is. - if (isFilesystemRoot(projectDirectory)) return null - return { sourceDirectory: ownedDirectory, destinationDirectory: projectDirectory } -} - -type OwnedSubtreeEntry = { session: Session; ownedDirectory: string | null } - -/** - * The root's subtree as the global cache knows it, root first. Drawn from the - * global cache rather than a live child store so archived descendants that - * never materialized in a directory store are still included. - */ -function getGlobalSubtree(rootSession: Session): OwnedSubtreeEntry[] { - const global = useGlobalSessionsStore.getState() - const sessionsById = new Map() - - for (const session of [...global.activeSessions, ...global.archivedSessions]) { - const current = sessionsById.get(session.id) - if (!current || Boolean(session.time?.archived)) sessionsById.set(session.id, session) - } - sessionsById.set(rootSession.id, rootSession) - - return [...computeSubtreeIds([...sessionsById.values()], rootSession.id)] - .map((id) => sessionsById.get(id)) - .filter((session): session is Session => Boolean(session)) - .map((session) => ({ session, ownedDirectory: resolveSessionOwnedDirectory(session) })) -} - -function getRestoreSubtree(rootSession: Session, sourceDirectory: string): Array<{ session: Session; sourceDirectory: string }> { - return getGlobalSubtree(rootSession) - // Keep a node while it is still archived or still stranded in the - // confirmed-missing worktree. The second clause matters on retry: a prior - // attempt may have already unarchived the root (server echo made it active) - // but failed to move it, so filtering on `archived` alone would drop the - // root and report a false success while it stays in the deleted worktree. - .filter((entry) => Boolean(entry.session.time?.archived) || entry.ownedDirectory === sourceDirectory) - .map((entry) => (entry.ownedDirectory ? { session: entry.session, sourceDirectory: entry.ownedDirectory } : null)) - .filter((entry): entry is { session: Session; sourceDirectory: string } => entry !== null) -} - -export type MissingDirectoryRelocation = - /** The session's directory is gone; its subtree now lives in the project directory. */ - | { status: "moved"; sourceDirectory: string; destinationDirectory: string; movedSessionIds: string[] } - /** The directory is available, its state is unknown, or the session has no project to move to. */ - | { status: "unchanged" } - /** The runtime changed while the relocation was in flight; nothing local was published. */ - | { status: "stale" } - /** A control-plane move failed; `movedSessionIds` already live in the destination. */ - | { status: "failed"; movedSessionIds: string[]; error: unknown } - -/** - * Move an active session whose worktree no longer exists into its project's - * primary directory. - * - * Same gate as the archived-session restore fallback: only a server-confirmed - * `missing` directory qualifies, the destination is the OpenCode project the - * session belongs to, and `available`, `unknown`, probe failures, and sessions - * without a project leave everything untouched. Every session of the root's - * subtree still stranded in that directory moves with it, root first, so the - * session the user is looking at is usable even if a descendant move fails. - * Moves carry no changes (`moveChanges: false`): the directory is gone, so - * there is nothing to carry. - */ -export async function relocateSessionFromMissingDirectory( - sessionId: string, - expectedRuntimeKey = getRuntimeKey(), -): Promise { - if (isStaleRuntime(expectedRuntimeKey)) return { status: "stale" } - const rootSession = getGlobalSessionSnapshot(sessionId) - if (!rootSession) return { status: "unchanged" } - - const relocation = await resolveMissingWorktreeRelocation(rootSession) - if (isStaleRuntime(expectedRuntimeKey)) return { status: "stale" } - if (!relocation) return { status: "unchanged" } - - const stranded = getGlobalSubtree(rootSession) - .filter((entry) => entry.ownedDirectory === relocation.sourceDirectory) - .map((entry) => entry.session) - const movedSessionIds: string[] = [] - for (const session of stranded) { - try { - await moveSessionToDirectory(session, relocation.sourceDirectory, relocation.destinationDirectory, false, expectedRuntimeKey) - } catch (error) { - console.error("[session-actions] relocateSessionFromMissingDirectory failed", error) - return { status: "failed", movedSessionIds, error } - } - if (isStaleRuntime(expectedRuntimeKey)) return { status: "stale" } - movedSessionIds.push(session.id) - } - return { status: "moved", ...relocation, movedSessionIds } -} - /** * Restore one archived session back to the active list. * @@ -1671,34 +1543,8 @@ export async function relocateSessionFromMissingDirectory( */ export async function unarchiveSession(sessionId: string, expectedRuntimeKey = getRuntimeKey()): Promise { if (isStaleRuntime(expectedRuntimeKey)) return false - const globalSession = getGlobalSessionSnapshot(sessionId) const sessionDirectory = getSessionDirectory(sessionId) try { - const restore = globalSession - ? await resolveMissingWorktreeRelocation(globalSession) - : null - if (isStaleRuntime(expectedRuntimeKey)) return false - - if (globalSession && restore) { - for (const { session, sourceDirectory } of getRestoreSubtree(globalSession, restore.sourceDirectory)) { - const restored = await opencodeClient.updateSession( - session.id, - { time: { archived: UNARCHIVED_TIMESTAMP } }, - sourceDirectory, - ) - if (isStaleRuntime(expectedRuntimeKey)) return false - if (!restored) { - throw new Error("session.update failed: server did not return the restored session") - } - if (restored.time?.archived) { - throw new Error("session.update failed: server kept the session archived") - } - await moveSessionToDirectory(restored, sourceDirectory, restore.destinationDirectory, false, expectedRuntimeKey) - if (isStaleRuntime(expectedRuntimeKey)) return false - } - return true - } - const restored = await opencodeClient.updateSession(sessionId, { time: { archived: UNARCHIVED_TIMESTAMP } }, sessionDirectory) if (isStaleRuntime(expectedRuntimeKey)) return false if (!restored) { diff --git a/packages/ui/src/sync/session-ui-store.test.js b/packages/ui/src/sync/session-ui-store.test.js index ef086eae..d95fcc8d 100644 --- a/packages/ui/src/sync/session-ui-store.test.js +++ b/packages/ui/src/sync/session-ui-store.test.js @@ -15,7 +15,6 @@ import { getDeferredSafeStorage } from '@/stores/utils/safeStorage'; import { CHAT_DRAFT_PROJECT_ID } from '@/lib/chatDirectories'; import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore'; import { useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore'; -import { subscribeWorktreeTopologyChanged } from '@/lib/worktrees/worktreeManager'; import { createContextPart } from '@/lib/messages/contextParts'; /** @@ -1347,52 +1346,17 @@ describe('missing session directory recovery', () => { useSessionUIStore.setState({ currentSessionId: null, currentSessionDirectory: null, worktreeMetadata: new Map() }); }); - test('moves the current session to its project, drops the worktree hint, and shares one attempt between callers', async () => { - const root = worktreeSession('root', missingWorktree); - const child = worktreeSession('child', missingWorktree, 'root'); - useGlobalSessionsStore.setState({ activeSessions: [root, child], archivedSessions: [] }); - useSessionUIStore.setState({ currentSessionId: 'root', currentSessionDirectory: missingWorktree }); - useSessionUIStore.getState().setWorktreeMetadata('root', { path: missingWorktree, branch: 'gone' }); - useSessionUIStore.getState().setWorktreeMetadata('child', { path: missingWorktree, branch: 'gone' }); - - const topologyChanges = []; - const unsubscribe = subscribeWorktreeTopologyChanged((directory) => topologyChanges.push(directory)); - const store = useSessionUIStore.getState(); - const [first, second] = await Promise.all([ - store.recoverMissingSessionDirectory('root'), - store.recoverMissingSessionDirectory('root'), - ]); - unsubscribe(); - - expect(first).toBe(second); - expect(topologyChanges).toEqual([projectDirectory]); - expect(first.status).toBe('moved'); - expect(moves.map((move) => move.sessionID)).toEqual(['root', 'child']); - expect(moves.every((move) => move.destination.directory === projectDirectory && move.moveChanges === false)).toBe(true); - expect(useSessionUIStore.getState().worktreeMetadata.has('root')).toBe(false); - expect(useSessionUIStore.getState().worktreeMetadata.has('child')).toBe(false); - expect(useSessionWorktreeStore.getState().getAttachment('root')).toBeUndefined(); - expect(useSessionUIStore.getState().getDirectoryForSession('root')).toBe(projectDirectory); - expect(useSessionUIStore.getState().currentSessionDirectory).toBe(projectDirectory); - expect(useDirectoryStore.getState().currentDirectory).toBe(projectDirectory); - }); - - test('probes a worktree session on activation and relocates it only when the directory is confirmed missing', async () => { + test('leaves a missing worktree session in place on activation and does not probe or relocate it', async () => { const root = worktreeSession('root', missingWorktree); useGlobalSessionsStore.setState({ activeSessions: [root], archivedSessions: [] }); - availability = 'available'; useSessionUIStore.getState().setCurrentSession('root', missingWorktree); await settle(); - expect(probes).toEqual([missingWorktree]); + + expect(probes).toEqual([]); expect(moves).toEqual([]); expect(useSessionUIStore.getState().currentSessionDirectory).toBe(missingWorktree); - - availability = 'missing'; - useSessionUIStore.getState().setCurrentSession('root', missingWorktree); - await settle(); - expect(moves.map((move) => move.sessionID)).toEqual(['root']); - expect(useSessionUIStore.getState().currentSessionDirectory).toBe(projectDirectory); + expect(useSessionUIStore.getState().getDirectoryForSession('root')).toBe(missingWorktree); }); test('never probes a session that lives in its project root or in a managed chat directory', async () => { diff --git a/packages/ui/src/sync/session-ui-store.ts b/packages/ui/src/sync/session-ui-store.ts index a1d8df90..ecaddacd 100644 --- a/packages/ui/src/sync/session-ui-store.ts +++ b/packages/ui/src/sync/session-ui-store.ts @@ -31,8 +31,7 @@ import { useSkillsStore } from "@/stores/useSkillsStore" import { getDeferredSafeStorage } from "@/stores/utils/safeStorage" import { markPendingUserSendAnimation } from "@/lib/userSendAnimation" import { normalizePath } from "@/lib/pathNormalization" -import type { ProjectEntry } from "@/lib/api/types" -import { CHAT_DRAFT_PROJECT_ID, createChatDirectory, deleteChatDirectory, getChatsRootFromDirectory, isChatDirectoryForHome, isChatDirectoryPath, warmChatsRootDirectory } from "@/lib/chatDirectories" +import { CHAT_DRAFT_PROJECT_ID, createChatDirectory, deleteChatDirectory, getChatsRootFromDirectory, isChatDirectoryPath, warmChatsRootDirectory } from "@/lib/chatDirectories" import { isVSCodeRuntime } from "@/lib/desktop" import { composeForkSessionMessage } from "@/lib/messages/executionMeta" import { findLatestUserModelChoice } from "@/lib/messages/userModelChoice" @@ -72,9 +71,7 @@ import { unrevertSession as unrevertSessionAction, forkFromMessage as forkFromMessageAction, fetchMessagesForSession, - relocateSessionFromMissingDirectory, type ArchiveSessionsOptions, - type MissingDirectoryRelocation, type DeleteSessionOptions, type DeleteSessionsOptions, type UnarchiveSessionsOptions, @@ -378,13 +375,6 @@ export type SessionUIState = { transition?: "submitted-draft", ) => void clearMaterializedDraftSession: (sessionId: string) => void - /** - * Move a session whose directory no longer exists (a worktree deleted - * outside OpenChamber) into its project directory. Concurrent calls for the - * same session share one attempt. Resolves `unchanged` when the directory is - * available, unknown, or the session has no project to move to. - */ - recoverMissingSessionDirectory: (sessionId: string) => Promise prepareForRuntimeSwitch: (apiBaseUrl?: string | null) => void restoreForRuntimeSwitch: (apiBaseUrl?: string | null) => void openNewSessionDraft: (options?: Partial & { automatic?: boolean }) => void @@ -768,27 +758,6 @@ const resolveCreatableDraftDirectory = async ( } } -const pendingDirectoryRecoveries = new Map>() - -/** - * Only a directory that is neither a registered project root nor a managed - * chat directory can be a deleted worktree. Project roots and chat directories - * have nowhere to relocate to, so they are never probed. - */ -const isRelocatableSessionDirectory = (directory: string, projects: readonly ProjectEntry[]): boolean => { - if (isChatDirectoryForHome(directory, useDirectoryStore.getState().homeDirectory)) return false - return !projects.some((project) => normalizePath(project.path) === directory) -} - -const notifySessionRelocated = async (destinationDirectory: string): Promise => { - const { toast } = await import("sonner") - const { useI18nStore, formatMessage } = await import("@/lib/i18n/store") - const project = useProjectsStore.getState().projects.find((entry) => normalizePath(entry.path) === destinationDirectory) - toast.info(formatMessage(useI18nStore.getState().dictionary, "sessions.missingDirectory.movedToProject", { - project: project?.label ?? destinationDirectory, - })) -} - const recoverStaleDraftDirectory = async (openedDraft: NewSessionDraftState): Promise => { const resolved = await resolveCreatableDraftDirectory(openedDraft, openedDraft.directoryOverride) if (resolved.status !== "ok") return @@ -1109,16 +1078,6 @@ export const useSessionUIStore = create()((set, get) => ({ console.warn("Failed to set OpenCode directory for session switch:", e) } - // A worktree session may have lost its directory while it was in the - // background. Probe on activation, the same way a reopened draft probes - // its inherited directory, so the session is relocated before its tabs - // and prompts run against a path that is gone. VS Code registers no - // worktrees, so every session there is its workspace root. - if (id && !isGuessedDir && resolvedDir && !isVSCodeRuntime() - && isRelocatableSessionDirectory(resolvedDir, projectsState.projects)) { - void get().recoverMissingSessionDirectory(id) - } - // Defer viewport anchor save for previous session — not needed for the // skeleton to render and reads messages which can be expensive. if (previousSessionId && previousSessionId !== id) { @@ -1210,39 +1169,7 @@ export const useSessionUIStore = create()((set, get) => ({ // --------------------------------------------------------------------------- // openNewSessionDraft // --------------------------------------------------------------------------- - recoverMissingSessionDirectory: (sessionId) => { - const runtimeKey = getRuntimeKey() - const key = `${runtimeKey}:${sessionId}` - const pending = pendingDirectoryRecoveries.get(key) - if (pending) return pending - const recovery = relocateSessionFromMissingDirectory(sessionId, runtimeKey) - .then(async (result) => { - if (result.status !== "moved" && result.status !== "failed") return result - // The worktree hint was the first thing every directory lookup read; - // with the worktree gone it would keep routing tabs to the dead path. - for (const movedId of result.movedSessionIds) { - get().setWorktreeMetadata(movedId, null) - } - if (result.status !== "moved") return result - if (get().currentSessionId === sessionId) { - // Re-select through the normal path so the active directory, project, - // and OpenCode client all follow the session to its new home. - get().setCurrentSession(sessionId, result.destinationDirectory) - } - // The server just confirmed a worktree directory is gone; the sidebar's - // worktree topology for that project is stale, so let it rediscover. - const { notifyWorktreeTopologyChanged } = await import("@/lib/worktrees/worktreeManager") - notifyWorktreeTopologyChanged(result.destinationDirectory) - await notifySessionRelocated(result.destinationDirectory) - return result - }) - .finally(() => { - pendingDirectoryRecoveries.delete(key) - }) - pendingDirectoryRecoveries.set(key, recovery) - return recovery - }, openNewSessionDraft: (options) => { // A USER-initiated draft open is a navigation choice: the next cold launch diff --git a/packages/vscode/src/DOCUMENTATION.md b/packages/vscode/src/DOCUMENTATION.md index fa988275..44f431e9 100644 --- a/packages/vscode/src/DOCUMENTATION.md +++ b/packages/vscode/src/DOCUMENTATION.md @@ -49,6 +49,7 @@ The webview build emits each worker as one self-contained file. VS Code webviews - `bridge-localfs-proxy-runtime.ts` - Local `/api/fs/read` and `/api/fs/raw` proxy helpers and shared proxy utility helpers. + - `/api/fs/directory-stat` returns 501 locally. Directory-availability probes remain unknown in VS Code rather than falling through to OpenCode. - Workspace-contained Markdown gallery images use these local filesystem routes without calling the server grant route. Grant requests for OpenCode temporary-directory images return an explicit unsupported response instead diff --git a/packages/vscode/src/bridge-localfs-proxy-runtime.test.js b/packages/vscode/src/bridge-localfs-proxy-runtime.test.js index 617eac7a..c551856f 100644 --- a/packages/vscode/src/bridge-localfs-proxy-runtime.test.js +++ b/packages/vscode/src/bridge-localfs-proxy-runtime.test.js @@ -61,6 +61,11 @@ describe('bridge local fs proxy', () => { expect(response?.status).toBe(404); }); + it('does not forward directory availability probes to OpenCode', async () => { + const response = await tryHandleLocalFsProxy('GET', '/api/fs/directory-stat?path=%2Fmissing-dir'); + expect(response?.status).toBe(501); + }); + it('reads from the active directory when it is the second workspace root', async () => { existingFiles.add('/workspace-two/image.png'); const response = await tryHandleLocalFsProxy( diff --git a/packages/vscode/src/bridge-localfs-proxy-runtime.ts b/packages/vscode/src/bridge-localfs-proxy-runtime.ts index ba1f3093..9254de54 100644 --- a/packages/vscode/src/bridge-localfs-proxy-runtime.ts +++ b/packages/vscode/src/bridge-localfs-proxy-runtime.ts @@ -56,6 +56,9 @@ export const tryHandleLocalFsProxy = async (method: string, requestPath: string) } const fsProxyPath = normalizeFsProxyPath(parsed.pathname); + if (parsed.pathname === '/api/fs/directory-stat') { + return buildProxyJsonError(501, 'Directory availability probes are not supported in the VS Code runtime'); + } if (/^\/api\/openchamber\/sessions\/[^/]+\/markdown-image-grants$/.test(parsed.pathname)) { return buildProxyJsonError(501, 'Markdown image grants are not supported in the VS Code runtime'); } diff --git a/packages/web/server/lib/fs/DOCUMENTATION.md b/packages/web/server/lib/fs/DOCUMENTATION.md index c3e7a797..774b7d3c 100644 --- a/packages/web/server/lib/fs/DOCUMENTATION.md +++ b/packages/web/server/lib/fs/DOCUMENTATION.md @@ -14,6 +14,8 @@ Own filesystem API behavior for the web server runtime, including workspace-boun - `POST /api/fs/mkdir` - `GET /api/fs/read` - `GET /api/fs/raw` + - `GET /api/fs/stat` + - `GET /api/fs/directory-stat` - `GET /api/fs/serve/:path(*)` - `POST /api/fs/write` - `POST /api/fs/upload` @@ -43,6 +45,7 @@ Own filesystem API behavior for the web server runtime, including workspace-boun - Workspace checks accept, besides the active workspace and its worktrees, the **managed roots**: the OpenChamber config root and the managed chats root (`managedChatsRoot` dependency; `OPENCHAMBER_CHATS_DIR` upstream, default `/chats`). Chat worktrees may legitimately live outside every project workspace. - `GET /api/fs/home` answers `{ home, chatsRoot }`. `chatsRoot` is the server-resolved managed chats root; clients must use it instead of joining `home` + the well-known segment (a relocated root does not contain that segment). - Filesystem `EPERM`/`EACCES` failures use the stable `reason: "os-permission"` response marker. Policy denials such as workspace-boundary or missing-grant failures must not use that marker because a native folder picker cannot remediate them. +- `GET /api/fs/directory-stat?path=...` uses one `stat` without listing contents or resolving project topology. It follows the same authenticated directory-discovery path policy as `/api/fs/list`, including targets outside the active workspace. A directory returns `{ isDirectory: true }`; `ENOENT` returns `not-found`, and a file or `ENOTDIR` returns `not-directory`. Permission and other failures remain distinct from a missing path. VS Code explicitly returns 501, so the shared client treats its probe as unknown. - Read-only routes authorize the requested path against the workspace before resolving symlinks. A symlink reached through the workspace may therefore target a file outside it, while a directly requested outside path still requires an exact-path grant. Write routes keep canonical-target boundary checks. - If adding new `/api/fs/*` endpoints, add them in `routes.js` and extend this document. - `GET /api/fs/list` may resolve symlinks with `realpath` to read directory contents, but the response `path` and each entry `path` must stay in the caller's requested path space (`path.join(requestedPath, name)`). Returning real paths breaks file-tree expansion for directories reached through workspace symlinks. diff --git a/packages/web/server/lib/fs/routes.js b/packages/web/server/lib/fs/routes.js index cda4ec5c..035f110e 100644 --- a/packages/web/server/lib/fs/routes.js +++ b/packages/web/server/lib/fs/routes.js @@ -864,6 +864,37 @@ export const registerFsRoutes = (app, dependencies) => { } }); + app.get('/api/fs/directory-stat', async (req, res) => { + res.setHeader('Cache-Control', 'no-store'); + const paths = new URL(req.url, 'http://openchamber.local').searchParams.getAll('path'); + const directoryPath = paths.length === 1 ? paths[0].trim() : ''; + if (!directoryPath) { + return res.status(400).json({ error: 'Path is required' }); + } + + try { + // Directory discovery uses the same path policy as /api/fs/list, including + // paths outside the current workspace. stat follows symlinks without readdir. + const resolvedPath = path.resolve(normalizeDirectoryPath(directoryPath)); + const stats = await fsPromises.stat(resolvedPath); + if (!stats.isDirectory()) { + return res.status(400).json({ error: 'Specified path is not a directory', reason: 'not-directory' }); + } + return res.json({ isDirectory: true }); + } catch (error) { + if (error.code === 'ENOENT' || error.code === 'ENOTDIR') { + return res.status(error.code === 'ENOENT' ? 404 : 400).json({ + error: error.code === 'ENOENT' ? 'Directory not found' : 'Specified path is not a directory', + reason: error.code === 'ENOENT' ? 'not-found' : 'not-directory', + }); + } + if (isOsPermissionError(error)) { + return sendOsPermissionDenied(res, 'Access to directory denied'); + } + return res.status(500).json({ error: 'Failed to stat directory' }); + } + }); + app.get('/api/fs/stat', async (req, res) => { const filePath = typeof req.query.path === 'string' ? req.query.path.trim() : ''; const optional = req.query.optional === 'true'; diff --git a/packages/web/server/lib/fs/routes.test.js b/packages/web/server/lib/fs/routes.test.js index d592d532..66cc0f38 100644 --- a/packages/web/server/lib/fs/routes.test.js +++ b/packages/web/server/lib/fs/routes.test.js @@ -1,5 +1,9 @@ import { EventEmitter } from 'events'; import path from 'path'; +import { copyFile, mkdir, mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { execFileSync } from 'node:child_process'; +import { pathToFileURL } from 'node:url'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { mintOutsideFileGrant, registerFsRoutes } from './routes.js'; @@ -1385,7 +1389,11 @@ describe('fs stat directory scope (issue 3019)', () => { path: path.posix, fsPromises: { realpath: async (targetPath) => targetPath, - stat: async () => ({ isFile: () => true, size: 12 }), + stat: async (targetPath) => ( + targetPath === '/repo-b' + ? { isDirectory: () => true, mtimeMs: 123 } + : { isFile: () => true, size: 12, mtimeMs: 456 } + ), }, spawn: vi.fn(), crypto: { randomUUID: () => 'job-0' }, @@ -1428,6 +1436,107 @@ describe('fs stat directory scope (issue 3019)', () => { expect(res.statusCode).toBe(200); expect(res.body.isFile).toBe(true); }); + +}); + +describe('fs stat directory error handling', () => { + it('loads in Node without workspace node_modules, as packaged desktop does', async () => { + const directory = await mkdtemp(path.join(tmpdir(), 'openchamber-fs-import-')); + try { + await mkdir(path.join(directory, 'fs')); + await copyFile(new URL('./routes.js', import.meta.url), path.join(directory, 'fs/routes.mjs')); + await copyFile(new URL('../path-realpath-cache.js', import.meta.url), path.join(directory, 'path-realpath-cache.js')); + expect(() => execFileSync('node', [ + '--input-type=module', + '--eval', + 'await import(process.argv[1])', + pathToFileURL(path.join(directory, 'fs/routes.mjs')).href, + ], { cwd: directory, stdio: 'pipe' })).not.toThrow(); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); + + it('returns directory-missing reasons and permission errors for directory stat', async () => { + const { app, getRoute } = createRouteRegistry(); + const enoent = Object.assign(new Error('missing'), { code: 'ENOENT' }); + const enotdir = Object.assign(new Error('not a directory'), { code: 'ENOTDIR' }); + const eacces = Object.assign(new Error('denied'), { code: 'EACCES' }); + const stat = vi.fn(async (targetPath) => { + if (targetPath === '/repo-b') throw enoent; + if (targetPath === '/repo-b/file.txt/child') throw enotdir; + if (targetPath === '/repo-b/protected') throw eacces; + if (targetPath === '/repo-b/file.txt') return { isDirectory: () => false }; + if (targetPath === '/repo-b/failure') throw new Error('unavailable'); + return { isDirectory: () => true, mtimeMs: 1 }; + }); + const readdir = vi.fn(async () => []); + const callStat = async (handler, { headers = {}, query }) => { + const res = createMockResponse(); + const req = { + url: `/api/fs/directory-stat?${new URLSearchParams(query)}`, + query, + get: (name) => headers[name.toLowerCase()] ?? undefined, + }; + await handler(req, res); + return res; + }; + registerFsRoutes(app, { + os: { homedir: () => '/home/user' }, + path: path.posix, + fsPromises: { + realpath: async (targetPath) => targetPath, + stat, + readdir, + }, + spawn: vi.fn(), + crypto: { randomUUID: () => 'job-0' }, + normalizeDirectoryPath: (p) => p, + resolveProjectDirectory: async () => ({ directory: '/repo' }), + buildAugmentedPath: () => '/usr/bin', + resolveGitBinaryForSpawn: () => 'git', + openchamberUserConfigRoot: '/home/user/.config', + }); + const handler = getRoute('GET', '/api/fs/directory-stat'); + + const available = await callStat(handler, { query: { path: '/other-project' } }); + expect(available.statusCode).toBe(200); + expect(available.body).toEqual({ isDirectory: true }); + expect(available.getHeader('Cache-Control')).toBe('no-store'); + expect(stat).toHaveBeenCalledTimes(1); + + const invalid = await callStat(handler, { query: { path: ' ' } }); + expect(invalid.statusCode).toBe(400); + expect(stat).toHaveBeenCalledTimes(1); + + for (const query of ['path=/repo&path=/other', 'path[]=/repo', '']) { + const malformed = createMockResponse(); + await handler({ url: `/api/fs/directory-stat?${query}` }, malformed); + expect(malformed.statusCode).toBe(400); + } + expect(stat).toHaveBeenCalledTimes(1); + + const missing = await callStat(handler, { headers: { 'x-opencode-directory': '/repo-b' }, query: { path: '/repo-b', directory: 'true' } }); + expect(missing.statusCode).toBe(404); + expect(missing.body).toEqual({ error: 'Directory not found', reason: 'not-found' }); + + const notDir = await callStat(handler, { headers: { 'x-opencode-directory': '/repo-b' }, query: { path: '/repo-b/file.txt/child', directory: 'true' } }); + expect(notDir.statusCode).toBe(400); + expect(notDir.body).toEqual({ error: 'Specified path is not a directory', reason: 'not-directory' }); + + const denied = await callStat(handler, { headers: { 'x-opencode-directory': '/repo-b' }, query: { path: '/repo-b/protected', directory: 'true' } }); + expect(denied.statusCode).toBe(403); + expect(denied.body).toEqual({ error: 'Access to directory denied', reason: 'os-permission' }); + + const file = await callStat(handler, { query: { path: '/repo-b/file.txt' } }); + expect(file.statusCode).toBe(400); + expect(file.body.reason).toBe('not-directory'); + + const failure = await callStat(handler, { query: { path: '/repo-b/failure' } }); + expect(failure.statusCode).toBe(500); + expect(failure.body).toEqual({ error: 'Failed to stat directory' }); + expect(readdir).not.toHaveBeenCalled(); + }); }); describe('fs managed chats root', () => { diff --git a/packages/web/server/lib/terminal/DOCUMENTATION.md b/packages/web/server/lib/terminal/DOCUMENTATION.md index 4ecd3b24..730b0b9f 100644 --- a/packages/web/server/lib/terminal/DOCUMENTATION.md +++ b/packages/web/server/lib/terminal/DOCUMENTATION.md @@ -35,7 +35,7 @@ HTTP remains the authenticated command plane for create, resize, appearance upda - Scrollback is retained on the server and capped at 512 KiB with UTF-8-safe trimming. Device-status, device-attribute, cursor-position reply, and color-query exchanges are removed from replay history with incomplete control sequences carried across PTY chunks; live output remains byte-for-byte unchanged. - Exited sessions remain attachable until explicit close, idle cleanup, or a successful replacement of the same project action. Creating a replacement retires only exited records for the same resolved directory and action, after the new PTY starts. Failed creation preserves the old record and output. These replaced records do not exhaust the terminal capacity limit. - Deduplicated create responses may describe another client's execution. Cancellation cleanup closes only the terminal ID allocated for the cancelled request; it never closes an adopted peer execution. -- Create and restart validate the working directory with a real `stat` and answer HTTP 400 `Invalid working directory` when it is not a directory. When the path does not exist at all (`ENOENT`/`ENOTDIR`, a worktree deleted outside OpenChamber) the body also carries `code: "TERMINAL_CWD_MISSING"`. That is the one rejection the client can recover from: the session, not the terminal, is stranded, and the shared UI moves it to its project directory and starts a terminal there. Every other rejection stays generic; the runtime never substitutes a parent directory on its own. +- Create and restart validate the working directory with a real `stat` and answer HTTP 400 `Invalid working directory` when it is not a directory. When the path does not exist at all (`ENOENT`/`ENOTDIR`, a worktree deleted outside OpenChamber) the body also carries `code: "TERMINAL_CWD_MISSING"`. The client shows the failure without moving the session. The runtime never substitutes a parent directory on its own. - Restarts are serialized per terminal. Each restart spawns and wires the replacement before terminating the old process, retaining the terminal ID. Command-mode sessions reject restart with HTTP 400 instead of silently turning into interactive shells with stale action metadata. - A delete that arrives while create is still pending leaves a cancellation tombstone. When the PTY arrives, the runtime terminates it immediately, never inserts the session into the live map, and returns a create error while the delete still succeeds. - Close uses SIGTERM with bounded SIGKILL escalation. Force-kill, idle cleanup, and runtime shutdown terminate process groups immediately where supported. Removal explicitly sends a fatal scoped closure and evicts client projections even when a PTY backend fails to emit `onExit`; attached terminals are not considered idle. From 39fa8c1917da4bab29de2acdd4c0324e4cff297e Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Mon, 7 Sep 2026 12:18:22 +0300 Subject: [PATCH 07/94] feat(terminal): replace ghostty-web with an in-repo libghostty-vt adapter The terminal ran on the ghostty-web npm package plus a hand-written patch, and every rendering bug (recycled rows, duplicated reflow fragments, prompt artifacts) had to be worked around from outside. The emulator now is the official libghostty-vt C ABI compiled to WebAssembly, driven by a browser adapter ported from T3 Code (MIT, notice in LICENSE-T3CODE) and owned in packages/ui/src/lib/ghostty. The artifact is reproducible with scripts/build-libghostty-wasm.sh, including a workaround for Zig 0.15.2 on macOS 27 SDKs. On top of the port: one WASM instance per page with every tab kept mounted and hidden tabs paused; history replayed at the PTY size it was drawn for; shells spawned only after the first fitted grid so zsh never prints the PROMPT_SP marker; box drawing, block elements and Powerline arrows drawn procedurally to the exact cell so TUI borders and block logos have no gaps between rows; a software-rasterized canvas so Gecko renders every tab's text with the same smoothing; the symbols-only Nerd Font bundled instead of a CDN fetch; touch selection and scrolling driven through the surface API; a copy button in the tab strip for touch hosts; localized aria labels. Testing: bun tests run the real WASM (reflow, palette, replay isolation, recycled rows, box glyph geometry); viewport and view tests use a surface double; verified in Chromium and Zen (windowed and headless) for crisp text, new tabs, panel reopen, resize and box glyph rendering; package type-check, oxlint/eslint on new files, web build. --- README.md | 2 +- bun.lock | 5 - package.json | 1 - packages/ui/package.json | 2 +- packages/ui/scripts/build-libghostty-wasm.sh | 174 ++ packages/ui/scripts/ghostty-write-pty.zig | 12 + .../ui/src/components/layout/ContextPanel.tsx | 4 +- .../contextPanelEscapeClosesTerminal.test.ts | 2 +- .../terminal/TerminalViewport.test.tsx | 229 +- .../components/terminal/TerminalViewport.tsx | 532 ++--- .../components/views/TerminalView.test.tsx | 9 +- .../ui/src/components/views/TerminalView.tsx | 297 ++- .../__tests__/terminalViewportRemount.test.ts | 50 +- packages/ui/src/index.css | 79 +- packages/ui/src/lib/ghostty/DOCUMENTATION.md | 45 + packages/ui/src/lib/ghostty/LICENSE-T3CODE | 25 + .../ui/src/lib/ghostty/boxDrawing.test.ts | 162 ++ packages/ui/src/lib/ghostty/boxDrawing.ts | 367 +++ packages/ui/src/lib/ghostty/core.test.ts | 158 ++ packages/ui/src/lib/ghostty/core.ts | 1288 +++++++++++ packages/ui/src/lib/ghostty/fonts.test.ts | 27 + packages/ui/src/lib/ghostty/fonts.ts | 73 + packages/ui/src/lib/ghostty/fonts/LICENSE | 21 + .../fonts/SymbolsNerdFontMono-Regular.woff2 | Bin 0 -> 1177576 bytes packages/ui/src/lib/ghostty/keyCodes.test.ts | 67 + packages/ui/src/lib/ghostty/keyCodes.ts | 269 +++ packages/ui/src/lib/ghostty/renderer.test.ts | 324 +++ packages/ui/src/lib/ghostty/renderer.ts | 331 +++ packages/ui/src/lib/ghostty/runtime.test.ts | 52 + packages/ui/src/lib/ghostty/runtime.ts | 260 +++ packages/ui/src/lib/ghostty/surface.test.ts | 179 ++ packages/ui/src/lib/ghostty/surface.ts | 2048 +++++++++++++++++ .../ui/src/lib/ghostty/terminalLinks.test.ts | 41 + packages/ui/src/lib/ghostty/terminalLinks.ts | 113 + packages/ui/src/lib/ghostty/vendor/LICENSE | 21 + packages/ui/src/lib/ghostty/vendor/VERSION | 1 + .../ui/src/lib/ghostty/vendor/ghostty-vt.wasm | Bin 0 -> 630932 bytes packages/ui/src/lib/i18n/messages/de.ts | 4 + packages/ui/src/lib/i18n/messages/en.ts | 4 + packages/ui/src/lib/i18n/messages/es.ts | 4 + packages/ui/src/lib/i18n/messages/fr.ts | 4 + packages/ui/src/lib/i18n/messages/ja.ts | 4 + packages/ui/src/lib/i18n/messages/ko.ts | 4 + packages/ui/src/lib/i18n/messages/pl.ts | 4 + packages/ui/src/lib/i18n/messages/pt-BR.ts | 4 + packages/ui/src/lib/i18n/messages/tr.ts | 4 + packages/ui/src/lib/i18n/messages/uk.ts | 4 + packages/ui/src/lib/i18n/messages/zh-CN.ts | 4 + packages/ui/src/lib/i18n/messages/zh-TW.ts | 4 + packages/ui/src/lib/terminalOutput.test.ts | 30 - packages/ui/src/lib/terminalOutput.ts | 52 - packages/ui/src/lib/terminalTheme.ts | 87 +- .../ui/src/lib/terminalTouchSelection.test.ts | 23 - packages/ui/src/lib/terminalTouchSelection.ts | 48 - packages/ui/src/stores/useTerminalStore.ts | 2 +- packages/ui/src/types/ghostty-web.d.ts | 11 - packages/ui/src/vite-env.d.ts | 1 - packages/web/index.html | 38 - packages/web/package.json | 1 - packages/web/vite.config.ts | 2 +- .../ghostty-web+0.4.0-next.20.g1858a59.patch | 55 - 61 files changed, 6678 insertions(+), 990 deletions(-) create mode 100755 packages/ui/scripts/build-libghostty-wasm.sh create mode 100644 packages/ui/scripts/ghostty-write-pty.zig create mode 100644 packages/ui/src/lib/ghostty/DOCUMENTATION.md create mode 100644 packages/ui/src/lib/ghostty/LICENSE-T3CODE create mode 100644 packages/ui/src/lib/ghostty/boxDrawing.test.ts create mode 100644 packages/ui/src/lib/ghostty/boxDrawing.ts create mode 100644 packages/ui/src/lib/ghostty/core.test.ts create mode 100644 packages/ui/src/lib/ghostty/core.ts create mode 100644 packages/ui/src/lib/ghostty/fonts.test.ts create mode 100644 packages/ui/src/lib/ghostty/fonts.ts create mode 100644 packages/ui/src/lib/ghostty/fonts/LICENSE create mode 100644 packages/ui/src/lib/ghostty/fonts/SymbolsNerdFontMono-Regular.woff2 create mode 100644 packages/ui/src/lib/ghostty/keyCodes.test.ts create mode 100644 packages/ui/src/lib/ghostty/keyCodes.ts create mode 100644 packages/ui/src/lib/ghostty/renderer.test.ts create mode 100644 packages/ui/src/lib/ghostty/renderer.ts create mode 100644 packages/ui/src/lib/ghostty/runtime.test.ts create mode 100644 packages/ui/src/lib/ghostty/runtime.ts create mode 100644 packages/ui/src/lib/ghostty/surface.test.ts create mode 100644 packages/ui/src/lib/ghostty/surface.ts create mode 100644 packages/ui/src/lib/ghostty/terminalLinks.test.ts create mode 100644 packages/ui/src/lib/ghostty/terminalLinks.ts create mode 100644 packages/ui/src/lib/ghostty/vendor/LICENSE create mode 100644 packages/ui/src/lib/ghostty/vendor/VERSION create mode 100644 packages/ui/src/lib/ghostty/vendor/ghostty-vt.wasm delete mode 100644 packages/ui/src/lib/terminalOutput.test.ts delete mode 100644 packages/ui/src/lib/terminalOutput.ts delete mode 100644 packages/ui/src/lib/terminalTouchSelection.test.ts delete mode 100644 packages/ui/src/lib/terminalTouchSelection.ts delete mode 100644 packages/ui/src/types/ghostty-web.d.ts delete mode 100644 patches/ghostty-web+0.4.0-next.20.g1858a59.patch diff --git a/README.md b/README.md index 8d6b35b1..79fe45e0 100644 --- a/README.md +++ b/README.md @@ -152,7 +152,7 @@ Special thanks to: - [OpenCode](https://opencode.ai) for the API and open-source architecture OpenChamber builds on - [Pierre](https://pierrejs-docs.vercel.app/) for the diff viewer and syntax highlighting -- [Ghostty-web](https://github.com/coder/ghostty-web) for its Ghostty web renderer +- The [T3 Code](https://github.com/pingdotgg/t3code) team for their browser adapter for [libghostty-vt](https://github.com/ghostty-org/ghostty), which our terminal is built on - [Yulia Ivashko](https://github.com/yulia-ivashko), who built the firework celebration that plays on every successful push - Everyone who contributed code, reported bugs, or shared ideas diff --git a/bun.lock b/bun.lock index 7cf18484..e2b18c35 100644 --- a/bun.lock +++ b/bun.lock @@ -47,7 +47,6 @@ "clsx": "^2.1.1", "cmdk": "^1.1.1", "express": "^5.1.0", - "ghostty-web": "0.4.0-next.20.g1858a59", "http-proxy-middleware": "^3.0.5", "next-themes": "^0.4.6", "node-pty": "1.2.0-beta.12", @@ -185,7 +184,6 @@ "express": "^5.1.0", "fflate": "^0.8.3", "fuse.js": "^7.1.0", - "ghostty-web": "0.4.0-next.20.g1858a59", "heic2any": "^0.0.4", "html-to-image": "^1.11.13", "http-proxy-middleware": "^3.0.5", @@ -323,7 +321,6 @@ "eslint": "^9.33.0", "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.5.0", - "ghostty-web": "0.4.0-next.20.g1858a59", "globals": "^16.3.0", "next-themes": "^0.4.6", "nodemon": "^3.1.7", @@ -2122,8 +2119,6 @@ "get-tsconfig": ["get-tsconfig@4.13.6", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw=="], - "ghostty-web": ["ghostty-web@0.4.0-next.20.g1858a59", "", {}, "sha512-NXA9H3IJlx+DGJukXbOPQWFkigYdAatTqkoIvM8tvhfbaYoDf3gGKxXLLyXtLYOBt5qzGdEZa294TU36gULjVg=="], - "github-from-package": ["github-from-package@0.0.0", "", {}, "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw=="], "glob": ["glob@11.1.0", "", { "dependencies": { "foreground-child": "^3.3.1", "jackspeak": "^4.1.1", "minimatch": "^10.1.1", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw=="], diff --git a/package.json b/package.json index 94e6d6f1..29f6978a 100644 --- a/package.json +++ b/package.json @@ -135,7 +135,6 @@ "clsx": "^2.1.1", "cmdk": "^1.1.1", "express": "^5.1.0", - "ghostty-web": "0.4.0-next.20.g1858a59", "http-proxy-middleware": "^3.0.5", "next-themes": "^0.4.6", "node-pty": "1.2.0-beta.12", diff --git a/packages/ui/package.json b/packages/ui/package.json index 364a9f83..76561272 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -7,6 +7,7 @@ "scripts": { "dev": "tsc --noEmit --watch", "build": "tsc --noEmit", + "build:ghostty-wasm": "bash scripts/build-libghostty-wasm.sh", "type-check": "tsc --noEmit", "lint": "eslint \"./src/**/*.{ts,tsx}\" --config ../../eslint.config.js", "test": "node ../../scripts/run-isolated-tests.mjs src" @@ -61,7 +62,6 @@ "express": "^5.1.0", "fflate": "^0.8.3", "fuse.js": "^7.1.0", - "ghostty-web": "0.4.0-next.20.g1858a59", "heic2any": "^0.0.4", "html-to-image": "^1.11.13", "http-proxy-middleware": "^3.0.5", diff --git a/packages/ui/scripts/build-libghostty-wasm.sh b/packages/ui/scripts/build-libghostty-wasm.sh new file mode 100755 index 00000000..15f15b91 --- /dev/null +++ b/packages/ui/scripts/build-libghostty-wasm.sh @@ -0,0 +1,174 @@ +#!/usr/bin/env bash +# +# Rebuilds the vendored libghostty-vt WebAssembly artifact from the Ghostty +# revision pinned in src/lib/ghostty/vendor/VERSION, plus the PTY write +# trampoline whose bytes are embedded in src/lib/ghostty/runtime.ts. +# +# Usage: bun run --cwd packages/ui build:ghostty-wasm +# +# The build is reproducible: the same revision and Zig version produce a +# byte-identical ghostty-vt.wasm. Bump VERSION, run this script, and commit the +# new artifact together with any ABI changes in core.ts. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +UI_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" +GHOSTTY_DIR="${UI_DIR}/src/lib/ghostty" +VENDOR_DIR="${GHOSTTY_DIR}/vendor" + +GHOSTTY_REVISION="$(tr -d '[:space:]' < "${VENDOR_DIR}/VERSION")" +CACHE_DIR="${OPENCHAMBER_GHOSTTY_CACHE:-${HOME}/.cache/openchamber-ghostty}" +GHOSTTY_SOURCE_DIR="${GHOSTTY_SOURCE_DIR:-${CACHE_DIR}/ghostty-${GHOSTTY_REVISION:0:8}}" +GHOSTTY_ZIG_VERSION="${GHOSTTY_ZIG_VERSION:-0.15.2}" +GHOSTTY_ZIG="${GHOSTTY_ZIG:-}" + +log() { + printf '[libghostty-vt-wasm] %s\n' "$*" +} + +die() { + printf '[libghostty-vt-wasm] error: %s\n' "$*" >&2 + exit 1 +} + +require_cmd() { + command -v "$1" >/dev/null 2>&1 || die "missing required command: $1" +} + +ensure_zig() { + if [[ -n "${GHOSTTY_ZIG}" ]]; then + [[ -x "${GHOSTTY_ZIG}" ]] || die "GHOSTTY_ZIG is not executable: ${GHOSTTY_ZIG}" + return + fi + if command -v zig >/dev/null 2>&1 && [[ "$(zig version)" == "${GHOSTTY_ZIG_VERSION}" ]]; then + GHOSTTY_ZIG="$(command -v zig)" + return + fi + + local host_os host_arch zig_dir + host_os="$(uname -s | tr '[:upper:]' '[:lower:]')" + host_arch="$(uname -m)" + case "${host_os}" in + darwin) host_os="macos" ;; + linux) ;; + *) die "unsupported host OS for Zig download: ${host_os}" ;; + esac + case "${host_arch}" in + arm64) host_arch="aarch64" ;; + aarch64 | x86_64) ;; + *) die "unsupported host architecture: ${host_arch}" ;; + esac + + zig_dir="${CACHE_DIR}/zig-${GHOSTTY_ZIG_VERSION}" + GHOSTTY_ZIG="${zig_dir}/zig" + if [[ -x "${GHOSTTY_ZIG}" ]]; then + return + fi + + require_cmd curl + require_cmd tar + mkdir -p "${zig_dir}" + log "downloading Zig ${GHOSTTY_ZIG_VERSION}" + curl -fsSL \ + "https://ziglang.org/download/${GHOSTTY_ZIG_VERSION}/zig-${host_arch}-${host_os}-${GHOSTTY_ZIG_VERSION}.tar.xz" \ + | tar -xJ --strip-components=1 -C "${zig_dir}" +} + +# Zig 0.15.2 links its build runner against the macOS SDK's libSystem stub. +# SDKs shipped with Xcode 26.x and later list only `arm64e-macos` in that +# stub, which Zig rejects for an arm64 host, so every native link fails with +# "undefined symbol: _abort". The wasm target itself is unaffected. Work around +# it with a minimal SDK root whose stubs also declare `arm64-macos`, and an +# xcrun shim so Zig's SDK lookup lands on it. +ensure_macos_sdk_shim() { + [[ "$(uname -s)" == "Darwin" ]] || return 0 + local sdk_path + sdk_path="$(xcrun --sdk macosx --show-sdk-path 2>/dev/null || true)" + [[ -n "${sdk_path}" ]] || die "xcrun could not locate a macOS SDK; install the Command Line Tools" + if grep -q "arm64-macos" "${sdk_path}/usr/lib/libSystem.tbd" 2>/dev/null; then + return 0 + fi + + local shim_root="${CACHE_DIR}/sdk-shim" + local shim_sdk="${shim_root}/MacOSX.sdk" + rm -rf "${shim_root}" + mkdir -p "${shim_sdk}/usr/lib/system" "${shim_root}/bin" + cp "${sdk_path}"/SDKSettings.* "${shim_sdk}/" 2>/dev/null || true + ln -s "${sdk_path}/usr/include" "${shim_sdk}/usr/include" + cp "${sdk_path}"/usr/lib/*.tbd "${shim_sdk}/usr/lib/" + cp "${sdk_path}"/usr/lib/system/*.tbd "${shim_sdk}/usr/lib/system/" + local stub + for stub in "${shim_sdk}"/usr/lib/*.tbd "${shim_sdk}"/usr/lib/system/*.tbd; do + sed -i '' 's/arm64e-macos/arm64-macos, arm64e-macos/g' "${stub}" + done + cat > "${shim_root}/bin/xcrun" </dev/null || echo none)" + if [[ "${actual_revision}" != "${GHOSTTY_REVISION}" ]]; then + log "checking out Ghostty ${GHOSTTY_REVISION}" + git -C "${GHOSTTY_SOURCE_DIR}" fetch --depth=1 origin "${GHOSTTY_REVISION}" + git -C "${GHOSTTY_SOURCE_DIR}" checkout --detach "${GHOSTTY_REVISION}" + fi + + actual_revision="$(git -C "${GHOSTTY_SOURCE_DIR}" rev-parse HEAD)" + [[ "${actual_revision}" == "${GHOSTTY_REVISION}" ]] || \ + die "expected Ghostty ${GHOSTTY_REVISION}, found ${actual_revision}" +} + +ensure_zig +ensure_macos_sdk_shim +ensure_ghostty_source + +build_root="$(mktemp -d)" +trap 'rm -rf "${build_root}"' EXIT + +log "building ${GHOSTTY_REVISION} for wasm32-freestanding" +( + cd "${GHOSTTY_SOURCE_DIR}" + # The pinned revision rides along as semver build metadata so the artifact + # identifies its own provenance through ghostty_build_info(); VERSION stays + # the single source of truth for the pin and the ABI test checks the two agree. + "${GHOSTTY_ZIG}" build \ + -Demit-lib-vt \ + -Dtarget=wasm32-freestanding \ + -Doptimize=ReleaseSmall \ + -Dstrip=true \ + -Dlib-version-string="0.1.0-dev+${GHOSTTY_REVISION}" \ + -p "${build_root}" +) + +cp "${build_root}/bin/ghostty-vt.wasm" "${VENDOR_DIR}/ghostty-vt.wasm" +chmod 0644 "${VENDOR_DIR}/ghostty-vt.wasm" +log "wrote ${VENDOR_DIR}/ghostty-vt.wasm" + +"${GHOSTTY_ZIG}" build-exe \ + "${SCRIPT_DIR}/ghostty-write-pty.zig" \ + -target wasm32-freestanding \ + -O ReleaseSmall \ + -fno-entry \ + -rdynamic \ + -femit-bin="${build_root}/ghostty-write-pty.wasm" +log "PTY trampoline bytes for runtime.ts (WRITE_PTY_TRAMPOLINE):" +od -An -v -tu1 "${build_root}/ghostty-write-pty.wasm" | tr -s ' \n' ' ' | sed 's/^ //; s/ $//; s/ /, /g' +echo diff --git a/packages/ui/scripts/ghostty-write-pty.zig b/packages/ui/scripts/ghostty-write-pty.zig new file mode 100644 index 00000000..466524fe --- /dev/null +++ b/packages/ui/scripts/ghostty-write-pty.zig @@ -0,0 +1,12 @@ +// Callback trampoline for libghostty-vt's write-PTY option. +// +// libghostty-vt calls the PTY writer through its indirect function table, so +// the JavaScript host cannot pass a closure directly. This 112-byte module +// exports one function whose only job is to forward the call to an import the +// host implements. `build-libghostty-wasm.sh` compiles it and prints the bytes +// that `runtime.ts` embeds, so the browser never fetches it separately. +extern "env" fn openchamber_write_pty(terminal: u32, userdata: u32, data: u32, len: u32) void; + +export fn ghostty_write_pty(terminal: u32, userdata: u32, data: u32, len: u32) void { + openchamber_write_pty(terminal, userdata, data, len); +} diff --git a/packages/ui/src/components/layout/ContextPanel.tsx b/packages/ui/src/components/layout/ContextPanel.tsx index 2d5b1920..0b7eef5d 100644 --- a/packages/ui/src/components/layout/ContextPanel.tsx +++ b/packages/ui/src/components/layout/ContextPanel.tsx @@ -680,8 +680,8 @@ export const ContextPanel: React.FC = () => { } // Terminal owns Escape so the PTY receives it (e.g. Vim Normal mode). - // ghostty-web listens in the bubble phase; stopping capture here would - // swallow the key before the terminal ever sees it (issue #2644). + // The terminal input listens in the bubble phase; stopping capture here + // would swallow the key before the terminal ever sees it (issue #2644). if (isTerminalEventTarget(event.target)) { return; } diff --git a/packages/ui/src/components/layout/__tests__/contextPanelEscapeClosesTerminal.test.ts b/packages/ui/src/components/layout/__tests__/contextPanelEscapeClosesTerminal.test.ts index 63bca320..37bbf57a 100644 --- a/packages/ui/src/components/layout/__tests__/contextPanelEscapeClosesTerminal.test.ts +++ b/packages/ui/src/components/layout/__tests__/contextPanelEscapeClosesTerminal.test.ts @@ -35,7 +35,7 @@ describe('issue #2644: Escape in terminal must not close the context panel', () expect(handler).toContain('event.stopPropagation()'); expect(handler).toContain('handleClose()'); - // Guard must return before preventDefault/stopPropagation so ghostty-web's + // Guard must return before preventDefault/stopPropagation so the terminal input's // bubble-phase keydown listener can forward Escape to the PTY. const guardIndex = handler.indexOf('isTerminalEventTarget(event.target)'); const preventIndex = handler.indexOf('event.preventDefault()'); diff --git a/packages/ui/src/components/terminal/TerminalViewport.test.tsx b/packages/ui/src/components/terminal/TerminalViewport.test.tsx index 7ee8e582..7878b7e8 100644 --- a/packages/ui/src/components/terminal/TerminalViewport.test.tsx +++ b/packages/ui/src/components/terminal/TerminalViewport.test.tsx @@ -1,59 +1,58 @@ -import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'; -import React, { act } from 'react'; +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { act } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { Window } from 'happy-dom'; +import { I18nProvider } from '@/lib/i18n'; import { useTerminalStore, type TerminalChunk } from '@/stores/useTerminalStore'; +import { TerminalViewport, type TerminalSurface, type TerminalSurfaceFactory } from './TerminalViewport'; + type TerminalEvent = | { type: 'write'; data: string } - | { type: 'reset' } - | { type: 'resize'; cols: number; rows: number }; + | { type: 'reset'; data: string; size?: { cols: number; rows: number } } + | { type: 'visible'; visible: boolean } + | { type: 'dispose' }; const terminalEvents: TerminalEvent[] = []; -class GhosttyTerminalDouble { - public options: { cursorBlink: boolean }; - public cols = 80; - public rows = 24; - - constructor(options: { cursorBlink?: boolean; cols?: number; rows?: number }) { - this.options = { cursorBlink: options.cursorBlink ?? false }; - this.cols = options.cols ?? 80; - this.rows = options.rows ?? 24; - } - - loadAddon() {} - open() {} - onData() { - return { dispose() {} }; - } - write(data: string, callback?: () => void) { +class TerminalSurfaceDouble implements TerminalSurface { + write(data: string) { terminalEvents.push({ type: 'write', data }); - callback?.(); } - resize(cols: number, rows: number) { - this.cols = cols; - this.rows = rows; - terminalEvents.push({ type: 'resize', cols, rows }); + resetAndWrite(data: string, drawnSize?: { readonly cols: number; readonly rows: number }) { + const event: TerminalEvent = { type: 'reset', data }; + if (drawnSize) event.size = { cols: drawnSize.cols, rows: drawnSize.rows }; + terminalEvents.push(event); } - reset() { - terminalEvents.push({ type: 'reset' }); + setTheme() {} + setFont() { + return Promise.resolve(); } + setVisible(visible: boolean) { + terminalEvents.push({ type: 'visible', visible }); + } + fit() { + return true; + } + refresh() {} focus() {} - dispose() {} + getSelection() { + return ''; + } + getSelectionPosition() { + return null; + } + scrollLines() {} + selectWordAt() { + return false; + } + extendSelectionTo() {} + dispose() { + terminalEvents.push({ type: 'dispose' }); + } } -class FitAddonDouble { - fit() {} -} - -mock.module('ghostty-web', () => ({ - Ghostty: { load: async () => ({}) }, - Terminal: GhosttyTerminalDouble, - FitAddon: FitAddonDouble, -})); - -const { TerminalViewport } = await import('./TerminalViewport'); +const createSurface: TerminalSurfaceFactory = () => Promise.resolve(new TerminalSurfaceDouble()); const theme = { background: '#000000', @@ -80,19 +79,16 @@ const theme = { brightWhite: '#ffffff', } as const; -const flushGhosttyLoad = async () => { +const flushSurfaceLoad = async () => { await act(async () => { await Promise.resolve(); await Promise.resolve(); + await Promise.resolve(); }); }; const TERMINAL_BUFFER_CAP = 512 * 1024; -const replayWriteEvents = (expectedPayloads: string[]) => terminalEvents.filter( - (event): event is { type: 'write'; data: string } => event.type === 'write' && expectedPayloads.includes(event.data), -); - const buildReplacedBufferChunks = (content: string): TerminalChunk[] => { const directory = '/fixture'; useTerminalStore.getState().clearAll(); @@ -103,18 +99,22 @@ const buildReplacedBufferChunks = (content: string): TerminalChunk[] => { return [...useTerminalStore.getState().getBuffer(directory, tabId).chunks]; }; -const renderViewport = (root: Root, chunks: TerminalChunk[]) => act(async () => { +const renderViewport = (root: Root, chunks: TerminalChunk[], isVisible = true) => act(async () => { root.render( - undefined} - onResize={() => undefined} - theme={theme} - monoFont="geist-mono" - fontFamily="Geist Mono" - fontSize={14} - />, + + undefined} + onResize={() => undefined} + theme={theme} + monoFont="system-mono" + fontFamily="Menlo" + fontSize={14} + isVisible={isVisible} + createSurface={createSurface} + /> + , ); }); @@ -135,14 +135,6 @@ describe('TerminalViewport chunk replay integration', () => { Element: windowInstance.Element, Node: windowInstance.Node, Event: windowInstance.Event, - InputEvent: windowInstance.InputEvent, - KeyboardEvent: windowInstance.KeyboardEvent, - MouseEvent: windowInstance.MouseEvent, - FocusEvent: windowInstance.FocusEvent, - ResizeObserver: class { - observe() {} - disconnect() {} - }, requestAnimationFrame: (callback: FrameRequestCallback) => { callback(0); return 1; @@ -150,16 +142,6 @@ describe('TerminalViewport chunk replay integration', () => { cancelAnimationFrame: () => undefined, IS_REACT_ACT_ENVIRONMENT: true, }); - Object.defineProperty(windowInstance.document, 'hasFocus', { - configurable: true, - value: () => true, - }); - Object.defineProperty(windowInstance.HTMLElement.prototype, 'getBoundingClientRect', { - configurable: true, - value() { - return { x: 0, y: 0, top: 0, left: 0, right: 800, bottom: 600, width: 800, height: 600 }; - }, - }); host = document.createElement('div'); document.body.appendChild(host); @@ -172,19 +154,20 @@ describe('TerminalViewport chunk replay integration', () => { useTerminalStore.getState().clearAll(); }); - test('would fail if adopted-buffer remount replay split history writes or exceeded the capped buffer payload', async () => { + test('replays adopted history as one reset and keeps the capped buffer payload intact', async () => { const replayChunks: TerminalChunk[] = [ { id: 1, data: 'live-one\n', replayData: 'replay-one\n', byteLength: 9 }, { id: 2, data: 'live-two\n', replayData: 'replay-two\n', byteLength: 9 }, { id: 3, data: 'live-three\n', byteLength: 11 }, ]; - const replayPayload = 'replay-one\nreplay-two\nlive-three\n'; await renderViewport(root, replayChunks); - await flushGhosttyLoad(); + await flushSurfaceLoad(); - expect(terminalEvents.filter((event) => event.type === 'reset')).toHaveLength(0); - expect(replayWriteEvents([replayPayload])).toEqual([{ type: 'write', data: replayPayload }]); + expect(terminalEvents.filter((event) => event.type === 'reset' || event.type === 'write')).toEqual([ + { type: 'reset', data: 'replay-one\n' }, + { type: 'write', data: 'replay-two\nlive-three\n' }, + ]); await act(async () => root.unmount()); host.remove(); @@ -197,13 +180,13 @@ describe('TerminalViewport chunk replay integration', () => { const oversizedPayload = oversizedReplayChunks.map((chunk) => chunk.data).join(''); await renderViewport(root, oversizedReplayChunks); - await flushGhosttyLoad(); + await flushSurfaceLoad(); - expect(replayWriteEvents([oversizedPayload])).toEqual([{ type: 'write', data: oversizedPayload }]); + expect(terminalEvents.filter((event) => event.type === 'reset')).toEqual([{ type: 'reset', data: oversizedPayload }]); expect(new TextEncoder().encode(oversizedPayload).byteLength).toBeLessThanOrEqual(TERMINAL_BUFFER_CAP); }); - test('would fail if authoritative replacement replay reset twice or re-streamed replacement history chunk-by-chunk', async () => { + test('appends live chunks and replaces history with a single reset', async () => { const initialChunks: TerminalChunk[] = [ { id: 1, data: 'initial-live\n', replayData: 'initial-replay\n', byteLength: 13 }, ]; @@ -215,10 +198,9 @@ describe('TerminalViewport chunk replay integration', () => { { id: 3, data: 'history-live-1\n', replayData: 'history-replay-1\n', byteLength: 15 }, { id: 4, data: 'history-live-2\n', replayData: 'history-replay-2\n', byteLength: 15 }, ]; - const replacementReplayPayload = 'history-replay-1\nhistory-replay-2\n'; await renderViewport(root, initialChunks); - await flushGhosttyLoad(); + await flushSurfaceLoad(); terminalEvents.length = 0; await renderViewport(root, appendedChunks); @@ -226,66 +208,28 @@ describe('TerminalViewport chunk replay integration', () => { terminalEvents.length = 0; await renderViewport(root, replacementChunks); - expect(terminalEvents.filter((event) => event.type === 'reset')).toHaveLength(1); - expect(replayWriteEvents([replacementReplayPayload])).toEqual([{ type: 'write', data: replacementReplayPayload }]); - expect(terminalEvents.some((event) => event.type === 'write' && event.data === 'history-replay-1\n')).toBe(false); - expect(terminalEvents.some((event) => event.type === 'write' && event.data === 'history-replay-2\n')).toBe(false); - expect(terminalEvents.some((event) => event.type === 'write' && event.data === 'history-live-1\n')).toBe(false); - expect(terminalEvents.some((event) => event.type === 'write' && event.data === 'history-live-2\n')).toBe(false); - }); - - test('would fail if a live append after replacement replay duplicated history or lost the new chunk ordering', async () => { - const initialChunks: TerminalChunk[] = [ - { id: 1, data: 'initial-live\n', replayData: 'initial-replay\n', byteLength: 13 }, - ]; - const replacementChunks: TerminalChunk[] = [ - { id: 3, data: 'history-live-1\n', replayData: 'history-replay-1\n', byteLength: 15 }, - { id: 4, data: 'history-live-2\n', replayData: 'history-replay-2\n', byteLength: 15 }, - ]; - const resumedChunks: TerminalChunk[] = [ - ...replacementChunks, - { id: 5, data: 'tail-live\n', replayData: 'tail-replay\n', byteLength: 10 }, - ]; - const replacementReplayPayload = 'history-replay-1\nhistory-replay-2\n'; - - await renderViewport(root, initialChunks); - await flushGhosttyLoad(); + expect(terminalEvents).toEqual([ + { type: 'reset', data: 'history-replay-1\n' }, + { type: 'write', data: 'history-replay-2\n' }, + ]); terminalEvents.length = 0; - await renderViewport(root, replacementChunks); - await renderViewport(root, resumedChunks); - - expect(terminalEvents.filter((event) => event.type === 'reset')).toHaveLength(1); - expect(replayWriteEvents([replacementReplayPayload, 'tail-live\n'])).toEqual([ - { type: 'write', data: replacementReplayPayload }, - { type: 'write', data: 'tail-live\n' }, - ]); - expect(terminalEvents.filter((event) => event.type === 'write' && event.data === replacementReplayPayload)).toHaveLength(1); - expect(terminalEvents.filter((event) => event.type === 'write' && event.data === 'tail-live\n')).toHaveLength(1); + await renderViewport(root, [...replacementChunks, { id: 5, data: 'tail-live\n', replayData: 'tail-replay\n', byteLength: 10 }]); + expect(terminalEvents).toEqual([{ type: 'write', data: 'tail-live\n' }]); }); - test('would fail if snapshot history drawn for another PTY size were replayed at the fitted size', async () => { - // A zsh prompt drawn for a 94-column PTY: the `%` end-of-line mark plus - // padding fills exactly one 94-column row. Written into an 80-column - // emulator it wraps and the mark survives as a stray fragment. - const history = `%${' '.repeat(93)}\r \r~ ❯ `; + test('passes the PTY size a snapshot was drawn for so the surface replays at that size', async () => { + const history = '[7m%[0m' + ' '.repeat(93) + '\r \r[J~ ❯ '; const chunks: TerminalChunk[] = [ { id: 1, data: history, byteLength: history.length, size: { cols: 94, rows: 56 } }, { id: 2, data: 'live\n', byteLength: 5 }, ]; await renderViewport(root, chunks); - await flushGhosttyLoad(); + await flushSurfaceLoad(); - // Default-background resets inside the history are rewritten before the - // write, so identify the history write by the prompt it carries. - const relevant = terminalEvents - .filter((event) => event.type === 'resize' || (event.type === 'write' && (event.data.includes('~ ❯') || event.data === 'live\n'))) - .map((event) => (event.type === 'write' && event.data.includes('~ ❯') ? { type: 'write', data: 'history' } : event)); - expect(relevant).toEqual([ - { type: 'resize', cols: 94, rows: 56 }, - { type: 'write', data: 'history' }, - { type: 'resize', cols: 80, rows: 24 }, + expect(terminalEvents.filter((event) => event.type === 'reset' || event.type === 'write')).toEqual([ + { type: 'reset', data: history, size: { cols: 94, rows: 56 } }, { type: 'write', data: 'live\n' }, ]); @@ -294,15 +238,18 @@ describe('TerminalViewport chunk replay integration', () => { expect(terminalEvents).toEqual([{ type: 'write', data: 'more\n' }]); }); - test('would fail if a snapshot drawn at the fitted size still bounced the emulator through a resize', async () => { - const chunks: TerminalChunk[] = [ - { id: 1, data: 'prompt ❯ ', byteLength: 11, size: { cols: 80, rows: 24 } }, - ]; + test('toggles surface visibility with the prop and disposes on unmount', async () => { + await renderViewport(root, [], false); + await flushSurfaceLoad(); + const hiddenEvents = terminalEvents.filter((event) => event.type === 'visible'); + expect(hiddenEvents.length).toBeGreaterThan(0); + expect(hiddenEvents.every((event) => event.type === 'visible' && !event.visible)).toBe(true); - await renderViewport(root, chunks); - await flushGhosttyLoad(); + await renderViewport(root, [], true); + expect(terminalEvents.at(-1)).toEqual({ type: 'visible', visible: true }); - expect(terminalEvents.filter((event) => event.type === 'resize')).toHaveLength(0); - expect(replayWriteEvents(['prompt ❯ '])).toEqual([{ type: 'write', data: 'prompt ❯ ' }]); + await act(async () => root.unmount()); + expect(terminalEvents.at(-1)).toEqual({ type: 'dispose' }); + root = createRoot(host); }); }); diff --git a/packages/ui/src/components/terminal/TerminalViewport.tsx b/packages/ui/src/components/terminal/TerminalViewport.tsx index 436f141c..baf6ed49 100644 --- a/packages/ui/src/components/terminal/TerminalViewport.tsx +++ b/packages/ui/src/components/terminal/TerminalViewport.tsx @@ -1,89 +1,88 @@ import React from 'react'; -import type { FitAddon, Ghostty, Terminal as GhosttyTerminal } from 'ghostty-web'; import { cn } from '@/lib/utils'; import { loadMonoFont } from '@/lib/fontLoader'; import type { MonoFontOption } from '@/lib/fontOptions'; import type { TerminalTheme } from '@/lib/terminalTheme'; -import { getGhosttyTerminalOptions } from '@/lib/terminalTheme'; -import { - getGhosttySafeResetSequence, - rewriteGhosttyDefaultBackgroundResets, -} from '@/lib/terminalOutput'; -import { - getTerminalCellFromPoint, - getTerminalWordRange, - type TerminalCellPosition, -} from '@/lib/terminalTouchSelection'; +import { toGhosttyTheme } from '@/lib/terminalTheme'; +import { openExternalUrl } from '@/lib/url'; +import { useI18n } from '@/lib/i18n'; import type { TerminalChunk } from '@/stores/useTerminalStore'; import { selectTerminalChunkReplay } from './terminalChunkReplay'; -// ghostty-web (638 KB raw of JS + the WASM VT) loads on demand: TerminalView -// stays eagerly importable for the bottom dock without pulling the emulator +// The libghostty-vt adapter (WASM VT + canvas renderer) loads on demand so the +// bottom dock can import TerminalView eagerly without pulling the emulator // into the startup graph before a terminal is actually mounted. -type GhosttyModule = typeof import('ghostty-web'); -type GhosttyRuntime = { module: GhosttyModule; ghostty: Ghostty }; -let ghosttyRuntimePromise: Promise | null = null; -const loadGhostty = (): Promise => - ghosttyRuntimePromise ??= import('ghostty-web').then(async (module) => ({ - module, - ghostty: await module.Ghostty.load(), - })); +type GhosttyTerminalSurface = import('@/lib/ghostty/surface').GhosttyTerminalSurface; +type GhosttyTerminalSurfaceOptions = import('@/lib/ghostty/surface').GhosttyTerminalSurfaceOptions; -// Wait briefly for both the selected mono font and the web entry's deferred -// Nerd Fonts before Ghostty measures glyphs. A cold CDN fetch must not block -// opening the terminal, so the renderer starts after the bound and is rebuilt -// once the fonts arrive. Runtimes without the Nerd Font hook resolve it at once. -const TERMINAL_FONT_WAIT_MS = 2000; -const loadNerdFonts = (): Promise => - Promise.resolve(window.__openchamberEnsureNerdFonts?.()).catch(() => undefined); +/** The subset of the surface the viewport drives; tests inject a double. */ +export type TerminalSurface = Pick< + GhosttyTerminalSurface, + | 'write' + | 'resetAndWrite' + | 'setTheme' + | 'setFont' + | 'setVisible' + | 'fit' + | 'refresh' + | 'focus' + | 'getSelection' + | 'getSelectionPosition' + | 'scrollLines' + | 'selectWordAt' + | 'extendSelectionTo' + | 'dispose' +>; -const waitForTerminalFonts = (font: MonoFontOption) => { - const loaded = Promise.all([loadMonoFont(font), loadNerdFonts()]).then(() => undefined); - const loadedBeforeTimeout = new Promise((resolve) => { - const timeout = setTimeout(() => resolve(false), TERMINAL_FONT_WAIT_MS); - void loaded.then(() => { - clearTimeout(timeout); - resolve(true); - }); - }); - return { loaded, loadedBeforeTimeout }; +export type TerminalSurfaceFactory = ( + mount: HTMLElement, + options: GhosttyTerminalSurfaceOptions, +) => Promise; + +const createGhosttySurface: TerminalSurfaceFactory = async (mount, options) => { + const { GhosttyTerminalSurface } = await import('@/lib/ghostty/surface'); + return GhosttyTerminalSurface.create(mount, options); }; +// The selected mono face loads from the app bundle, so this normally resolves +// at once. A stalled fetch must not keep the terminal from opening: after the +// bound the surface measures with whatever faces are available and refits +// when the face arrives (document.fonts "loadingdone"). +const TERMINAL_FONT_WAIT_MS = 2000; +const waitForMonoFont = (font: MonoFontOption): Promise => + new Promise((resolve) => { + const timeout = setTimeout(resolve, TERMINAL_FONT_WAIT_MS); + void loadMonoFont(font).finally(() => { + clearTimeout(timeout); + resolve(); + }); + }); + type TerminalSize = { cols: number; rows: number }; +const CONTENT_PADDING = 4; + const getProvisionalTerminalSize = ( container: HTMLDivElement, fontFamily: string, fontSize: number, ): TerminalSize | null => { - if (typeof window === 'undefined' || typeof document === 'undefined') return null; - - const context = document.createElement('canvas').getContext('2d'); + const context = container.ownerDocument.createElement('canvas').getContext('2d'); if (!context || container.clientWidth < 24 || container.clientHeight < 24) return null; context.font = `${fontSize}px ${fontFamily}`; const metrics = context.measureText('M'); - const cellWidth = Math.ceil(metrics.width); - const cellHeight = Math.ceil( - (metrics.actualBoundingBoxAscent || fontSize * 0.8) + - (metrics.actualBoundingBoxDescent || fontSize * 0.2), - ) + 2; + const cellWidth = metrics.width; + const glyphHeight = (metrics.actualBoundingBoxAscent || fontSize * 0.8) + (metrics.actualBoundingBoxDescent || fontSize * 0.2); + // Mirrors measureGhosttyCell: the line height is the larger of 1.35em and the glyph box. + const cellHeight = Math.max(1, Math.round(fontSize * 1.35), Math.ceil(glyphHeight)); if (cellWidth < 1 || cellHeight < 1) return null; - const style = window.getComputedStyle(container); - const horizontalPadding = - (Number.parseInt(style.paddingLeft, 10) || 0) + - (Number.parseInt(style.paddingRight, 10) || 0); - const verticalPadding = - (Number.parseInt(style.paddingTop, 10) || 0) + - (Number.parseInt(style.paddingBottom, 10) || 0); - - // Match Ghostty FitAddon's 15px scrollbar reservation and minimum dimensions. return { - cols: Math.max(2, Math.floor((container.clientWidth - horizontalPadding - 15) / cellWidth)), - rows: Math.max(1, Math.floor((container.clientHeight - verticalPadding) / cellHeight)), + cols: Math.max(2, Math.floor((container.clientWidth - CONTENT_PADDING * 2) / cellWidth)), + rows: Math.max(1, Math.floor((container.clientHeight - CONTENT_PADDING * 2) / cellHeight)), }; }; @@ -113,184 +112,84 @@ type Props = { enableTouchScroll?: boolean; autoFocus?: boolean; isVisible?: boolean; + /** Surface construction, injectable for tests. */ + createSurface?: TerminalSurfaceFactory; }; const TerminalViewport = React.forwardRef(({ sessionKey, chunks, onInput, onResize, onProvisionalSize, theme, monoFont, fontFamily, fontSize, className, - enableTouchScroll = false, autoFocus = true, isVisible = true, + enableTouchScroll = false, autoFocus = true, isVisible = true, createSurface = createGhosttySurface, }, ref) => { + const { t } = useI18n(); const containerRef = React.useRef(null); - const terminalRef = React.useRef(null); - const fitRef = React.useRef(null); + const surfaceRef = React.useRef(null); const inputRef = React.useRef(onInput); const resizeRef = React.useRef(onResize); const provisionalSizeCallbackRef = React.useRef(onProvisionalSize); - const lastSizeRef = React.useRef(null); - const provisionalSizeRef = React.useRef(null); const lastChunkRef = React.useRef(null); - const writeQueueRef = React.useRef(''); - const outputRewriteCarryRef = React.useRef(''); - const safeResetRef = React.useRef(getGhosttySafeResetSequence(theme.background)); - const writingRef = React.useRef(false); - // Incremented whenever the replay stream restarts, so a write completing from - // before the restart cannot clear the in-flight flag of a newer write. - const writeEpochRef = React.useRef(0); const visibleRef = React.useRef(isVisible); - const rendererReadyRef = React.useRef(false); + const labelsRef = React.useRef({ input: '', scrollbar: '' }); const [ready, setReady] = React.useState(0); - const [rendererGeneration, setRendererGeneration] = React.useState(0); inputRef.current = onInput; resizeRef.current = onResize; provisionalSizeCallbackRef.current = onProvisionalSize; visibleRef.current = isVisible; - safeResetRef.current = getGhosttySafeResetSequence(theme.background); + labelsRef.current = { + input: t('terminalView.viewport.inputAria'), + scrollbar: t('terminalView.viewport.scrollbarAria'), + }; React.useLayoutEffect(() => { const container = containerRef.current; if (!container) return; const size = getProvisionalTerminalSize(container, fontFamily, fontSize); - provisionalSizeRef.current = size; if (size) (provisionalSizeCallbackRef.current ?? resizeRef.current)(size.cols, size.rows); }, [fontFamily, fontSize]); - const fit = React.useCallback(() => { - const container = containerRef.current; - const terminal = terminalRef.current; - if (!container || !terminal || !fitRef.current || !visibleRef.current) return; - const bounds = container.getBoundingClientRect(); - if (bounds.width < 24 || bounds.height < 24) return; - try { - fitRef.current.fit(); - const next = { cols: terminal.cols, rows: terminal.rows }; - if (!lastSizeRef.current || lastSizeRef.current.cols !== next.cols || lastSizeRef.current.rows !== next.rows) { - lastSizeRef.current = next; - resizeRef.current(next.cols, next.rows); - } - if (!rendererReadyRef.current) { - rendererReadyRef.current = true; - setReady((value) => value + 1); - } - } catch { /* hidden or detached */ } - }, []); - - const flush = React.useCallback(() => { - if (writingRef.current || !writeQueueRef.current || !terminalRef.current) return; - const terminal = terminalRef.current; - const pending = writeQueueRef.current; - writeQueueRef.current = ''; - const rewritten = rewriteGhosttyDefaultBackgroundResets( - pending, - outputRewriteCarryRef.current, - safeResetRef.current, - ); - outputRewriteCarryRef.current = rewritten.carry; - if (!rewritten.data) { - if (writeQueueRef.current) flush(); - return; - } - writingRef.current = true; - const epoch = writeEpochRef.current; - terminal.write(rewritten.data, () => { - if (terminalRef.current !== terminal || writeEpochRef.current !== epoch) return; - writingRef.current = false; - if (writeQueueRef.current) flush(); - }); - }, []); - - /** - * Replay discontinuities (restart, reconnect, buffer reset) only need the VT - * state cleared. `Terminal.reset()` frees and rebuilds the WASM terminal while - * keeping the canvas, renderer and font atlas, so prefer it over remounting the - * whole terminal; the generation bump remains the fallback before the terminal - * exists. - */ - const recreateRenderer = React.useCallback(() => { - lastChunkRef.current = null; - writeQueueRef.current = ''; - outputRewriteCarryRef.current = ''; - writingRef.current = false; - writeEpochRef.current += 1; - const terminal = terminalRef.current; - if (!terminal) { - setRendererGeneration((value) => value + 1); - return; - } - try { - terminal.reset(); - const safeReset = safeResetRef.current; - if (safeReset) terminal.write(`${safeReset}\u001b[2J\u001b[H`); - } catch { - setRendererGeneration((value) => value + 1); - } - }, []); - + // The surface lives for the whole mount. Theme and font changes are applied + // in place below; only the container identity and the factory can recreate it. React.useEffect(() => { const container = containerRef.current; if (!container) return; let disposed = false; - let terminal: GhosttyTerminal | null = null; - let observer: ResizeObserver | null = null; - let resizeTimeout: ReturnType | null = null; - let fitFrame: number | null = null; - let subscriptions: Array<{ dispose: () => void }> = []; - const handleFocusIn = () => { - if (terminal && visibleRef.current) terminal.options.cursorBlink = true; - }; - const handleFocusOut = (event: FocusEvent) => { - if (event.relatedTarget instanceof Node && container.contains(event.relatedTarget)) return; - if (terminal) terminal.options.cursorBlink = false; - }; - const handleWindowFocus = () => { - if (terminal && visibleRef.current && container.contains(document.activeElement)) { - terminal.options.cursorBlink = true; - } - }; - const handleWindowBlur = () => { - if (terminal) terminal.options.cursorBlink = false; - }; + let surface: TerminalSurface | null = null; + const initialTheme = theme; + const initialFont = { family: fontFamily, size: fontSize }; + const initialMonoFont = monoFont; + const ownsTouch = !enableTouchScroll; - container.addEventListener('focusin', handleFocusIn); - container.addEventListener('focusout', handleFocusOut); - window.addEventListener('focus', handleWindowFocus); - window.addEventListener('blur', handleWindowBlur); - - const fonts = waitForTerminalFonts(monoFont); - Promise.all([loadGhostty(), fonts.loadedBeforeTimeout]).then(([{ module, ghostty }, fontsLoaded]) => { + void (async () => { + await waitForMonoFont(initialMonoFont); if (disposed) return; - terminal = new module.Terminal({ - ...getGhosttyTerminalOptions(fontFamily, fontSize, theme, ghostty, false), - ...(provisionalSizeRef.current ?? {}), - }); - const fitAddon = new module.FitAddon(); - terminal.loadAddon(fitAddon); - terminal.open(container); - // ghostty-web marks the container contenteditable for touch IME input but - // sets autocapitalize/autocorrect only on its hidden textarea. Mobile - // keyboards (iOS and Android) therefore auto-capitalize the first letter - // of every terminal command; disable IME text mangling on the container. - container.setAttribute('autocapitalize', 'off'); - container.setAttribute('autocorrect', 'off'); - container.setAttribute('spellcheck', 'false'); - terminalRef.current = terminal; - fitRef.current = fitAddon; - subscriptions = [terminal.onData((data) => inputRef.current(data))]; - observer = new ResizeObserver(() => { - if (resizeTimeout) clearTimeout(resizeTimeout); - resizeTimeout = setTimeout(fit, 80); - }); - observer.observe(container); - fit(); - const safeReset = safeResetRef.current; - if (safeReset) terminal.write(`${safeReset}\u001b[2J\u001b[H`); - fitFrame = requestAnimationFrame(fit); - if (!fontsLoaded) { - void fonts.loaded.then(() => { - if (!disposed && terminalRef.current === terminal) { - setRendererGeneration((value) => value + 1); - } + let created: TerminalSurface; + try { + created = await createSurface(container, { + theme: toGhosttyTheme(initialTheme), + font: initialFont, + get visible() { + return visibleRef.current; + }, + labels: labelsRef.current, + handleTouchPointer: ownsTouch, + onData: (data) => inputRef.current(data), + onResize: (cols, rows) => resizeRef.current(cols, rows), + onLinkActivate: (text) => { + void openExternalUrl(text); + }, }); + } catch (error) { + console.error('[terminal] failed to initialize the terminal renderer', error); + return; } - }); + if (disposed) { + created.dispose(); + return; + } + surface = created; + surfaceRef.current = created; + created.setVisible(visibleRef.current); + setReady((value) => value + 1); + })(); return () => { disposed = true; @@ -301,6 +200,7 @@ const TerminalViewport = React.forwardRef(({ const active = document.activeElement; if (active instanceof HTMLElement && container.contains(active)) { active.blur(); + // SAFETY: the Capacitor bridge installs window.Capacitor with getPlatform() on native shells only. const capacitor = (window as typeof window & { Capacitor?: { getPlatform?: () => string } }).Capacitor; if (capacitor?.getPlatform?.() === 'android') { void import('@capacitor/keyboard') @@ -308,120 +208,56 @@ const TerminalViewport = React.forwardRef(({ .catch(() => undefined); } } - observer?.disconnect(); - if (resizeTimeout) clearTimeout(resizeTimeout); - if (fitFrame !== null) cancelAnimationFrame(fitFrame); - container.removeEventListener('focusin', handleFocusIn); - container.removeEventListener('focusout', handleFocusOut); - window.removeEventListener('focus', handleWindowFocus); - window.removeEventListener('blur', handleWindowBlur); - subscriptions.forEach((subscription) => subscription.dispose()); - terminal?.dispose(); - terminalRef.current = null; - fitRef.current = null; - lastSizeRef.current = null; + surface?.dispose(); + surface = null; + surfaceRef.current = null; lastChunkRef.current = null; - writeQueueRef.current = ''; - outputRewriteCarryRef.current = ''; - writingRef.current = false; - writeEpochRef.current += 1; - rendererReadyRef.current = false; }; - }, [fit, fontFamily, fontSize, monoFont, rendererGeneration, theme]); + // Theme, font and touch mode are applied to the live surface by the effects below. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [createSurface]); React.useEffect(() => { - const terminal = terminalRef.current; - const container = containerRef.current; - if (!terminal || !container) return; - terminal.options.cursorBlink = isVisible && document.hasFocus() && container.contains(document.activeElement); + surfaceRef.current?.setTheme(toGhosttyTheme(theme)); + }, [theme, ready]); + + React.useEffect(() => { + void surfaceRef.current?.setFont({ family: fontFamily, size: fontSize }); + }, [fontFamily, fontSize, ready]); + + React.useEffect(() => { + surfaceRef.current?.setVisible(isVisible); }, [isVisible, ready]); - /** - * Snapshot history was laid out by the shell for the PTY size recorded on the - * chunk. Writing it into an emulator of another width wraps or joins lines the - * shell never wrapped, and the shell's later SIGWINCH redraw only repaints - * from its own cursor row down, so the stray fragments stay on screen. Replay - * such a chunk at its own size and let the emulator reflow back to the fitted - * size; a subsequent PTY resize (when the sizes differ) makes the shell redraw - * on top of a consistent screen. - * - * Only valid while nothing is queued: the write must not overtake bytes that - * are still waiting for the emulator. - */ - const writeReplayAtDrawnSize = React.useCallback((terminal: GhosttyTerminal, chunk: TerminalChunk): boolean => { - if (!chunk.size || writingRef.current || writeQueueRef.current) return false; - const rewritten = rewriteGhosttyDefaultBackgroundResets( - chunk.replayData ?? chunk.data, - outputRewriteCarryRef.current, - safeResetRef.current, - ); - outputRewriteCarryRef.current = rewritten.carry; - if (!rewritten.data) return true; - const fitted = { cols: terminal.cols, rows: terminal.rows }; - const resizeForReplay = chunk.size.cols !== fitted.cols || chunk.size.rows !== fitted.rows; - if (resizeForReplay) terminal.resize(chunk.size.cols, chunk.size.rows); - try { - terminal.write(rewritten.data); - } finally { - if (resizeForReplay) terminal.resize(fitted.cols, fitted.rows); - } - return true; - }, []); - React.useEffect(() => { - const terminal = terminalRef.current; - if (!terminal) return; + const surface = surfaceRef.current; + if (!surface) return; const { reset, replay, pending } = selectTerminalChunkReplay(chunks, lastChunkRef.current); - if (reset) recreateRenderer(); - if (pending.length === 0) return; - const queued = replay && writeReplayAtDrawnSize(terminal, pending[0]) ? pending.slice(1) : pending; - writeQueueRef.current += queued - .map((chunk) => replay ? (chunk.replayData ?? chunk.data) : chunk.data) - .join(''); + if (replay) { + // Snapshot history is laid out for the PTY size recorded on its chunk; + // the surface replays it at that size and reflows to the fitted grid. + const [snapshot, ...live] = pending; + surface.resetAndWrite(snapshot ? (snapshot.replayData ?? snapshot.data) : '', snapshot?.size); + const liveData = live.map((chunk) => chunk.replayData ?? chunk.data).join(''); + if (liveData) surface.write(liveData); + } else if (reset) { + surface.resetAndWrite(''); + } else if (pending.length > 0) { + surface.write(pending.map((chunk) => chunk.data).join('')); + } lastChunkRef.current = chunks.at(-1)?.id ?? null; - flush(); - }, [chunks, flush, ready, recreateRenderer, writeReplayAtDrawnSize]); + }, [chunks, ready]); React.useEffect(() => { if (!autoFocus || !isVisible) return; - const frame = requestAnimationFrame(() => terminalRef.current?.focus()); + const frame = requestAnimationFrame(() => surfaceRef.current?.focus()); return () => cancelAnimationFrame(frame); }, [autoFocus, isVisible, ready, sessionKey]); React.useEffect(() => { const container = containerRef.current; - if (!enableTouchScroll || !container) return; - // ghostty-web only reads keydown/composition events and preventDefaults - // beforeinput without consuming it. Android IMEs deliver text via - // beforeinput (their keydown arrives as keyCode 229, which ghostty - // ignores), so forward those payloads to the terminal here. Composition - // updates are skipped: ghostty commits them itself on compositionend. - const handleBeforeInput = (event: Event) => { - const input = event as InputEvent; - if (input.isComposing) return; - switch (input.inputType) { - case 'insertText': - if (input.data) inputRef.current(input.data); - break; - case 'insertLineBreak': - case 'insertParagraph': - inputRef.current('\r'); - break; - case 'deleteContentBackward': - inputRef.current('\x7f'); - break; - default: - break; - } - }; - container.addEventListener('beforeinput', handleBeforeInput); - return () => container.removeEventListener('beforeinput', handleBeforeInput); - }, [enableTouchScroll, ready]); - - React.useEffect(() => { - const container = containerRef.current; - const terminal = terminalRef.current; - if (!enableTouchScroll || !container || !terminal) return; + const surface = surfaceRef.current; + if (!enableTouchScroll || !container || !surface) return; let pointerId: number | null = null; let longPressTimeout: ReturnType | null = null; let gesture: 'idle' | 'pending' | 'scrolling' | 'selecting' = 'idle'; @@ -429,12 +265,12 @@ const TerminalViewport = React.forwardRef(({ let startY = 0; let lastY = 0; let remainder = 0; - let selectionFocus: TerminalCellPosition | null = null; - const lineHeight = Math.max(12, fontSize + 2); + const lineHeight = Math.max(12, Math.round(fontSize * 1.35)); // Android WebView only raises the soft keyboard for a native tap-focus; the // pointer-captured, touch-action:none tap here focuses programmatically, so // the IME must be summoned explicitly via the Capacitor Keyboard plugin. const showAndroidSoftKeyboard = () => { + // SAFETY: the Capacitor bridge installs window.Capacitor with getPlatform() on native shells only. const capacitor = (window as typeof window & { Capacitor?: { getPlatform?: () => string } }).Capacitor; if (capacitor?.getPlatform?.() !== 'android') return; void import('@capacitor/keyboard') @@ -446,37 +282,6 @@ const TerminalViewport = React.forwardRef(({ clearTimeout(longPressTimeout); longPressTimeout = null; }; - const cellFromPoint = (clientX: number, clientY: number) => { - const canvas = container.querySelector('canvas'); - if (!canvas) return null; - return getTerminalCellFromPoint(clientX, clientY, canvas.getBoundingClientRect(), terminal.cols, terminal.rows); - }; - const dispatchSelectionMouseEvent = ( - type: 'mousedown' | 'mousemove', - cell: TerminalCellPosition, - ) => { - const canvas = container.querySelector('canvas'); - if (!canvas) return; - const bounds = canvas.getBoundingClientRect(); - const clientX = bounds.left + ((cell.column + 0.5) / terminal.cols) * bounds.width; - const clientY = bounds.top + ((cell.row + 0.5) / terminal.rows) * bounds.height; - canvas.dispatchEvent(new MouseEvent(type, { - bubbles: true, - cancelable: true, - button: 0, - buttons: 1, - clientX, - clientY, - })); - }; - const finishSelection = () => { - document.dispatchEvent(new MouseEvent('mouseup', { - bubbles: true, - cancelable: true, - button: 0, - buttons: 0, - })); - }; const down = (event: PointerEvent) => { if (event.pointerType !== 'touch' || pointerId !== null) return; pointerId = event.pointerId; @@ -485,35 +290,18 @@ const TerminalViewport = React.forwardRef(({ startY = event.clientY; lastY = event.clientY; remainder = 0; - selectionFocus = null; container.setPointerCapture(event.pointerId); longPressTimeout = setTimeout(() => { longPressTimeout = null; if (pointerId !== event.pointerId || gesture !== 'pending') return; - const cell = cellFromPoint(startX, startY); - if (!cell) return; - - const buffer = terminal.buffer.active; - const lineIndex = Math.max(0, buffer.length - terminal.rows - buffer.viewportY + cell.row); - const line = buffer.getLine(lineIndex); - const cells = Array.from({ length: terminal.cols }, (_, column) => line?.getCell(column)?.getChars() ?? ''); - const word = getTerminalWordRange(cells, cell.column); - const selectionAnchor = { column: word.startColumn, row: cell.row }; - selectionFocus = { column: word.endColumn, row: cell.row }; - gesture = 'selecting'; - dispatchSelectionMouseEvent('mousedown', selectionAnchor); - dispatchSelectionMouseEvent('mousemove', selectionFocus); + if (surface.selectWordAt(startX, startY)) gesture = 'selecting'; }, 350); }; const move = (event: PointerEvent) => { if (pointerId !== event.pointerId) return; if (gesture === 'selecting') { - const focus = cellFromPoint(event.clientX, event.clientY); - if (focus && (!selectionFocus || focus.column !== selectionFocus.column || focus.row !== selectionFocus.row)) { - selectionFocus = focus; - dispatchSelectionMouseEvent('mousemove', focus); - } + surface.extendSelectionTo(event.clientX, event.clientY); if (event.cancelable) event.preventDefault(); return; } @@ -530,32 +318,23 @@ const TerminalViewport = React.forwardRef(({ lastY = event.clientY; remainder += delta; const lines = Math.trunc(remainder / lineHeight); - if (lines) { terminal.scrollLines(lines); remainder -= lines * lineHeight; } + if (lines) { surface.scrollLines(lines); remainder -= lines * lineHeight; } if (event.cancelable) event.preventDefault(); }; - const up = (event: PointerEvent) => { + const finish = (event: PointerEvent, focusOnTap: boolean) => { if (pointerId !== event.pointerId) return; - const shouldFocus = gesture === 'pending'; - const shouldFinishSelection = gesture === 'selecting'; + const shouldFocus = focusOnTap && gesture === 'pending'; clearLongPress(); if (container.hasPointerCapture(event.pointerId)) container.releasePointerCapture(event.pointerId); pointerId = null; gesture = 'idle'; - if (shouldFinishSelection) finishSelection(); if (shouldFocus) { - terminal.focus(); + surface.focus(); showAndroidSoftKeyboard(); } }; - const cancel = (event: PointerEvent) => { - if (pointerId !== event.pointerId) return; - const shouldFinishSelection = gesture === 'selecting'; - clearLongPress(); - if (container.hasPointerCapture(event.pointerId)) container.releasePointerCapture(event.pointerId); - pointerId = null; - gesture = 'idle'; - if (shouldFinishSelection) finishSelection(); - }; + const up = (event: PointerEvent) => finish(event, true); + const cancel = (event: PointerEvent) => finish(event, false); container.addEventListener('pointerdown', down); container.addEventListener('pointermove', move, { passive: false }); container.addEventListener('pointerup', up); @@ -570,22 +349,27 @@ const TerminalViewport = React.forwardRef(({ }, [enableTouchScroll, fontSize, ready]); React.useImperativeHandle(ref, () => ({ - focus: () => terminalRef.current?.focus(), - fit, + focus: () => surfaceRef.current?.focus(), + fit: () => { + const surface = surfaceRef.current; + if (!surface) return; + surface.fit(); + surface.refresh(); + }, getSelection: () => { - const terminal = terminalRef.current; - const range = terminal?.getSelectionPosition(); - const text = terminal?.getSelection() ?? ''; + const surface = surfaceRef.current; + const range = surface?.getSelectionPosition(); + const text = surface?.getSelection() ?? ''; if (!range || !text.trim()) return null; return { text, startLine: range.start.y + 1, endLine: range.end.y + 1 }; }, - }), [fit]); + }), []); return (
); }); diff --git a/packages/ui/src/components/views/TerminalView.test.tsx b/packages/ui/src/components/views/TerminalView.test.tsx index 54416586..dac5c437 100644 --- a/packages/ui/src/components/views/TerminalView.test.tsx +++ b/packages/ui/src/components/views/TerminalView.test.tsx @@ -98,7 +98,7 @@ mock.module('@/stores/useUIStore', () => ({ useUIStore: useUiStoreMock })); mock.module('@/stores/useInlineCommentDraftStore', () => ({ useInlineCommentDraftStore: () => ({ addDraft: () => undefined }) })); mock.module('@/components/terminal/TerminalViewport', () => ({ TerminalViewport: React.forwardRef(function TerminalViewportMock( - { sessionKey, chunks, isVisible }: { sessionKey: string; chunks: unknown[]; isVisible: boolean }, + { sessionKey, chunks, isVisible, onResize }: { sessionKey: string; chunks: unknown[]; isVisible: boolean; onResize: (cols: number, rows: number) => void }, ref: React.ForwardedRef<{ focus: () => void; fit: () => void; getSelection: () => null }>, ) { React.useImperativeHandle(ref, () => ({ @@ -106,6 +106,11 @@ mock.module('@/components/terminal/TerminalViewport', () => ({ fit: () => undefined, getSelection: () => null, }), []); + // A real surface reports its fitted grid once it is visible; a visible tab + // spawns its shell only after that report. + React.useEffect(() => { + if (isVisible) onResize(100, 30); + }, [isVisible, onResize]); return React.createElement('div', { 'data-terminal-viewport': 'true', @@ -303,7 +308,7 @@ describe('TerminalView project action tab indicator', () => { expect(ensureDirectoryCalls).not.toContain('/missing-repo'); expect(createSessionCalls.length).toBe(0); expect(host.querySelector('[data-tabs-strip="terminal"]')).toBeNull(); - expect(host.querySelector('[data-terminal-viewport="true"]')?.getAttribute('data-chunk-count')).toBe('0'); + expect(host.querySelector('[data-terminal-viewport="true"]')).toBeNull(); }); test('includes the terminal directory in the viewport identity key', async () => { diff --git a/packages/ui/src/components/views/TerminalView.tsx b/packages/ui/src/components/views/TerminalView.tsx index 7f27db70..93f60716 100644 --- a/packages/ui/src/components/views/TerminalView.tsx +++ b/packages/ui/src/components/views/TerminalView.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { useSessionUIStore } from '@/sync/session-ui-store'; -import { ACTIVE_PROJECT_ACTION_LIFECYCLES, EMPTY_TERMINAL_BUFFER, useTerminalStore } from '@/stores/useTerminalStore'; +import { ACTIVE_PROJECT_ACTION_LIFECYCLES, useTerminalStore } from '@/stores/useTerminalStore'; import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory'; import { type TerminalStreamEvent } from '@/lib/api/types'; import { useThemeSystem } from '@/contexts/useThemeSystem'; @@ -9,9 +9,13 @@ import { useFontPreferences } from '@/hooks/useFontPreferences'; import { CODE_FONT_OPTION_MAP, DEFAULT_MONO_FONT } from '@/lib/fontOptions'; import { convertThemeToXterm } from '@/lib/terminalTheme'; import { TerminalViewport, type TerminalController } from '@/components/terminal/TerminalViewport'; +import type { MonoFontOption } from '@/lib/fontOptions'; +import type { TerminalTheme } from '@/lib/terminalTheme'; import { cn } from '@/lib/utils'; import { useUIStore } from '@/stores/useUIStore'; import { Button } from '@/components/ui/button'; +import { toast } from '@/components/ui'; +import { copyTextToClipboard } from '@/lib/clipboard'; import { SortableTabsStrip } from '@/components/ui/sortable-tabs-strip'; import { Icon } from "@/components/icon/Icon"; import type { IconName } from '@/components/icon/icons'; @@ -32,6 +36,57 @@ type TerminalViewProps = { }; const FALLBACK_TERMINAL_SIZE = { cols: 80, rows: 24 } as const; + +type TerminalTabViewportProps = { + directory: string; + tabId: string; + isActive: boolean; + isTerminalVisible: boolean; + registerController: (tabId: string, controller: TerminalController | null) => void; + onInput: (data: string) => void; + onResize: (cols: number, rows: number) => void; + onProvisionalSize: (cols: number, rows: number) => void; + theme: TerminalTheme; + monoFont: MonoFontOption; + fontFamily: string; + fontSize: number; + enableTouchScroll: boolean; +}; + +/** + * One mounted emulator per tab. Inactive tabs stay mounted but hidden so + * switching back shows the last drawn screen at once instead of rebuilding + * the WASM terminal, re-measuring fonts and replaying history from scratch. + * Only the active tab holds a stream; its buffer refresh replays in place. + */ +const TerminalTabViewport: React.FC = ({ + directory, tabId, isActive, isTerminalVisible, registerController, + onInput, onResize, onProvisionalSize, theme, monoFont, fontFamily, fontSize, enableTouchScroll, +}) => { + // Scrollback is a leaf subscription: streaming output must not rerender the tab strip. + const chunks = useTerminalStore((s) => s.getBuffer(directory, tabId).chunks); + const viewportKey = `${directory}::${tabId}`; + return ( +
+ registerController(tabId, controller)} + sessionKey={viewportKey} + chunks={chunks} + onInput={onInput} + onResize={onResize} + onProvisionalSize={onProvisionalSize} + theme={theme} + monoFont={monoFont} + fontFamily={fontFamily} + fontSize={fontSize} + enableTouchScroll={enableTouchScroll} + autoFocus={isTerminalVisible && isActive} + isVisible={isTerminalVisible && isActive} + /> +
+ ); +}; + const resolveTabIconName = (iconKey: string | null): IconName => { const matchedIcon = PROJECT_ACTION_ICONS.find((entry) => entry.key === iconKey); return matchedIcon?.Icon ?? 'terminal'; @@ -126,10 +181,6 @@ export const TerminalView: React.FC = ({ visible, directory } const terminalSessionId = activeTab?.terminalSessionId ?? null; const terminalLifecycle = activeTab?.lifecycle ?? 'idle'; const isActionTab = activeTab?.purpose.type === 'project-action'; - // Scrollback is a leaf subscription: streaming output must not rerender the tab strip. - const bufferChunks = useTerminalStore((s) => ( - terminalDirectory && activeTabId ? s.getBuffer(terminalDirectory, activeTabId).chunks : EMPTY_TERMINAL_BUFFER.chunks - )); const isConnecting = activeTab?.isConnecting ?? false; const previewUrl = activeTab?.previewUrl ?? null; @@ -145,7 +196,14 @@ export const TerminalView: React.FC = ({ visible, directory } const terminalIdRef = React.useRef(terminalSessionId); const directoryRef = React.useRef(terminalDirectory); const terminalControllerRef = React.useRef(null); + const tabControllersRef = React.useRef(new Map()); const lastViewportSizeRef = React.useRef<{ cols: number; rows: number } | null>(null); + // The grid Ghostty actually fitted for a tab. A visible tab spawns its shell + // at this size and not before: a shell started wider than the real grid + // prints its first prompt for that width, and after the corrective resize + // zsh only repaints the prompt row, leaving the `%` end-of-line mark above it. + const fittedViewportRef = React.useRef<{ tabId: string; cols: number; rows: number } | null>(null); + const isTerminalVisibleRef = React.useRef(false); const pendingTerminalCreatesRef = React.useRef(new Set()); const previewScanTailRef = React.useRef(''); const pendingPreviewProbeUrlsRef = React.useRef>(new Set()); @@ -175,6 +233,7 @@ export const TerminalView: React.FC = ({ visible, directory } }, [useTouchTerminalInput]); const isTerminalVisible = visible ?? false; + isTerminalVisibleRef.current = isTerminalVisible; const [hasOpenedTerminalViewport, setHasOpenedTerminalViewport] = React.useState(isTerminalVisible); React.useEffect(() => { @@ -197,6 +256,16 @@ export const TerminalView: React.FC = ({ visible, directory } resetTerminalPreviewScan(); }, [activeTabId, resetTerminalPreviewScan]); + React.useLayoutEffect(() => { + terminalControllerRef.current = activeTabId ? (tabControllersRef.current.get(activeTabId) ?? null) : null; + }, [activeTabId]); + + const registerTabController = React.useCallback((tabId: string, controller: TerminalController | null) => { + if (controller) tabControllersRef.current.set(tabId, controller); + else tabControllersRef.current.delete(tabId); + if (tabId === activeTabIdRef.current) terminalControllerRef.current = controller; + }, []); + React.useEffect(() => { directoryRef.current = terminalDirectory; }, [terminalDirectory]); @@ -418,6 +487,79 @@ export const TerminalView: React.FC = ({ visible, directory } ] ); + // Spawns the PTY for a tab. Pending creates are single-flight per tab; + // the session effect and the first fitted-grid report both funnel here. + const createTerminalSession = React.useCallback( + async (directory: string, tabId: string, initialSize: { cols: number; rows: number }) => { + const createKey = `${directory}\u0000${tabId}`; + if (pendingTerminalCreatesRef.current.has(createKey)) { + return; + } + pendingTerminalCreatesRef.current.add(createKey); + + setConnectionError(null); + setIsFatalError(false); + setIsReconnectPending(false); + setConnecting(directory, tabId, true); + try { + const session = await terminal.createSession({ + cwd: directory, + sessionId: tabId, + cols: initialSize.cols, + rows: initialSize.rows, + shell: terminalShell, + loginShell: terminalLoginShell, + ...terminalAppearanceRef.current, + }); + + const stillActive = + directoryRef.current === directory && + activeTabIdRef.current === tabId; + + const owningTab = useTerminalStore.getState().getDirectoryState(directory)?.tabs.find((entry) => entry.id === tabId); + if (!owningTab) { + try { + await terminal.close(session.sessionId); + } catch { /* ignored */ } + return; + } + + setTabSessionId(directory, tabId, session.sessionId); + if (!stillActive) return; + + const viewportSize = lastViewportSizeRef.current; + if ( + viewportSize && + (viewportSize.cols !== initialSize.cols || viewportSize.rows !== initialSize.rows) + ) { + void terminal.resize({ sessionId: session.sessionId, ...viewportSize }).catch(() => {}); + } + // Storing the session ID reruns the session effect. Let that + // effect own stream startup. + return; + } catch (error) { + const owningTab = useTerminalStore.getState().getDirectoryState(directory)?.tabs.find((entry) => entry.id === tabId); + if (!owningTab || owningTab.terminalSessionId) return; + + setConnecting(directory, tabId, false); + // Use current store ownership so a rejected create cannot + // leave a tab spinning that no longer owns the request. + if (directoryRef.current !== directory || activeTabIdRef.current !== tabId) return; + setConnectionError( + error instanceof Error + ? error.message + : t('terminalView.error.startSessionFailed') + ); + setIsFatalError(true); + setIsReconnectPending(false); + return; + } finally { + pendingTerminalCreatesRef.current.delete(createKey); + } + }, + [setConnecting, setTabSessionId, t, terminal, terminalLoginShell, terminalShell] + ); + React.useEffect(() => { let cancelled = false; @@ -475,80 +617,16 @@ export const TerminalView: React.FC = ({ visible, directory } return; } - const createKey = `${directory}\u0000${tabId}`; - if (pendingTerminalCreatesRef.current.has(createKey)) { - return; - } - - // Launch the shell while Ghostty is still loading and fitting. - // The backend accepts 80x24, then receives the measured size as - // soon as the viewport is ready. - const initialSize = lastViewportSizeRef.current ?? FALLBACK_TERMINAL_SIZE; - pendingTerminalCreatesRef.current.add(createKey); - - setConnectionError(null); - setIsFatalError(false); - setIsReconnectPending(false); - setConnecting(directory, tabId, true); - try { - const session = await terminal.createSession({ - cwd: directory, - sessionId: tabId, - cols: initialSize.cols, - rows: initialSize.rows, - shell: terminalShell, - loginShell: terminalLoginShell, - ...terminalAppearanceRef.current, - }); - - const stillActive = - !cancelled && - directoryRef.current === directory && - activeTabIdRef.current === tabId; - - const owningTab = useTerminalStore.getState().getDirectoryState(directory)?.tabs.find((entry) => entry.id === tabId); - if (!owningTab) { - try { - await terminal.close(session.sessionId); - } catch { /* ignored */ } - return; - } - - setTabSessionId(directory, tabId, session.sessionId); - if (!stillActive) return; - - const viewportSize = lastViewportSizeRef.current; - if ( - viewportSize && - (viewportSize.cols !== initialSize.cols || viewportSize.rows !== initialSize.rows) - ) { - void terminal.resize({ sessionId: session.sessionId, ...viewportSize }).catch(() => {}); - } - // Storing the session ID reruns this effect. Let that next - // effect own stream startup: starting here would be torn - // down immediately by this effect's cleanup. - return; - } catch (error) { - const owningTab = useTerminalStore.getState().getDirectoryState(directory)?.tabs.find((entry) => entry.id === tabId); - if (!owningTab || owningTab.terminalSessionId) return; - - setConnecting(directory, tabId, false); - // Strict Mode replaces the first effect while its create - // request is pending. `cancelled` therefore does not mean - // this tab stopped owning the request; use current store - // ownership so a rejected create cannot leave it spinning. - if (directoryRef.current !== directory || activeTabIdRef.current !== tabId) return; - setConnectionError( - error instanceof Error - ? error.message - : t('terminalView.error.startSessionFailed') - ); - setIsFatalError(true); - setIsReconnectPending(false); - return; - } finally { - pendingTerminalCreatesRef.current.delete(createKey); - } + // A visible tab waits for Ghostty's fitted grid; the resize + // handler spawns it the moment that grid arrives. A hidden tab + // cannot be fitted, so it launches at the container estimate or + // 80x24 and resizes once shown. + const fitted = fittedViewportRef.current; + const fittedSize = fitted && fitted.tabId === tabId ? { cols: fitted.cols, rows: fitted.rows } : null; + if (isTerminalVisibleRef.current && !fittedSize) return; + const initialSize = fittedSize ?? lastViewportSizeRef.current ?? FALLBACK_TERMINAL_SIZE; + void createTerminalSession(directory, tabId, initialSize); + return; } if (!terminalId || cancelled) return; @@ -573,6 +651,7 @@ export const TerminalView: React.FC = ({ visible, directory } terminalLifecycle, activeTabId, hasOpenedTerminalViewport, + createTerminalSession, enableTabs, terminalHydrated, ensureDirectory, @@ -687,6 +766,17 @@ export const TerminalView: React.FC = ({ visible, directory } }); }, [activeTab, addContextDraft, contextDirectory, currentSessionId, newSessionDraft?.open]); + // Touch hosts have no keyboard shortcut for copy, so the toolbar offers the + // same action the desktop gets from Cmd/Ctrl+C on a selection. + const handleCopySelection = React.useCallback(() => { + const selection = terminalControllerRef.current?.getSelection(); + if (!selection?.text) return; + void copyTextToClipboard(selection.text).then((result) => { + if (result.ok) toast.success(t('terminalView.toast.selectionCopied')); + else toast.error(t('terminalView.toast.copyFailed')); + }); + }, [t]); + const handleSelectTab = React.useCallback( (tabId: string) => { if (!terminalDirectory) return; @@ -766,14 +856,25 @@ export const TerminalView: React.FC = ({ visible, directory } if (!previous || previous.cols !== cols || previous.rows !== rows) { lastViewportSizeRef.current = { cols, rows }; } + const tabId = activeTabIdRef.current; + const directory = directoryRef.current; + if (tabId) fittedViewportRef.current = { tabId, cols, rows }; if (!isTerminalVisible) { return; } + // The fitted grid is what a visible tab was waiting for to spawn. + const tab = tabId && directory + ? useTerminalStore.getState().getDirectoryState(directory)?.tabs.find((entry) => entry.id === tabId) + : undefined; + if (tab && directory && tabId && !tab.terminalSessionId && tab.lifecycle !== 'exited' && tab.purpose.type !== 'project-action') { + void createTerminalSession(directory, tabId, { cols, rows }); + return; + } const terminalId = terminalIdRef.current; if (!terminalId) return; void terminal.resize({ sessionId: terminalId, cols, rows }).catch(() => {}); }, - [isTerminalVisible, terminal] + [createTerminalSession, isTerminalVisible, terminal] ); const handleModifierToggle = React.useCallback( @@ -865,6 +966,7 @@ export const TerminalView: React.FC = ({ visible, directory } // here tore down and rebuilt the Ghostty terminal (WASM VT + canvas + font // atlas) a second time the moment `createSession` resolved, doubling the cost // of every terminal open. Session changes are handled by the chunk replay path. + // Every tab keeps its viewport mounted; this key names the active one. const terminalViewportKey = `${terminalDirectory ?? 'no-dir'}::${activeTabId ?? 'no-tab'}`; React.useEffect(() => { @@ -933,6 +1035,10 @@ export const TerminalView: React.FC = ({ visible, directory } const quickKeysDisabled = !terminalSessionId || isConnecting || isRestarting || isReconnectPending; const shouldRenderViewport = hasOpenedTerminalViewport; + // Without tabs (VS Code) only the first tab exists; with tabs every open tab stays mounted. + const mountedTabIds = enableTabs + ? (directoryTerminalState?.tabs ?? []).map((tab) => tab.id) + : (activeTabId ? [activeTabId] : []); const quickKeySize: 'lg' | 'xs' = isTouchTerminal ? 'lg' : 'xs'; const quickKeyIconClass = isTouchTerminal ? 'w-10 p-0' : 'w-9 p-0'; const preserveTerminalFocus = (event: React.PointerEvent) => { @@ -1094,6 +1200,17 @@ export const TerminalView: React.FC = ({ visible, directory } > + {previewUrl ? (