From 39bb71a62baa4e951190f89cf09deea892ed99de Mon Sep 17 00:00:00 2001 From: herjarsa Date: Mon, 17 Aug 2026 11:37:20 +0200 Subject: [PATCH 001/157] fix(ui): bound OpenCode read requests so half-open sockets cannot freeze bootstrap (#2470) The SDK client fetch wrapper now applies a 30s timeout to non-streaming reads. Without it, a socket that neither resolves nor rejects keeps the directory bootstrap concurrency slot busy forever and the UI stays on "loading sessions". Long-lived streams (POST prompts, the /event SSE) are explicitly excluded so they are not cut off mid-flight. The normalized "request timed out" error is added to the retry allowlist alongside undici's "terminated" (the exact failure observed in #2470 when undici tears down a half-open upstream connection); both are transient while the managed OpenCode process restarts. Caller- initiated aborts keep their original error shape so a user-cancelled request is not retried. Tests cover: GET timeout fires after the bound, POST is not timed out, /event SSE is not timed out, caller abort wins, AbortError is not retried, and the SDK normalized error is retried 3x. --- .../src/lib/opencode/client-timeout.test.ts | 157 ++++++++++++++++++ packages/ui/src/lib/opencode/client.ts | 55 +++++- packages/ui/src/sync/retry.test.ts | 38 +++++ packages/ui/src/sync/retry.ts | 6 + packages/ui/src/types/bun-test.d.ts | 11 +- 5 files changed, 264 insertions(+), 3 deletions(-) create mode 100644 packages/ui/src/lib/opencode/client-timeout.test.ts create mode 100644 packages/ui/src/sync/retry.test.ts diff --git a/packages/ui/src/lib/opencode/client-timeout.test.ts b/packages/ui/src/lib/opencode/client-timeout.test.ts new file mode 100644 index 00000000..fd45a4a7 --- /dev/null +++ b/packages/ui/src/lib/opencode/client-timeout.test.ts @@ -0,0 +1,157 @@ +import { beforeEach, describe, expect, mock, test } from 'bun:test'; + +// Regression tests for issue #2470: sessions stuck on "loading sessions" +// forever after managed OpenCode connection goes half-open. +// +// The SDK client fetch wrapper must bound read requests so a socket that +// neither resolves nor rejects fails after `requestTimeoutMs`, releasing the +// directory bootstrap concurrency slot. Long-lived streams must be excluded: +// POST (prompt/shell/summarize/command) and the `/event` SSE stream. + +type CapturedFetch = (input: string | URL | Request, init?: RequestInit) => Promise; +type RuntimeFetch = (input: string | URL | Request, init?: RequestInit) => Promise; + +// `mock(...)` returns a Mock that exposes mockImplementation; keep a typed +// reference so per-test overrides stay type-safe without re-importing. +const runtimeFetchMock = mock(async () => new Response('', { status: 200 })); + +let capturedFetch: CapturedFetch | null = null; + +(mock as unknown as { restore?: () => void }).restore?.(); + +mock.module('@opencode-ai/sdk/v2', () => ({ + createOpencodeClient: mock((opts: { fetch: CapturedFetch }) => { + capturedFetch = opts.fetch; + return {}; + }), +})); + +mock.module('@/contexts/runtimeAPIRegistry', () => ({ + getRegisteredRuntimeAPIs: mock(() => null), +})); + +mock.module('@/lib/runtime-url', () => ({ + getRuntimeUrlResolver: mock(() => ({ api: (path: string) => path })), +})); + +mock.module('@/lib/runtime-switch', () => ({ + getRuntimeApiBaseUrl: mock(() => ''), + getRuntimeKey: mock(() => 'test-runtime'), +})); + +mock.module('@/lib/runtime-fetch', () => ({ + runtimeFetch: runtimeFetchMock, +})); + +mock.module('@/lib/startupTrace', () => ({ + markStartupTrace: mock(() => undefined), +})); + +const { createRuntimeOpencodeClient } = await import( + `./client?timeout-final=${Date.now()}` +); + +beforeEach(() => { + capturedFetch = null; + runtimeFetchMock.mockImplementation(async () => new Response('', { status: 200 })); +}); + +describe('createRuntimeOpencodeClient fetch wrapper (#2470)', () => { + test('AbortSignal.timeout fires inside a test environment (sanity)', async () => { + const sig = AbortSignal.timeout(20); + const fired = await new Promise((resolve) => { + sig.addEventListener('abort', () => resolve(true)); + setTimeout(() => resolve(false), 200); + }); + expect(fired).toBe(true); + }); + + test('createRuntimeOpencodeClient is exported', () => { + expect(typeof createRuntimeOpencodeClient).toBe('function'); + }); + + test('a GET whose socket never settles rejects with normalized "request timed out" error', async () => { + let firedAt = 0; + let calledAt = Date.now(); + runtimeFetchMock.mockImplementation(async (_input: string | URL | Request, init?: RequestInit) => { + calledAt = Date.now(); + return new Promise((_resolve, reject) => { + init?.signal?.addEventListener('abort', () => { + firedAt = Date.now(); + reject(new DOMException('Aborted', 'AbortError')); + }); + setTimeout( + () => reject(new Error('TIMEOUT_TEST_FAIL: signal never fired')), + 1000, + ); + }); + }); + + createRuntimeOpencodeClient({ baseUrl: '', requestTimeoutMs: 25 }); + expect(capturedFetch).not.toBeNull(); + + const start = Date.now(); + await expect( + capturedFetch!('http://opencode.test/api/session'), + ).rejects.toThrow(/request timed out/); + const elapsed = Date.now() - start; + expect(firedAt).toBeGreaterThanOrEqual(calledAt); + expect(elapsed).toBeLessThan(500); + }); + + test('POST requests (long-running prompt/shell/summarize) are NOT timed out', async () => { + runtimeFetchMock.mockImplementation(async () => { + await new Promise((r) => setTimeout(r, 200)); + return new Response('ok', { status: 200 }); + }); + + createRuntimeOpencodeClient({ baseUrl: '', requestTimeoutMs: 25 }); + expect(capturedFetch).not.toBeNull(); + + const response = await capturedFetch!( + 'http://opencode.test/session/prompt', + { method: 'POST' }, + ); + expect(await response.text()).toBe('ok'); + }); + + test('the /event SSE stream is NOT timed out', async () => { + runtimeFetchMock.mockImplementation(async () => { + await new Promise((r) => setTimeout(r, 200)); + return new Response('event: ping\n\n', { + status: 200, + headers: { 'Content-Type': 'text/event-stream' }, + }); + }); + + createRuntimeOpencodeClient({ baseUrl: '', requestTimeoutMs: 25 }); + expect(capturedFetch).not.toBeNull(); + + const response = await capturedFetch!( + 'http://opencode.test/api/global/event', + ); + expect(response.headers.get('Content-Type')).toBe('text/event-stream'); + }); + + test('caller-provided abort signal still wins (no normalized timeout error)', async () => { + runtimeFetchMock.mockImplementation(async (_input: string | URL | Request, init?: RequestInit) => + new Promise((_, reject) => { + init?.signal?.addEventListener('abort', () => { + reject(new DOMException('Aborted', 'AbortError')); + }); + }), + ); + + createRuntimeOpencodeClient({ baseUrl: '', requestTimeoutMs: 25 }); + expect(capturedFetch).not.toBeNull(); + + const callerController = new AbortController(); + setTimeout(() => callerController.abort(), 5); + + await expect( + capturedFetch!('http://opencode.test/api/session', { + signal: callerController.signal, + }), + ).rejects.toThrow('Aborted'); + }); +}); diff --git a/packages/ui/src/lib/opencode/client.ts b/packages/ui/src/lib/opencode/client.ts index 37e12620..ae4000fb 100644 --- a/packages/ui/src/lib/opencode/client.ts +++ b/packages/ui/src/lib/opencode/client.ts @@ -199,10 +199,61 @@ const createTimeoutSignal = (timeoutMs: number): { signal: AbortSignal; cleanup: }; }; -const createRuntimeOpencodeClient = (config: { baseUrl: string; directory?: string }): OpencodeClient => { +/** + * Upper bound for non-streaming OpenCode read requests. Without it, a socket + * that neither resolves nor rejects (the half-open state described in #2470) + * keeps the bootstrap concurrency slot busy forever and the UI stays on + * "loading sessions". Long-lived streams (POST prompts, the /event SSE) are + * explicitly excluded in {@link createRuntimeOpencodeClient}. + */ +const OPENCODE_REQUEST_TIMEOUT_MS = 30_000; + +const isEventStreamUrl = (input: string | URL | Request): boolean => { + const url = typeof input === 'string' + ? input + : input instanceof URL + ? input.toString() + : input.url; + return url.includes('/event'); +}; + +type RuntimeOpencodeClientConfig = { + baseUrl: string; + directory?: string; + /** Read-request timeout in ms. Overridable so tests can use short value. */ + requestTimeoutMs?: number; +}; + +export const createRuntimeOpencodeClient = (config: RuntimeOpencodeClientConfig): OpencodeClient => { + const requestTimeoutMs = config.requestTimeoutMs ?? OPENCODE_REQUEST_TIMEOUT_MS; return createOpencodeClient({ ...config, - fetch: runtimeFetch, + fetch: async (input: string | URL | Request, init?: RequestInit) => { + const method = String( + init?.method ?? (input instanceof Request ? input.method : 'GET'), + ).toUpperCase(); + if (isEventStreamUrl(input) || method === 'POST') { + return runtimeFetch(input, init); + } + const timeout = createTimeoutSignal(requestTimeoutMs); + const callerSignal = init?.signal; + const supportsAny = typeof AbortSignal !== 'undefined' + && typeof (AbortSignal as { any?: unknown }).any === 'function'; + const signal: AbortSignal = callerSignal && supportsAny + ? (AbortSignal as typeof AbortSignal & { any: (signals: AbortSignal[]) => AbortSignal }) + .any([callerSignal, timeout.signal]) + : (callerSignal ?? timeout.signal); + try { + return await runtimeFetch(input, { ...init, signal }); + } catch (error) { + if (timeout.signal.aborted && !callerSignal?.aborted) { + throw new Error(`OpenCode request timed out after ${requestTimeoutMs}ms`); + } + throw error; + } finally { + timeout.cleanup(); + } + }, }); }; diff --git a/packages/ui/src/sync/retry.test.ts b/packages/ui/src/sync/retry.test.ts new file mode 100644 index 00000000..d1819302 --- /dev/null +++ b/packages/ui/src/sync/retry.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, test } from 'bun:test'; + +import { retry } from './retry'; + +describe('retry transient classification (#2470)', () => { + test("'terminated' (undici half-open socket teardown) is retried", async () => { + let attempts = 0; + await expect( + retry(async () => { + attempts += 1; + throw new TypeError('terminated'); + }), + ).rejects.toThrow('terminated'); + expect(attempts).toBe(3); + }); + + test("normalized 'request timed out' (SDK read timeout) is retried", async () => { + let attempts = 0; + await expect( + retry(async () => { + attempts += 1; + throw new Error('OpenCode request timed out after 30000ms'); + }), + ).rejects.toThrow('request timed out'); + expect(attempts).toBe(3); + }); + + test('caller-initiated abort (AbortError) is NOT retried', async () => { + let attempts = 0; + await expect( + retry(async () => { + attempts += 1; + throw new DOMException('Aborted', 'AbortError'); + }), + ).rejects.toThrow('Aborted'); + expect(attempts).toBe(1); + }); +}); diff --git a/packages/ui/src/sync/retry.ts b/packages/ui/src/sync/retry.ts index 8c7aafea..0b3b0a0d 100644 --- a/packages/ui/src/sync/retry.ts +++ b/packages/ui/src/sync/retry.ts @@ -6,6 +6,10 @@ export interface RetryOptions { retryIf?: (error: unknown) => boolean } +// undici tears down half-open upstream connection with `TypeError: terminated` +// (exact failure from the #2470 logs); the SDK client also rejects reads with +// normalized "request timed out" error after OPENCODE_REQUEST_TIMEOUT_MS. +// Both are transient — managed process may be restarting. const TRANSIENT_MESSAGES = [ "load failed", "network connection was lost", @@ -18,6 +22,8 @@ const TRANSIENT_MESSAGES = [ "opencode api unavailable", "503", "502", + "terminated", + "request timed out", ] function isTransientError(error: unknown): boolean { diff --git a/packages/ui/src/types/bun-test.d.ts b/packages/ui/src/types/bun-test.d.ts index 921149c1..f7304d74 100644 --- a/packages/ui/src/types/bun-test.d.ts +++ b/packages/ui/src/types/bun-test.d.ts @@ -31,8 +31,17 @@ declare module "bun:test" { export function beforeEach(fn: () => void | Promise): void; export function afterEach(fn: () => void | Promise): void; export function afterAll(fn: () => void | Promise): void; - export function mock unknown>(fn?: T): T; + // Mock matches the bun:test runtime mock: T (callable) plus spy methods. + // Tests that need to swap implementations at runtime cast through `Mock`. + export interface Mock unknown> { + (...args: Parameters): ReturnType; + mockImplementation(fn: T): Mock; + mockReturnValue(value: ReturnType): Mock; + mockReset(): Mock; + } + export function mock unknown>(fn?: T): Mock; export namespace mock { function module(moduleName: string, factory: () => Record): void; + function restore(): void; } } From 70a7a77ded97a28be3239c6d81e644c4e8b8e69d Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Fri, 21 Aug 2026 16:57:44 +0300 Subject: [PATCH 002/157] refactor(ui): name workspace surfaces --- .../comments/useInlineCommentController.ts | 2 - .../ui/src/components/layout/MainLayout.tsx | 16 ++-- .../ui/src/components/views/TerminalView.tsx | 4 +- packages/ui/src/hooks/useRouter.ts | 38 ++++----- packages/ui/src/lib/router/index.ts | 6 +- packages/ui/src/lib/router/parseRoute.ts | 6 +- packages/ui/src/lib/router/serializeRoute.ts | 6 +- packages/ui/src/lib/router/types.ts | 10 +-- packages/ui/src/stores/DOCUMENTATION.md | 2 +- packages/ui/src/stores/useUIStore.ts | 79 +++++++++++++------ 10 files changed, 99 insertions(+), 70 deletions(-) diff --git a/packages/ui/src/components/comments/useInlineCommentController.ts b/packages/ui/src/components/comments/useInlineCommentController.ts index 7009aef0..b8862039 100644 --- a/packages/ui/src/components/comments/useInlineCommentController.ts +++ b/packages/ui/src/components/comments/useInlineCommentController.ts @@ -12,7 +12,6 @@ import { useI18n } from '@/lib/i18n'; import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory'; import { getRuntimeKey } from '@/lib/runtime-switch'; import { focusChatInput } from '@/components/chat/composer/editor/dom'; -import { useUIStore } from '@/stores/useUIStore'; type LineRangeBase = { start: number; @@ -164,7 +163,6 @@ export function useInlineCommentController( reset(); if (isNewComment) { - useUIStore.getState().setActiveMainTab('chat'); requestAnimationFrame(focusChatInput); } }, [addDraft, editingDraftId, fileLabel, getCodeForRange, language, reset, selection, source, t, target, toStoreRange, updateDraft]); diff --git a/packages/ui/src/components/layout/MainLayout.tsx b/packages/ui/src/components/layout/MainLayout.tsx index 28e44ce8..1a25e06c 100644 --- a/packages/ui/src/components/layout/MainLayout.tsx +++ b/packages/ui/src/components/layout/MainLayout.tsx @@ -45,7 +45,7 @@ const SettingsWindow = lazyWithChunkRecovery(() => import('@/components/views/Se export const MainLayout: React.FC = () => { const isSidebarOpen = useUIStore((state) => state.isSidebarOpen); - const activeMainTab = useUIStore((state) => state.activeMainTab); + const activeSurface = useUIStore((state) => state.activeSurface); const setIsMobile = useUIStore((state) => state.setIsMobile); const isSessionSwitcherOpen = useUIStore((state) => state.isSessionSwitcherOpen); const isSettingsDialogOpen = useUIStore((state) => state.isSettingsDialogOpen); @@ -83,7 +83,7 @@ export const MainLayout: React.FC = () => { if (sessionSelected || draftOpened) closeSurfacePages(); }); const unsubscribeTab = useUIStore.subscribe((state, prev) => { - if (state.activeMainTab !== prev.activeMainTab) closeSurfacePages(); + if (state.activeSurface !== prev.activeSurface) closeSurfacePages(); }); return () => { unsubscribeSession(); @@ -195,7 +195,7 @@ export const MainLayout: React.FC = () => { }, [isMobile, setMobileSessionPanelOpen]); useEffect(() => { - if (!isMobile || activeMainTab !== 'chat' || mobileLeftDrawerOpen || mobileRightSidebarOpen || isSettingsDialogOpen) { + if (!isMobile || activeSurface !== 'chat' || mobileLeftDrawerOpen || mobileRightSidebarOpen || isSettingsDialogOpen) { return; } @@ -231,7 +231,7 @@ export const MainLayout: React.FC = () => { window.clearTimeout(timeoutId); } }; - }, [activeMainTab, isMobile, isSettingsDialogOpen, mobileLeftDrawerOpen, mobileRightSidebarOpen]); + }, [activeSurface, isMobile, isSettingsDialogOpen, mobileLeftDrawerOpen, mobileRightSidebarOpen]); // Ensure mobile drawers are closed when opening full-screen settings useEffect(() => { @@ -263,10 +263,10 @@ export const MainLayout: React.FC = () => { // Desktop surfaces live in the context panel; the only full-view // overlays left there are the terminal (promoted by project actions) // and the diagram viewer. Mobile keeps the full tab set. - if (!isMobile && activeMainTab !== 'terminal' && activeMainTab !== 'diagram') { + if (!isMobile && activeSurface !== 'terminal' && activeSurface !== 'diagram') { return null; } - switch (activeMainTab) { + switch (activeSurface) { case 'plan': return ; case 'git': @@ -284,9 +284,9 @@ export const MainLayout: React.FC = () => { default: return null; } - }, [activeMainTab, isMobile, mobileRightSidebarOpen]); + }, [activeSurface, isMobile, mobileRightSidebarOpen]); - const isChatActive = activeMainTab === 'chat'; + const isChatActive = activeSurface === 'chat'; return ( diff --git a/packages/ui/src/components/views/TerminalView.tsx b/packages/ui/src/components/views/TerminalView.tsx index c97f608e..e4da0d45 100644 --- a/packages/ui/src/components/views/TerminalView.tsx +++ b/packages/ui/src/components/views/TerminalView.tsx @@ -147,8 +147,8 @@ export const TerminalView: React.FC = ({ visible }) => { terminalControllerRef.current?.focus(); }, [useTouchTerminalInput]); - const activeMainTab = useUIStore((state) => state.activeMainTab); - const isTerminalActive = activeMainTab === 'terminal'; + const activeSurface = useUIStore((state) => state.activeSurface); + const isTerminalActive = activeSurface === 'terminal'; const isTerminalVisible = visible ?? isTerminalActive; const [hasOpenedTerminalViewport, setHasOpenedTerminalViewport] = React.useState(isTerminalVisible); diff --git a/packages/ui/src/hooks/useRouter.ts b/packages/ui/src/hooks/useRouter.ts index fa794ad5..1e34e765 100644 --- a/packages/ui/src/hooks/useRouter.ts +++ b/packages/ui/src/hooks/useRouter.ts @@ -3,7 +3,7 @@ import { useSessionUIStore } from '@/sync/session-ui-store'; import { useUIStore } from '@/stores/useUIStore'; import { parseRoute, updateBrowserURL, hasRouteParams } from '@/lib/router'; import type { RouteState, AppRouteState } from '@/lib/router'; -import type { MainTab } from '@/stores/useUIStore'; +import type { WorkspaceSurface } from '@/stores/useUIStore'; import { resolveSettingsSlug } from '@/lib/settings/metadata'; import { isEmbeddedSessionChat } from '@/components/layout/contextPanelEmbeddedChat'; @@ -49,7 +49,7 @@ export function useRouter(): void { // Get store actions (stable references) const setCurrentSession = useSessionUIStore((state) => state.setCurrentSession); - const setActiveMainTab = useUIStore((state) => state.setActiveMainTab); + const setActiveSurface = useUIStore((state) => state.setActiveSurface); const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen); const setSettingsPage = useUIStore((state) => state.setSettingsPage); const navigateToDiff = useUIStore((state) => state.navigateToDiff); @@ -75,11 +75,11 @@ export function useRouter(): void { } } - // 2. Handle settings (takes precedence over tabs - it's a full-screen overlay) + // 2. Handle settings first because it is a full-screen overlay. if (route.settingsPath) { setSettingsPage(resolveSettingsSlug(route.settingsPath)); setSettingsDialogOpen(true); - // Don't process tab when settings is open + // Do not process a route view while settings is open. return; } @@ -88,9 +88,9 @@ export function useRouter(): void { setSettingsDialogOpen(false); } - // 3. Apply tab + // 3. Apply the view selected by the legacy URL parameter. if (route.tab) { - setActiveMainTab(route.tab); + setActiveSurface(route.tab); } // 4. Apply diff file (only if going to diff tab) @@ -101,7 +101,7 @@ export function useRouter(): void { isApplyingRouteRef.current = false; } }, - [setCurrentSession, setActiveMainTab, setSettingsDialogOpen, setSettingsPage, navigateToDiff] + [setCurrentSession, setActiveSurface, setSettingsDialogOpen, setSettingsPage, navigateToDiff] ); /** @@ -113,7 +113,7 @@ export function useRouter(): void { return { sessionId: sessionState.currentSessionId, - tab: uiState.activeMainTab, + tab: uiState.activeSurface, isSettingsOpen: uiState.isSettingsDialogOpen, settingsPath: uiState.settingsPage, diffFile: uiState.pendingDiffFile, @@ -162,7 +162,7 @@ export function useRouter(): void { updateBrowserURL({ ...getCurrentAppState(), sessionId: route.sessionId ?? useSessionUIStore.getState().currentSessionId, - tab: route.tab ?? useUIStore.getState().activeMainTab, + tab: route.tab ?? useUIStore.getState().activeSurface, settingsPath: route.settingsPath ?? useUIStore.getState().settingsPage, diffFile: route.diffFile ?? useUIStore.getState().pendingDiffFile, }, { replace: true, force: true }); @@ -195,13 +195,13 @@ export function useRouter(): void { return unsubscribe; }, [isVSCode, isEmbeddedChat, syncURLFromState]); - // Subscribe to UI store changes (tab, settings) + // Subscribe to UI store changes (view, settings) React.useEffect(() => { if (isVSCode || isEmbeddedChat) { return; } - let prevTab: MainTab = useUIStore.getState().activeMainTab; + let prevSurface: WorkspaceSurface = useUIStore.getState().activeSurface; let prevSettingsOpen: boolean = useUIStore.getState().isSettingsDialogOpen; let prevSettingsPath: string = useUIStore.getState().settingsPage; let prevDiffFile: string | null = useUIStore.getState().pendingDiffFile; @@ -212,19 +212,19 @@ export function useRouter(): void { return; } - const tabChanged = state.activeMainTab !== prevTab; + const surfaceChanged = state.activeSurface !== prevSurface; const settingsOpenChanged = state.isSettingsDialogOpen !== prevSettingsOpen; const settingsPathChanged = state.settingsPage !== prevSettingsPath; - const diffFileChanged = state.pendingDiffFile !== prevDiffFile && state.activeMainTab === 'diff'; + const diffFileChanged = state.pendingDiffFile !== prevDiffFile && state.activeSurface === 'diff'; // Update tracking vars - prevTab = state.activeMainTab; + prevSurface = state.activeSurface; prevSettingsOpen = state.isSettingsDialogOpen; prevSettingsPath = state.settingsPage; prevDiffFile = state.pendingDiffFile; // Only sync if something relevant changed - if (tabChanged || settingsOpenChanged || settingsPathChanged || diffFileChanged) { + if (surfaceChanged || settingsOpenChanged || settingsPathChanged || diffFileChanged) { syncURLFromState(); } }); @@ -252,9 +252,9 @@ export function useRouter(): void { if (uiState.isSettingsDialogOpen) { setSettingsDialogOpen(false); } - // Reset to chat tab if not already there - if (uiState.activeMainTab !== 'chat') { - setActiveMainTab('chat'); + // Reset to chat when no route view is specified. + if (uiState.activeSurface !== 'chat') { + setActiveSurface('chat'); } } }; @@ -264,5 +264,5 @@ export function useRouter(): void { return () => { window.removeEventListener('popstate', handlePopState); }; - }, [applyRoute, isVSCode, isEmbeddedChat, setActiveMainTab, setSettingsDialogOpen]); + }, [applyRoute, isVSCode, isEmbeddedChat, setActiveSurface, setSettingsDialogOpen]); } diff --git a/packages/ui/src/lib/router/index.ts b/packages/ui/src/lib/router/index.ts index c6f2f23c..dc68e1f8 100644 --- a/packages/ui/src/lib/router/index.ts +++ b/packages/ui/src/lib/router/index.ts @@ -6,15 +6,15 @@ * * URL Schema: * - `?session=` - Navigate to specific session - * - `?tab=` - Active main tab + * - `?tab=` - Legacy URL name for the active workspace surface * - `?settings=
` - Open settings to specific section * - `?file=` - Diff view with file selected * * Examples: * - `/?session=abc123` - Open session abc123 - * - `/?tab=git` - Open git tab + * - `/?tab=git` - Open the Git surface * - `/?settings=providers` - Open settings to providers section - * - `/?tab=diff&file=src/main.ts` - Open diff view with file + * - `/?tab=diff&file=src/main.ts` - Open the Diff surface with a file */ export type { RouteState } from './types'; diff --git a/packages/ui/src/lib/router/parseRoute.ts b/packages/ui/src/lib/router/parseRoute.ts index 23addc40..458fb4f9 100644 --- a/packages/ui/src/lib/router/parseRoute.ts +++ b/packages/ui/src/lib/router/parseRoute.ts @@ -1,4 +1,4 @@ -import type { MainTab } from '@/stores/useUIStore'; +import type { WorkspaceSurface } from '@/stores/useUIStore'; import { type RouteState, VALID_TABS, @@ -52,13 +52,13 @@ function parseSessionId(params: URLSearchParams): string | null { * Parse main tab from URL parameters. * Returns null if missing or invalid. */ -function parseTab(params: URLSearchParams): MainTab | null { +function parseTab(params: URLSearchParams): WorkspaceSurface | null { const value = params.get(ROUTE_PARAMS.TAB); if (!value) { return null; } - const normalized = value.toLowerCase().trim() as MainTab; + const normalized = value.toLowerCase().trim() as WorkspaceSurface; if (VALID_TABS.includes(normalized)) { return normalized; } diff --git a/packages/ui/src/lib/router/serializeRoute.ts b/packages/ui/src/lib/router/serializeRoute.ts index c4c27c11..6b6e054c 100644 --- a/packages/ui/src/lib/router/serializeRoute.ts +++ b/packages/ui/src/lib/router/serializeRoute.ts @@ -1,4 +1,4 @@ -import type { MainTab } from '@/stores/useUIStore'; +import type { WorkspaceSurface } from '@/stores/useUIStore'; import { isEmbeddedSessionChat } from '@/components/layout/contextPanelEmbeddedChat'; import { ROUTE_PARAMS } from './types'; @@ -7,7 +7,7 @@ import { ROUTE_PARAMS } from './types'; */ export interface AppRouteState { sessionId: string | null; - tab: MainTab; + tab: WorkspaceSurface; isSettingsOpen: boolean; settingsPath: string; diffFile: string | null; @@ -16,7 +16,7 @@ export interface AppRouteState { /** * Default tab when none is specified. */ -const DEFAULT_TAB: MainTab = 'chat'; +const DEFAULT_TAB: WorkspaceSurface = 'chat'; /** * Serialize application state to URL search parameters. diff --git a/packages/ui/src/lib/router/types.ts b/packages/ui/src/lib/router/types.ts index 3978b450..9c5801e2 100644 --- a/packages/ui/src/lib/router/types.ts +++ b/packages/ui/src/lib/router/types.ts @@ -1,5 +1,5 @@ import type { SidebarSection } from '@/constants/sidebar'; -import type { MainTab } from '@/stores/useUIStore'; +import type { WorkspaceSurface } from '@/stores/useUIStore'; /** * Represents the current route state derived from URL parameters. @@ -8,8 +8,8 @@ import type { MainTab } from '@/stores/useUIStore'; export interface RouteState { /** Session ID to navigate to */ sessionId: string | null; - /** Main tab to display (chat, git, diff, terminal, files) */ - tab: MainTab | null; + /** View selected through the legacy `tab` URL parameter. */ + tab: WorkspaceSurface | null; /** Settings section - when non-null, settings dialog should be open */ settingsPath: string | null; /** File path for diff view */ @@ -17,9 +17,9 @@ export interface RouteState { } /** - * Valid main tab values for URL routing. + * Valid values for the legacy `tab` URL parameter. */ -export const VALID_TABS: readonly MainTab[] = ['chat', 'git', 'diff', 'terminal', 'files', 'diagram'] as const; +export const VALID_TABS: readonly WorkspaceSurface[] = ['chat', 'git', 'diff', 'terminal', 'files', 'diagram'] as const; /** * Valid settings section values for URL routing. diff --git a/packages/ui/src/stores/DOCUMENTATION.md b/packages/ui/src/stores/DOCUMENTATION.md index 6ef48876..49ed4a87 100644 --- a/packages/ui/src/stores/DOCUMENTATION.md +++ b/packages/ui/src/stores/DOCUMENTATION.md @@ -38,7 +38,7 @@ Examples: - `useFeatureFlagsStore.ts` - `useUpdateStore.ts` -These stores coordinate visible app state, navigation, selected tabs, dialogs, and lightweight feature flags. +These stores coordinate visible app state, navigation, selected context-panel tabs, dialogs, and lightweight feature flags. `useUIStore.activeSurface` selects the primary mobile view and the few desktop views that are promoted out of the context panel. It is not a desktop tab selection. Context-panel session chats mount only the active chat iframe. After installing its message listener, the iframe requests its authoritative visibility from the diff --git a/packages/ui/src/stores/useUIStore.ts b/packages/ui/src/stores/useUIStore.ts index 24235b3b..7c772122 100644 --- a/packages/ui/src/stores/useUIStore.ts +++ b/packages/ui/src/stores/useUIStore.ts @@ -13,7 +13,13 @@ import { useFilesViewTabsStore } from './useFilesViewTabsStore'; import { isWindowsArm64 } from '@/lib/platform'; import { isVSCodeRuntime } from '@/lib/desktop'; -export type MainTab = 'chat' | 'plan' | 'git' | 'diff' | 'terminal' | 'files' | 'context' | 'diagram'; +/** + * The primary view on mobile and the desktop's promoted full-screen view. + * Desktop context-panel content is not represented here. + */ +export type WorkspaceSurface = 'chat' | 'plan' | 'git' | 'diff' | 'terminal' | 'files' | 'context' | 'diagram'; +/** @deprecated Use WorkspaceSurface. */ +export type MainTab = WorkspaceSurface; export type PendingDiffScope = 'working' | 'staged' | 'turn'; export type ContextPanelMode = 'diff' | 'walkthrough' | 'file' | 'context' | 'plan' | 'chat' | 'browser' | 'git' | 'pr' | 'notes' | 'terminal'; export type MermaidRenderingMode = 'svg' | 'ascii'; @@ -77,7 +83,9 @@ type PendingFileNavigation = { column: number; }; -export type MainTabGuard = (nextTab: MainTab) => boolean; +export type WorkspaceSurfaceGuard = (nextSurface: WorkspaceSurface) => boolean; +/** @deprecated Use WorkspaceSurfaceGuard. */ +export type MainTabGuard = WorkspaceSurfaceGuard; export type EventStreamStatus = | 'idle' | 'connecting' @@ -129,7 +137,7 @@ const CONTEXT_PANEL_MAX_WIDTH = 1400; const CONTEXT_PANEL_MAX_TABS = 12; const CONTEXT_PANEL_MAX_LABEL_LENGTH = 120; const LEFT_SIDEBAR_MIN_WIDTH = 280; -const activeMainTabByRuntime = new Map(); +const activeSurfaceByRuntime = new Map(); /** Separates browser tabs opened in the same millisecond. */ let browserTabSequence = 0; @@ -648,8 +656,12 @@ interface UIStore { workStatusHiddenSections: string[]; isSessionSwitcherOpen: boolean; isSessionDropdownOpen: boolean; - activeMainTab: MainTab; - mainTabGuard: MainTabGuard | null; + activeSurface: WorkspaceSurface; + surfaceGuard: WorkspaceSurfaceGuard | null; + /** @deprecated Use activeSurface. */ + activeMainTab: WorkspaceSurface; + /** @deprecated Use surfaceGuard. */ + mainTabGuard: WorkspaceSurfaceGuard | null; sidebarOpenBeforeFullscreenTab: boolean | null; pendingDiffFile: string | null; pendingDiffStaged: boolean; @@ -834,10 +846,14 @@ interface UIStore { setWorkStatusHiddenSections: (sectionIds: string[]) => void; setSessionSwitcherOpen: (open: boolean) => void; setSessionDropdownOpen: (open: boolean) => void; - setActiveMainTab: (tab: MainTab) => void; + setActiveSurface: (surface: WorkspaceSurface) => void; + /** @deprecated Use setActiveSurface. */ + setActiveMainTab: (surface: WorkspaceSurface) => void; prepareForRuntimeSwitch: (runtimeKey?: string | null) => void; restoreForRuntimeSwitch: (runtimeKey?: string | null) => void; - setMainTabGuard: (guard: MainTabGuard | null) => void; + setSurfaceGuard: (guard: WorkspaceSurfaceGuard | null) => void; + /** @deprecated Use setSurfaceGuard. */ + setMainTabGuard: (guard: WorkspaceSurfaceGuard | null) => void; setPendingDiffFile: (filePath: string | null, staged?: boolean, scope?: PendingDiffScope | null) => void; setPendingDiagramFile: (filePath: string | null) => void; setPendingFileNavigation: (navigation: PendingFileNavigation | null) => void; @@ -1011,6 +1027,8 @@ export const useUIStore = create()( workStatusHiddenSections: [], isSessionSwitcherOpen: false, isSessionDropdownOpen: false, + activeSurface: 'chat', + surfaceGuard: null, activeMainTab: 'chat', mainTabGuard: null, sidebarOpenBeforeFullscreenTab: null, @@ -1625,29 +1643,33 @@ export const useUIStore = create()( set({ isSessionDropdownOpen: open }); }, - setMainTabGuard: (guard) => { - if (get().mainTabGuard === guard) { + setSurfaceGuard: (guard) => { + if (get().surfaceGuard === guard) { return; } - set({ mainTabGuard: guard }); + set({ surfaceGuard: guard, mainTabGuard: guard }); }, - setActiveMainTab: (tab) => { - const guard = get().mainTabGuard; - if (guard && !guard(tab)) { + setMainTabGuard: (guard) => get().setSurfaceGuard(guard), + + setActiveSurface: (surface) => { + const guard = get().surfaceGuard; + if (guard && !guard(surface)) { return; } - activeMainTabByRuntime.set(runtimeMemoryKey(), tab); - set({ activeMainTab: tab }); + activeSurfaceByRuntime.set(runtimeMemoryKey(), surface); + set({ activeSurface: surface, activeMainTab: surface }); }, + setActiveMainTab: (surface) => get().setActiveSurface(surface), + prepareForRuntimeSwitch: (runtimeKey?: string | null) => { - activeMainTabByRuntime.set(runtimeMemoryKey(runtimeKey), get().activeMainTab); + activeSurfaceByRuntime.set(runtimeMemoryKey(runtimeKey), get().activeSurface); }, restoreForRuntimeSwitch: (runtimeKey?: string | null) => { - const restored = activeMainTabByRuntime.get(runtimeMemoryKey(runtimeKey)) ?? 'chat'; - set({ activeMainTab: restored }); + const restored = activeSurfaceByRuntime.get(runtimeMemoryKey(runtimeKey)) ?? 'chat'; + set({ activeSurface: restored, activeMainTab: restored }); }, setPendingDiffFile: (filePath, staged = false, scope = null) => { @@ -1671,11 +1693,11 @@ export const useUIStore = create()( }, navigateToDiff: (filePath, staged = false, scope = null) => { - const guard = get().mainTabGuard; + const guard = get().surfaceGuard; if (guard && !guard('diff')) { return; } - set({ pendingDiffFile: filePath, pendingDiffStaged: staged, pendingDiffScope: scope, activeMainTab: 'diff' }); + set({ pendingDiffFile: filePath, pendingDiffStaged: staged, pendingDiffScope: scope, activeSurface: 'diff', activeMainTab: 'diff' }); }, consumePendingDiffFile: () => { @@ -1687,11 +1709,11 @@ export const useUIStore = create()( }, navigateToDiagram: (filePath) => { - const guard = get().mainTabGuard; + const guard = get().surfaceGuard; if (guard && !guard('diagram')) { return; } - set({ pendingDiagramFile: filePath, activeMainTab: 'diagram' }); + set({ pendingDiagramFile: filePath, activeSurface: 'diagram', activeMainTab: 'diagram' }); }, consumePendingDiagramFile: () => { @@ -2462,13 +2484,20 @@ export const useUIStore = create()( { name: 'ui-store', storage: createDeferredSafeJSONStorage(), - version: 14, + version: 15, migrate: (persistedState, version) => { if (!persistedState || typeof persistedState !== 'object') { return persistedState; } const state = persistedState as Record; + // v14 -> v15: rename the historic main-tab field. The selected + // mobile or promoted desktop view remains unchanged. + if (version < 15) { + state.activeSurface = state.activeMainTab; + state.activeMainTab = state.activeSurface; + } + // v13 -> v14: the separate 'preview' surface merged into 'browser'. // Stored preview tabs keep their URL and become browser tabs; their // id encodes the mode, so it is rebuilt rather than left dangling. @@ -2674,7 +2703,9 @@ export const useUIStore = create()( workStatusPanelEnabled: state.workStatusPanelEnabled, workStatusHiddenSections: state.workStatusHiddenSections, isSessionSwitcherOpen: state.isSessionSwitcherOpen, - activeMainTab: state.activeMainTab, + activeSurface: state.activeSurface, + // Keep the deprecated mirror synchronized while consumers migrate. + activeMainTab: state.activeSurface, sidebarSection: state.sidebarSection, settingsPage: state.settingsPage, settingsHasOpenedOnce: state.settingsHasOpenedOnce, From 96c7be93427cdd1280a27b7fabf653dfc4666a11 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Fri, 21 Aug 2026 17:27:07 +0300 Subject: [PATCH 003/157] feat(chat): animate draft session transition --- .../ui/src/components/chat/ChatContainer.tsx | 281 +++++++++--------- packages/ui/src/components/chat/ChatInput.tsx | 57 ++-- .../components/chat/composer/DOCUMENTATION.md | 8 + 3 files changed, 181 insertions(+), 165 deletions(-) diff --git a/packages/ui/src/components/chat/ChatContainer.tsx b/packages/ui/src/components/chat/ChatContainer.tsx index 8b07222e..0d769c95 100644 --- a/packages/ui/src/components/chat/ChatContainer.tsx +++ b/packages/ui/src/components/chat/ChatContainer.tsx @@ -65,6 +65,8 @@ const EMPTY_MESSAGES: Array<{ info: Message; parts: Part[] }> = []; const IDLE_SESSION_STATUS = { type: 'idle' as const }; const CHAT_FORCE_SCROLL_BOTTOM_EVENT = 'openchamber:chat-force-scroll-bottom'; const DEFAULT_RETRY_MESSAGE = 'Quota limit reached. Retrying automatically.'; +const DRAFT_EXIT_DURATION_MS = 100; +const COMPOSER_MOVE_DURATION_MS = 120; const CHAT_SCROLL_STYLE = { overflowAnchor: 'none', overscrollBehavior: 'contain', @@ -502,7 +504,7 @@ const renderDraftTitle = (title: string, projectLabel: string | null): React.Rea ); }; -const DraftWelcome: React.FC = () => { +const DraftWelcome: React.FC<{ exiting?: boolean }> = ({ exiting = false }) => { const { t } = useI18n(); const draftTarget = useSessionUIStore((state) => state.newSessionDraft.target); const selectedProjectId = useSessionUIStore((state) => state.newSessionDraft.selectedProjectId ?? null); @@ -516,7 +518,10 @@ const DraftWelcome: React.FC = () => { }, [draftTarget, selectedProjectId])); return ( -
+

{renderDraftTitle( projectLabel @@ -1093,11 +1098,62 @@ export const ChatContainer: React.FC = ({ void ensureSessionRenderable(currentSessionId); }, [currentSessionId, ensureSessionRenderable, hasRenderableSessionSnapshot, messagesEnabled]); + const composerSlotRef = React.useRef(null); + const previousComposerRectRef = React.useRef(null); + const previousDraftOpenRef = React.useRef(draftOpen); + const previousDraftLayoutVisibleRef = React.useRef(draftOpen); + const [draftExitAnimating, setDraftExitAnimating] = React.useState(false); + const draftPresentationExiting = draftExitAnimating + || (previousDraftOpenRef.current && !draftOpen && Boolean(currentSessionId)); + const draftLayoutVisible = draftOpen || draftPresentationExiting; + + React.useLayoutEffect(() => { + if (draftOpen) { + setDraftExitAnimating(false); + return; + } + if (!previousDraftOpenRef.current || !currentSessionId) return; + + setDraftExitAnimating(true); + const timeoutId = window.setTimeout(() => setDraftExitAnimating(false), DRAFT_EXIT_DURATION_MS); + return () => window.clearTimeout(timeoutId); + }, [currentSessionId, draftOpen]); + + React.useLayoutEffect(() => { + previousDraftOpenRef.current = draftOpen; + }, [draftOpen]); + + React.useLayoutEffect(() => { + const composerSlot = composerSlotRef.current; + if (!composerSlot) return; + + const composerEditor = composerSlot.querySelector('[data-testid="chat-input"]'); + const currentRect = composerEditor?.getBoundingClientRect() ?? composerSlot.getBoundingClientRect(); + const previousRect = previousComposerRectRef.current; + const leftDraftLayout = previousDraftLayoutVisibleRef.current + && !draftLayoutVisible + && Boolean(currentSessionId); + const reduceMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false; + + if (leftDraftLayout && previousRect && !reduceMotion && !useCompactDraftLayout && !isDesktopExpandedInput) { + const deltaX = previousRect.left - currentRect.left; + const deltaY = previousRect.top - currentRect.top; + composerSlot.animate( + [ + { transform: `translate(${deltaX}px, ${deltaY}px)` }, + { transform: 'translate(0, 0)' }, + ], + { duration: COMPOSER_MOVE_DURATION_MS, easing: 'cubic-bezier(0.22, 1, 0.36, 1)' }, + ); + } + + previousComposerRectRef.current = currentRect; + previousDraftLayoutVisibleRef.current = draftLayoutVisible; + }, [currentSessionId, draftLayoutVisible, isDesktopExpandedInput, useCompactDraftLayout]); + if (!currentSessionId && !draftOpen) { - // With auto-open, the draft welcome opens on the next tick (effect below), - // so the empty state is only ever transient here — render a neutral - // background instead of flashing the logo / "start a new chat" on refresh. - // Keep the empty state when there's nothing to auto-open or an init error to show. + // The auto-open effect runs on the next tick. Use a neutral background + // until then instead of flashing the standard empty state. if (autoOpenDraft && !initError) { return
; } @@ -1108,82 +1164,37 @@ export const ChatContainer: React.FC = ({ ); } - if (!currentSessionId && draftOpen) { - return ( - // No transform on this root: it would become the containing block for - // the fullscreen composer's position:fixed visual-viewport pinning in - // mobile browsers (see ChatInput's composerFormRef effect). -
-
- {useCompactDraftLayout && !isDesktopExpandedInput ? : null} -
- {promptReadOnly ? : } -
- {workStatusOverlayMountable ? ( - - ) : null} -
- {workStatusPanelMountable ? ( - - ) : null} -
- ); - } + const sessionSurface = (() => { + if (draftOpen || draftPresentationExiting) { + if (!useCompactDraftLayout || isDesktopExpandedInput) { + return null; + } + return ; + } - if (!currentSessionId) { - return null; - } + if (isSessionHydrating && sessionMessages.length === 0 && !sessionIsWorking) { + if (sessionMessageLoadState.status === 'error') { + return ( +
+
+
+ +
+

{t('chat.container.sessionLoadError.title')}

+

{t('chat.container.sessionLoadError.description')}

+ +
+
+ ); + } - if (isSessionHydrating && sessionMessages.length === 0 && !sessionIsWorking) { - if (sessionMessageLoadState.status === 'error') { - return ( -
- {returnToParentButton} -
-
-
- -
-

{t('chat.container.sessionLoadError.title')}

-

{t('chat.container.sessionLoadError.description')}

- -
-
-
- {promptReadOnly ? : } -
-
- ); - } - return ( -
- {returnToParentButton} -
@@ -1194,20 +1205,18 @@ export const ChatContainer: React.FC = ({
- {item.toolRows.map((row) => { - return ( -
- - - -
- ); - })} + {item.toolRows.map((row) => ( +
+ + + +
+ ))}
- - - + {item.textWidths.map((width, index) => ( + + ))}
@@ -1216,62 +1225,25 @@ export const ChatContainer: React.FC = ({
+ ); + } + + if (sessionMessages.length === 0 && !sessionIsWorking) { + return (
- {promptReadOnly ? : } -
-

- ); - } - - if (sessionMessages.length === 0 && !sessionIsWorking) { - return ( - // No transform here either — same fixed-positioning constraint as the - // draft branch above. -
- {returnToParentButton} -
- {!isDesktopExpandedInput ? ( -
- -
- ) : null} -
-
- {promptReadOnly ? : } -
-
- ); - } + /> + ); + } - return ( -
-
- {returnToParentButton} - = ({ isLoadingOlderPrompts={timelineController.isLoadingOlder} onLoadEarlierPrompts={handleLoadOlderClick} /> + ); + })(); + + return ( +
+
+ {returnToParentButton} + {sessionSurface}
- {!isDesktopExpandedInput && sessionMessages.length > 0 && ( + {!draftLayoutVisible && !isDesktopExpandedInput && sessionMessages.length > 0 && ( )} - {promptReadOnly ? : } + {promptReadOnly ? ( + + ) : ( + + )}
{/* Inside the chat column, not beside it: as a row sibling it took diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx index fa98b7d0..83a3c2e9 100644 --- a/packages/ui/src/components/chat/ChatInput.tsx +++ b/packages/ui/src/components/chat/ChatInput.tsx @@ -224,6 +224,7 @@ interface ChatInputProps { onOpenSettings?: () => void; scrollToBottom?: () => void; active?: boolean; + draftPresentationExiting?: boolean; } const resolveChatDraftIdentity = (sessionId: string | null): ChatDraftIdentity | null => { @@ -237,7 +238,12 @@ const resolveChatDraftIdentity = (sessionId: string | null): ChatDraftIdentity | return createChatDraftIdentity(getRuntimeKey(), directory, sessionId); }; -const ChatInputComponent: React.FC = ({ onOpenSettings, scrollToBottom, active = true }) => { +const ChatInputComponent: React.FC = ({ + onOpenSettings, + scrollToBottom, + active = true, + draftPresentationExiting = false, +}) => { const { t } = useI18n(); // Track if we restored a draft on mount (for text selection) const initialDraftRef = React.useRef(null); @@ -2375,6 +2381,15 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo const chatSurfaceMode = useChatSurfaceMode(); const isMiniChatSurface = chatSurfaceMode === 'mini-chat'; + const showDesktopDraftPresentation = (newSessionDraftOpen || draftPresentationExiting) + && !isDesktopExpanded + && !isMobile + && !isVSCode + && !isMiniChatSurface; + const draftPresentationClassName = cn( + 'transition-opacity duration-100 ease-out motion-reduce:transition-none', + draftPresentationExiting && 'pointer-events-none opacity-0', + ); const hasPendingChanges = React.useMemo(() => { if (isMiniChatSurface) { @@ -2552,8 +2567,8 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo )} style={isMobile && inputBarOffset > 0 ? { marginBottom: `${inputBarOffset}px` } : undefined} > - {newSessionDraftOpen && !isDesktopExpanded && !isMobile && !isVSCode && !isMiniChatSurface ? ( -
+ {showDesktopDraftPresentation ? ( +

{renderDraftTitle( draftProjectLabel @@ -2624,21 +2639,23 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo ? null : } /> - {!isMobile && showDraftTargetSelectors && selectedDraftProject ? ( - + {!isMobile && (showDraftTargetSelectors || draftPresentationExiting) && selectedDraftProject ? ( +
+ +
) : null} {isMobile && showDraftTargetSelectors && selectedDraftProject ? ( = ({ onOpenSettings, scrollTo /> ) : null}

- {newSessionDraftOpen && !isDesktopExpanded && !isMobile && !isVSCode && !isMiniChatSurface ? ( + {showDesktopDraftPresentation ? ( submitPresetPrompt(starter.submitText, starter.ref.type)} - className="chat-input-column mt-4" + className={cn('chat-input-column mt-4', draftPresentationClassName)} /> ) : null} diff --git a/packages/ui/src/components/chat/composer/DOCUMENTATION.md b/packages/ui/src/components/chat/composer/DOCUMENTATION.md index 667c2db7..63136460 100644 --- a/packages/ui/src/components/chat/composer/DOCUMENTATION.md +++ b/packages/ui/src/components/chat/composer/DOCUMENTATION.md @@ -7,6 +7,14 @@ everything between typing and sending. own state and wires these modules together; it should not grow logic that belongs to one of them. +`ChatContainer.tsx` keeps one `ChatInput` mounted while a new-session draft +becomes its first session. Draft-only UI first fades for 100ms while the editor +stays in place. The parent then moves the editor to its final session position +with a 120ms transform-only FLIP animation. Reduced-motion mode skips these +transitions. Do not restore separate draft and session composer branches: +remounting the editor loses focus and interrupts the transition. Keep the +existing mobile fixed-position rules unchanged. + ## Layers | Directory | Owns | From eda20654a662f9b5c19bb6aa571ff66412e3112c Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Fri, 21 Aug 2026 17:45:05 +0300 Subject: [PATCH 004/157] fix(chat): let bash output grow with content --- packages/ui/src/components/chat/message/parts/DOCUMENTATION.md | 2 +- packages/ui/src/components/chat/message/parts/ToolPart.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/components/chat/message/parts/DOCUMENTATION.md b/packages/ui/src/components/chat/message/parts/DOCUMENTATION.md index edc16029..730b78e2 100644 --- a/packages/ui/src/components/chat/message/parts/DOCUMENTATION.md +++ b/packages/ui/src/components/chat/message/parts/DOCUMENTATION.md @@ -86,7 +86,7 @@ Use this doc when you ask an agent to change tool/header/description behavior. - The managed `openchamber` plugin tool uses the expandable path and hides its broad protocol input. The plugin supplies the selected action's human description as the native tool title; the UI renders that metadata without owning an action map. The full versioned result envelope renders through the same neutral JSON summary/tree/raw views as other tools, without a tool-specific output card. - `ToolPart` defers expanded content after a user toggle, preventing large tool input/output payloads from mounting during the initial chat render. - The rich tool diff preview lives in `ToolPartDiffPreview.tsx` and is lazy-loaded from `ToolPart`. It is the only tool-card piece that imports the `@pierre/diffs` + Shiki rendering stack, keeping that stack out of the eager chat startup graph. While its chunk loads (first rendered diff only) the plain-text patch from `PlainDiffFallback.tsx` renders as the Suspense fallback, mirroring the preview's error fallback. `ToolPart` itself must not statically import `@pierre/diffs` runtime modules or `@/lib/shiki/appThemeRegistry`. -- Running bash output falls back to `state.metadata.output` until canonical `state.output` arrives. Its fixed-height output viewport follows new output until the user scrolls up, then resumes following when the user returns to the bottom. Live output appends or replaces rewritten snapshots as plain text without worker highlighting; finalized output normalizes ANSI terminal controls with a bounded synthetic-cell budget, bypasses the throttle, and receives the normal one-time highlighted rendering. +- Running bash output falls back to `state.metadata.output` until canonical `state.output` arrives. Its output viewport grows with the content up to `46vh`, then scrolls and follows new output until the user scrolls up; following resumes when the user returns to the bottom. Live output appends or replaces rewritten snapshots as plain text without worker highlighting; finalized output normalizes ANSI terminal controls with a bounded synthetic-cell budget, bypasses the throttle, and receives the normal one-time highlighted rendering. - Thinking/Justification duration is hidden in `sorted` mode (handled in `ReasoningPart.tsx` + `JustificationBlock.tsx`). ## "I want to change description for Perplexity" (example recipe) diff --git a/packages/ui/src/components/chat/message/parts/ToolPart.tsx b/packages/ui/src/components/chat/message/parts/ToolPart.tsx index 29b5553b..18e6fa5d 100644 --- a/packages/ui/src/components/chat/message/parts/ToolPart.tsx +++ b/packages/ui/src/components/chat/message/parts/ToolPart.tsx @@ -1548,7 +1548,7 @@ const ToolExpandedContent: React.FC = React.memo(({ output, { className: part.tool === 'bash' ? 'p-1 rounded-none' : 'p-1', - maxHeightClass: isStreamingBash ? 'h-[46vh]' : part.tool === 'bash' ? 'max-h-[46vh]' : undefined, + maxHeightClass: part.tool === 'bash' ? 'max-h-[46vh]' : undefined, followKey: isStreamingBash ? outputString : undefined, } ); From f29844b2f1629cfa94a3c7678569949e23576bca Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Fri, 21 Aug 2026 20:45:49 +0300 Subject: [PATCH 005/157] feat(settings): mark integrations as experimental --- packages/docs/content/docs/de/integrations.mdx | 2 ++ packages/docs/content/docs/es/integrations.mdx | 2 ++ packages/docs/content/docs/fr/integrations.mdx | 2 ++ packages/docs/content/docs/integrations.mdx | 2 ++ packages/docs/content/docs/ja/integrations.mdx | 2 ++ packages/docs/content/docs/ko/integrations.mdx | 2 ++ packages/docs/content/docs/pl/integrations.mdx | 2 ++ packages/docs/content/docs/pt-br/integrations.mdx | 2 ++ packages/docs/content/docs/uk/integrations.mdx | 2 ++ packages/docs/content/docs/zh-cn/integrations.mdx | 2 ++ .../sections/integrations/IntegrationsPage.tsx | 7 +++++++ packages/ui/src/components/views/SettingsView.tsx | 2 +- .../messages/third-party-integrations.i18n.test.ts | 1 + .../i18n/messages/third-party-integrations.i18n.ts | 11 +++++++++++ 14 files changed, 40 insertions(+), 1 deletion(-) diff --git a/packages/docs/content/docs/de/integrations.mdx b/packages/docs/content/docs/de/integrations.mdx index 42f5972b..ff674b1f 100644 --- a/packages/docs/content/docs/de/integrations.mdx +++ b/packages/docs/content/docs/de/integrations.mdx @@ -7,6 +7,8 @@ description: Nutze dein Claude-, Command-Code- oder Cursor-Abo als Provider. Eine Integration ist ein kleines Plugin, das OpenChamber einen Provider hinzufügt — auf Basis eines Abos, das du bereits hast. Verwalten kannst du sie unter **Settings → Integrations**. +> **Experimentelle Funktion:** Integrationen können sich ändern oder nicht mehr funktionieren. Nutze sie nach eigenem Ermessen. + Verfügbare Integrationen: - **Claude Code** — dein Claude Pro- oder Max-Plan, ohne API-Keys diff --git a/packages/docs/content/docs/es/integrations.mdx b/packages/docs/content/docs/es/integrations.mdx index 206e40d8..246f52ce 100644 --- a/packages/docs/content/docs/es/integrations.mdx +++ b/packages/docs/content/docs/es/integrations.mdx @@ -7,6 +7,8 @@ description: Usa tu suscripción de Claude, Command Code o Cursor como proveedor Una integración es un pequeño plugin que añade un proveedor a OpenChamber usando una suscripción que ya tienes. Las gestionas en **Settings → Integrations**. +> **Función experimental:** las integraciones pueden cambiar o dejar de funcionar. Úsalas bajo tu propia responsabilidad. + Integraciones disponibles: - **Claude Code** — tu plan Claude Pro o Max, sin claves de API diff --git a/packages/docs/content/docs/fr/integrations.mdx b/packages/docs/content/docs/fr/integrations.mdx index df347a34..f6e4711e 100644 --- a/packages/docs/content/docs/fr/integrations.mdx +++ b/packages/docs/content/docs/fr/integrations.mdx @@ -7,6 +7,8 @@ description: Utilise ton abonnement Claude, Command Code ou Cursor comme fournis Une intégration est un petit plugin qui ajoute un fournisseur à OpenChamber à partir d'un abonnement que tu possèdes déjà. Tu les gères dans **Settings → Integrations**. +> **Fonctionnalité expérimentale :** les intégrations peuvent changer ou cesser de fonctionner. Utilise-les à ta discrétion. + Intégrations disponibles : - **Claude Code** — ton plan Claude Pro ou Max, sans clés API diff --git a/packages/docs/content/docs/integrations.mdx b/packages/docs/content/docs/integrations.mdx index 14aee1f1..d45fe3f7 100644 --- a/packages/docs/content/docs/integrations.mdx +++ b/packages/docs/content/docs/integrations.mdx @@ -7,6 +7,8 @@ description: Use your Claude, Command Code, or Cursor subscription as a provider An integration is a small plugin that adds a provider to OpenChamber using a subscription you already have. You manage them at **Settings → Integrations**. +> **Experimental feature:** integrations may change or stop working. Use them at your own discretion. + Available integrations: - **Claude Code** — your Claude Pro or Max plan, no API keys diff --git a/packages/docs/content/docs/ja/integrations.mdx b/packages/docs/content/docs/ja/integrations.mdx index 02fa8e36..df3664d2 100644 --- a/packages/docs/content/docs/ja/integrations.mdx +++ b/packages/docs/content/docs/ja/integrations.mdx @@ -7,6 +7,8 @@ description: Claude、Command Code、Cursor のサブスクリプションをプ 統合機能(インテグレーション)は、すでに持っているサブスクリプションを使って OpenChamber にプロバイダーを追加する小さなプラグインです。**Settings → Integrations** で管理します。 +> **実験的な機能:** 連携は変更されたり、動作しなくなったりする可能性があります。自己責任で使用してください。 + 利用できる統合機能: - **Claude Code** — Claude Pro または Max プラン、API キー不要 diff --git a/packages/docs/content/docs/ko/integrations.mdx b/packages/docs/content/docs/ko/integrations.mdx index f2589d50..192f0f55 100644 --- a/packages/docs/content/docs/ko/integrations.mdx +++ b/packages/docs/content/docs/ko/integrations.mdx @@ -7,6 +7,8 @@ description: Claude, Command Code 또는 Cursor 구독을 공급자로 사용하 통합 기능(인테그레이션)은 이미 가지고 있는 구독을 사용해 OpenChamber에 공급자를 추가하는 작은 플러그인입니다. **Settings → Integrations**에서 관리합니다. +> **실험 단계 기능:** 통합 기능은 변경되거나 작동하지 않을 수 있습니다. 본인의 판단에 따라 사용하세요. + 사용 가능한 통합 기능: - **Claude Code** — Claude Pro 또는 Max 플랜, API 키 불필요 diff --git a/packages/docs/content/docs/pl/integrations.mdx b/packages/docs/content/docs/pl/integrations.mdx index aca4b269..82f2429c 100644 --- a/packages/docs/content/docs/pl/integrations.mdx +++ b/packages/docs/content/docs/pl/integrations.mdx @@ -7,6 +7,8 @@ description: Używaj subskrypcji Claude, Command Code lub Cursor jako dostawcy. Integracja to mała wtyczka, która dodaje dostawcę do OpenChamber na podstawie subskrypcji, którą już masz. Zarządzasz nimi w **Settings → Integrations**. +> **Funkcja eksperymentalna:** integracje mogą się zmienić lub przestać działać. Korzystasz z nich na własną odpowiedzialność. + Dostępne integracje: - **Claude Code** — Twój plan Claude Pro lub Max, bez kluczy API diff --git a/packages/docs/content/docs/pt-br/integrations.mdx b/packages/docs/content/docs/pt-br/integrations.mdx index 1d66cbda..d5df5cbf 100644 --- a/packages/docs/content/docs/pt-br/integrations.mdx +++ b/packages/docs/content/docs/pt-br/integrations.mdx @@ -7,6 +7,8 @@ description: Use sua assinatura Claude, Command Code ou Cursor como provedor. Uma integração é um pequeno plugin que adiciona um provedor ao OpenChamber usando uma assinatura que você já tem. Você as gerencia em **Settings → Integrations**. +> **Recurso experimental:** as integrações podem mudar ou deixar de funcionar. Use-as por sua conta e risco. + Integrações disponíveis: - **Claude Code** — seu plano Claude Pro ou Max, sem chaves de API diff --git a/packages/docs/content/docs/uk/integrations.mdx b/packages/docs/content/docs/uk/integrations.mdx index b68da46c..94925355 100644 --- a/packages/docs/content/docs/uk/integrations.mdx +++ b/packages/docs/content/docs/uk/integrations.mdx @@ -7,6 +7,8 @@ description: Використовуйте підписки Claude, Command Code Інтеграція — це невеликий плагін, що додає провайдера до OpenChamber на основі підписки, яка в вас уже є. Керувати ними можна в **Settings → Integrations**. +> **Експериментальна функція:** інтеграції можуть змінюватися або перестати працювати. Використовуйте їх на власний розсуд. + Доступні інтеграції: - **Claude Code** — ваша підписка Claude Pro або Max, без API-ключів diff --git a/packages/docs/content/docs/zh-cn/integrations.mdx b/packages/docs/content/docs/zh-cn/integrations.mdx index 94daf884..e13b5ee9 100644 --- a/packages/docs/content/docs/zh-cn/integrations.mdx +++ b/packages/docs/content/docs/zh-cn/integrations.mdx @@ -7,6 +7,8 @@ description: 将你的 Claude、Command Code 或 Cursor 订阅用作提供商。 集成是一个小型插件,它使用你已有的订阅为 OpenChamber 添加一个提供商。你可以在 **Settings → Integrations** 中管理它们。 +> **实验性功能:**集成可能会变更或停止工作。请自行酌情使用。 + 可用的集成: - **Claude Code** — 你的 Claude Pro 或 Max 套餐,无需 API 密钥 diff --git a/packages/ui/src/components/sections/integrations/IntegrationsPage.tsx b/packages/ui/src/components/sections/integrations/IntegrationsPage.tsx index f07ce340..047755d0 100644 --- a/packages/ui/src/components/sections/integrations/IntegrationsPage.tsx +++ b/packages/ui/src/components/sections/integrations/IntegrationsPage.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import { Icon } from '@/components/icon/Icon'; import { SettingsPageLayout } from '@/components/sections/shared/SettingsPageLayout'; import { useI18n } from '@/lib/i18n'; import { ThirdPartyIntegrationsSection } from './ThirdPartyIntegrationsSection'; @@ -20,6 +21,12 @@ export const IntegrationsPage: React.FC = ({ description={t('settings.page.integrations.description')} showSaveStatus={false} > +
+ +

+ {t('settings.integrations.experimentalWarning')} +

+
= ({ onClose, forceMobile : } {getPageTitle(page.slug)} - {page.slug === 'tunnel' && ( + {(page.slug === 'tunnel' || page.slug === 'integrations') && ( {t('settings.view.badge.beta')} diff --git a/packages/ui/src/lib/i18n/messages/third-party-integrations.i18n.test.ts b/packages/ui/src/lib/i18n/messages/third-party-integrations.i18n.test.ts index 03a57e39..01cd2750 100644 --- a/packages/ui/src/lib/i18n/messages/third-party-integrations.i18n.test.ts +++ b/packages/ui/src/lib/i18n/messages/third-party-integrations.i18n.test.ts @@ -6,6 +6,7 @@ const locales = ['en', 'de', 'fr', 'es', 'ja', 'pt-BR', 'uk', 'ko', 'pl', 'zh-CN const requiredKeys = [ 'settings.page.integrations.title', 'settings.page.integrations.description', + 'settings.integrations.experimentalWarning', 'settings.integrations.messengers.title', 'settings.integrations.messengers.discord.name', 'settings.integrations.messengers.telegram.name', diff --git a/packages/ui/src/lib/i18n/messages/third-party-integrations.i18n.ts b/packages/ui/src/lib/i18n/messages/third-party-integrations.i18n.ts index 7bf69308..38b64549 100644 --- a/packages/ui/src/lib/i18n/messages/third-party-integrations.i18n.ts +++ b/packages/ui/src/lib/i18n/messages/third-party-integrations.i18n.ts @@ -3,6 +3,7 @@ export const thirdPartyIntegrationI18n = { en: { 'settings.page.integrations.title': 'Integrations', 'settings.page.integrations.description': 'Add third-party subscriptions to use as OpenChamber providers.', + 'settings.integrations.experimentalWarning': 'This is an experimental feature. Integrations may change or stop working. Use them at your own discretion.', 'settings.integrations.messengers.title': 'Messengers', 'settings.integrations.messengers.info': 'Chat with OpenChamber from Discord or Telegram. These bridges are not available yet.', 'settings.integrations.messengers.discord.name': 'Discord', @@ -45,6 +46,7 @@ export const thirdPartyIntegrationI18n = { de: { 'settings.page.integrations.title': 'Integrationen', 'settings.page.integrations.description': 'Füge Drittanbieter-Abonnements hinzu, um sie als OpenChamber-Provider zu nutzen.', + 'settings.integrations.experimentalWarning': 'Dies ist eine experimentelle Funktion. Integrationen können sich ändern oder nicht mehr funktionieren. Nutze sie nach eigenem Ermessen.', 'settings.integrations.messengers.title': 'Messenger', 'settings.integrations.messengers.info': 'Chatte mit OpenChamber über Discord oder Telegram. Diese Bridges sind noch nicht verfügbar.', 'settings.integrations.messengers.discord.name': 'Discord', @@ -87,6 +89,7 @@ export const thirdPartyIntegrationI18n = { fr: { 'settings.page.integrations.title': 'Intégrations', 'settings.page.integrations.description': 'Ajoutez des abonnements tiers à utiliser comme fournisseurs OpenChamber.', + 'settings.integrations.experimentalWarning': 'Cette fonctionnalité est expérimentale. Les intégrations peuvent changer ou cesser de fonctionner. Utilisez-les à votre discrétion.', 'settings.integrations.messengers.title': 'Messagers', 'settings.integrations.messengers.info': 'Discutez avec OpenChamber depuis Discord ou Telegram. Ces ponts ne sont pas encore disponibles.', 'settings.integrations.messengers.discord.name': 'Discord', @@ -129,6 +132,7 @@ export const thirdPartyIntegrationI18n = { es: { 'settings.page.integrations.title': 'Integraciones', 'settings.page.integrations.description': 'Añade suscripciones de terceros para usarlas como proveedores de OpenChamber.', + 'settings.integrations.experimentalWarning': 'Esta función es experimental. Las integraciones pueden cambiar o dejar de funcionar. Úsalas bajo tu propia responsabilidad.', 'settings.integrations.messengers.title': 'Mensajeros', 'settings.integrations.messengers.info': 'Chatea con OpenChamber desde Discord o Telegram. Estos puentes aún no están disponibles.', 'settings.integrations.messengers.discord.name': 'Discord', @@ -171,6 +175,7 @@ export const thirdPartyIntegrationI18n = { ja: { 'settings.page.integrations.title': '連携', 'settings.page.integrations.description': 'サードパーティのサブスクリプションを追加して、OpenChamber のプロバイダーとして使います。', + 'settings.integrations.experimentalWarning': 'これは実験的な機能です。連携は変更されたり、動作しなくなったりする可能性があります。自己責任で使用してください。', 'settings.integrations.messengers.title': 'メッセンジャー', 'settings.integrations.messengers.info': 'Discord または Telegram から OpenChamber とチャットできます。これらの連携はまだ利用できません。', 'settings.integrations.messengers.discord.name': 'Discord', @@ -213,6 +218,7 @@ export const thirdPartyIntegrationI18n = { ko: { 'settings.page.integrations.title': '통합', 'settings.page.integrations.description': '타사 구독을 추가해 OpenChamber 프로바이더로 사용하세요.', + 'settings.integrations.experimentalWarning': '이 기능은 실험 단계입니다. 통합 기능은 변경되거나 작동하지 않을 수 있습니다. 본인의 판단에 따라 사용하세요.', 'settings.integrations.messengers.title': '메신저', 'settings.integrations.messengers.info': 'Discord 또는 Telegram에서 OpenChamber와 채팅하세요. 이 브리지는 아직 사용할 수 없습니다.', 'settings.integrations.messengers.discord.name': 'Discord', @@ -255,6 +261,7 @@ export const thirdPartyIntegrationI18n = { pl: { 'settings.page.integrations.title': 'Integracje', 'settings.page.integrations.description': 'Dodaj subskrypcje zewnętrzne, aby używać ich jako dostawców OpenChamber.', + 'settings.integrations.experimentalWarning': 'To funkcja eksperymentalna. Integracje mogą się zmienić lub przestać działać. Korzystasz z nich na własną odpowiedzialność.', 'settings.integrations.messengers.title': 'Komunikatory', 'settings.integrations.messengers.info': 'Czatuj z OpenChamber przez Discord lub Telegram. Te mosty nie są jeszcze dostępne.', 'settings.integrations.messengers.discord.name': 'Discord', @@ -297,6 +304,7 @@ export const thirdPartyIntegrationI18n = { 'pt-BR': { 'settings.page.integrations.title': 'Integrações', 'settings.page.integrations.description': 'Adicione assinaturas de terceiros para usar como provedores do OpenChamber.', + 'settings.integrations.experimentalWarning': 'Este recurso é experimental. As integrações podem mudar ou deixar de funcionar. Use-as por sua conta e risco.', 'settings.integrations.messengers.title': 'Mensageiros', 'settings.integrations.messengers.info': 'Converse com o OpenChamber pelo Discord ou Telegram. Essas pontes ainda não estão disponíveis.', 'settings.integrations.messengers.discord.name': 'Discord', @@ -339,6 +347,7 @@ export const thirdPartyIntegrationI18n = { uk: { 'settings.page.integrations.title': 'Інтеграції', 'settings.page.integrations.description': 'Додайте сторонні підписки, щоб використовувати їх як провайдери OpenChamber.', + 'settings.integrations.experimentalWarning': 'Це експериментальна функція. Інтеграції можуть змінюватися або перестати працювати. Використовуйте їх на власний розсуд.', 'settings.integrations.messengers.title': 'Месенджери', 'settings.integrations.messengers.info': 'Спілкуйтеся з OpenChamber у Discord або Telegram. Ці мости ще недоступні.', 'settings.integrations.messengers.discord.name': 'Discord', @@ -381,6 +390,7 @@ export const thirdPartyIntegrationI18n = { 'zh-CN': { 'settings.page.integrations.title': '集成', 'settings.page.integrations.description': '添加第三方订阅,将其用作 OpenChamber 提供商。', + 'settings.integrations.experimentalWarning': '这是实验性功能。集成可能会变更或停止工作。请自行酌情使用。', 'settings.integrations.messengers.title': '即时通讯', 'settings.integrations.messengers.info': '通过 Discord 或 Telegram 与 OpenChamber 聊天。这些桥接尚不可用。', 'settings.integrations.messengers.discord.name': 'Discord', @@ -423,6 +433,7 @@ export const thirdPartyIntegrationI18n = { 'zh-TW': { 'settings.page.integrations.title': '整合', 'settings.page.integrations.description': '新增第三方訂閱,將其用作 OpenChamber 供應商。', + 'settings.integrations.experimentalWarning': '這是實驗性功能。整合可能會變更或停止運作。請自行斟酌使用。', 'settings.integrations.messengers.title': '即時通訊', 'settings.integrations.messengers.info': '透過 Discord 或 Telegram 與 OpenChamber 聊天。這些橋接尚不可用。', 'settings.integrations.messengers.discord.name': 'Discord', From 9f7d839fc61dabc72f118d4293153e246da8ec9e Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Fri, 21 Aug 2026 20:46:44 +0300 Subject: [PATCH 006/157] docs(integrations): add provider account notice --- packages/docs/content/docs/de/integrations.mdx | 2 ++ packages/docs/content/docs/es/integrations.mdx | 2 ++ packages/docs/content/docs/fr/integrations.mdx | 2 ++ packages/docs/content/docs/integrations.mdx | 2 ++ packages/docs/content/docs/ja/integrations.mdx | 2 ++ packages/docs/content/docs/ko/integrations.mdx | 2 ++ packages/docs/content/docs/pl/integrations.mdx | 2 ++ packages/docs/content/docs/pt-br/integrations.mdx | 2 ++ packages/docs/content/docs/uk/integrations.mdx | 2 ++ packages/docs/content/docs/zh-cn/integrations.mdx | 2 ++ 10 files changed, 20 insertions(+) diff --git a/packages/docs/content/docs/de/integrations.mdx b/packages/docs/content/docs/de/integrations.mdx index ff674b1f..58c2acf4 100644 --- a/packages/docs/content/docs/de/integrations.mdx +++ b/packages/docs/content/docs/de/integrations.mdx @@ -9,6 +9,8 @@ Eine Integration ist ein kleines Plugin, das OpenChamber einen Provider hinzufü > **Experimentelle Funktion:** Integrationen können sich ändern oder nicht mehr funktionieren. Nutze sie nach eigenem Ermessen. +Wir haben diese Integrationen so entwickelt, dass sie die vorgesehenen Anmeldewege der Anbieter nutzen und bekannte Verstöße gegen deren Nutzungsbedingungen vermeiden. Wir können nicht garantieren, dass ein Anbieter jede Nutzung oder jedes Konto akzeptiert. Lies die Bedingungen des Anbieters und nutze Integrationen auf eigenes Risiko. OpenChamber kann keine Kontobeschränkungen, Sperrungen oder Streitfälle mit einem Anbieter klären. + Verfügbare Integrationen: - **Claude Code** — dein Claude Pro- oder Max-Plan, ohne API-Keys diff --git a/packages/docs/content/docs/es/integrations.mdx b/packages/docs/content/docs/es/integrations.mdx index 246f52ce..d1f23cc7 100644 --- a/packages/docs/content/docs/es/integrations.mdx +++ b/packages/docs/content/docs/es/integrations.mdx @@ -9,6 +9,8 @@ Una integración es un pequeño plugin que añade un proveedor a OpenChamber usa > **Función experimental:** las integraciones pueden cambiar o dejar de funcionar. Úsalas bajo tu propia responsabilidad. +Diseñamos estas integraciones para seguir los flujos de inicio de sesión previstos por los proveedores y evitar infracciones conocidas de sus Términos de Servicio. No podemos garantizar que un proveedor acepte cada uso o cuenta. Revisa los términos del proveedor y usa las integraciones bajo tu propia responsabilidad. OpenChamber no puede resolver restricciones, suspensiones de cuentas ni disputas con un proveedor. + Integraciones disponibles: - **Claude Code** — tu plan Claude Pro o Max, sin claves de API diff --git a/packages/docs/content/docs/fr/integrations.mdx b/packages/docs/content/docs/fr/integrations.mdx index f6e4711e..c736ba85 100644 --- a/packages/docs/content/docs/fr/integrations.mdx +++ b/packages/docs/content/docs/fr/integrations.mdx @@ -9,6 +9,8 @@ Une intégration est un petit plugin qui ajoute un fournisseur à OpenChamber à > **Fonctionnalité expérimentale :** les intégrations peuvent changer ou cesser de fonctionner. Utilise-les à ta discrétion. +Nous avons conçu ces intégrations pour suivre les méthodes de connexion prévues par les fournisseurs et éviter les violations connues de leurs conditions d'utilisation. Nous ne pouvons pas garantir qu'un fournisseur acceptera chaque usage ou chaque compte. Consulte les conditions du fournisseur et utilise les intégrations à tes risques. OpenChamber ne peut pas résoudre les restrictions, suspensions de compte ou litiges avec un fournisseur. + Intégrations disponibles : - **Claude Code** — ton plan Claude Pro ou Max, sans clés API diff --git a/packages/docs/content/docs/integrations.mdx b/packages/docs/content/docs/integrations.mdx index d45fe3f7..599d0720 100644 --- a/packages/docs/content/docs/integrations.mdx +++ b/packages/docs/content/docs/integrations.mdx @@ -9,6 +9,8 @@ An integration is a small plugin that adds a provider to OpenChamber using a sub > **Experimental feature:** integrations may change or stop working. Use them at your own discretion. +We designed these integrations to follow providers' intended sign-in flows and avoid known Terms of Service violations. We cannot guarantee that a provider will accept every use or account. Review the provider's terms and use integrations at your own risk. OpenChamber cannot resolve account restrictions, suspensions, or disputes with a provider. + Available integrations: - **Claude Code** — your Claude Pro or Max plan, no API keys diff --git a/packages/docs/content/docs/ja/integrations.mdx b/packages/docs/content/docs/ja/integrations.mdx index df3664d2..b4f2c24c 100644 --- a/packages/docs/content/docs/ja/integrations.mdx +++ b/packages/docs/content/docs/ja/integrations.mdx @@ -9,6 +9,8 @@ description: Claude、Command Code、Cursor のサブスクリプションをプ > **実験的な機能:** 連携は変更されたり、動作しなくなったりする可能性があります。自己責任で使用してください。 +これらの連携は、プロバイダーが想定するサインインの流れに従い、既知の利用規約違反を避けるよう設計しています。ただし、プロバイダーがすべての利用方法やアカウントを受け入れることは保証できません。プロバイダーの規約を確認し、自己責任で連携を使用してください。OpenChamber は、プロバイダーによるアカウント制限、停止、または紛争を解決できません。 + 利用できる統合機能: - **Claude Code** — Claude Pro または Max プラン、API キー不要 diff --git a/packages/docs/content/docs/ko/integrations.mdx b/packages/docs/content/docs/ko/integrations.mdx index 192f0f55..bdc26f2a 100644 --- a/packages/docs/content/docs/ko/integrations.mdx +++ b/packages/docs/content/docs/ko/integrations.mdx @@ -9,6 +9,8 @@ description: Claude, Command Code 또는 Cursor 구독을 공급자로 사용하 > **실험 단계 기능:** 통합 기능은 변경되거나 작동하지 않을 수 있습니다. 본인의 판단에 따라 사용하세요. +이 통합 기능은 프로바이더가 의도한 로그인 흐름을 따르고 알려진 서비스 약관 위반을 피하도록 설계했습니다. 프로바이더가 모든 사용 방식이나 계정을 허용한다고 보장할 수는 없습니다. 프로바이더의 약관을 검토하고 본인의 책임 아래 통합 기능을 사용하세요. OpenChamber는 프로바이더와의 계정 제한, 정지 또는 분쟁을 해결할 수 없습니다. + 사용 가능한 통합 기능: - **Claude Code** — Claude Pro 또는 Max 플랜, API 키 불필요 diff --git a/packages/docs/content/docs/pl/integrations.mdx b/packages/docs/content/docs/pl/integrations.mdx index 82f2429c..da95fde2 100644 --- a/packages/docs/content/docs/pl/integrations.mdx +++ b/packages/docs/content/docs/pl/integrations.mdx @@ -9,6 +9,8 @@ Integracja to mała wtyczka, która dodaje dostawcę do OpenChamber na podstawie > **Funkcja eksperymentalna:** integracje mogą się zmienić lub przestać działać. Korzystasz z nich na własną odpowiedzialność. +Zaprojektowaliśmy te integracje tak, aby korzystały z zamierzonych przez dostawców sposobów logowania i unikały znanych naruszeń ich warunków korzystania. Nie możemy zagwarantować, że dostawca zaakceptuje każdy sposób użycia lub konto. Sprawdź warunki dostawcy i używaj integracji na własne ryzyko. OpenChamber nie może rozwiązać ograniczeń konta, zawieszeń ani sporów z dostawcą. + Dostępne integracje: - **Claude Code** — Twój plan Claude Pro lub Max, bez kluczy API diff --git a/packages/docs/content/docs/pt-br/integrations.mdx b/packages/docs/content/docs/pt-br/integrations.mdx index d5df5cbf..1fd915d3 100644 --- a/packages/docs/content/docs/pt-br/integrations.mdx +++ b/packages/docs/content/docs/pt-br/integrations.mdx @@ -9,6 +9,8 @@ Uma integração é um pequeno plugin que adiciona um provedor ao OpenChamber us > **Recurso experimental:** as integrações podem mudar ou deixar de funcionar. Use-as por sua conta e risco. +Projetamos estas integrações para seguir os fluxos de login pretendidos pelos provedores e evitar violações conhecidas de seus Termos de Serviço. Não podemos garantir que um provedor aceitará todos os usos ou contas. Consulte os termos do provedor e use as integrações por sua conta e risco. O OpenChamber não pode resolver restrições, suspensões de conta ou disputas com um provedor. + Integrações disponíveis: - **Claude Code** — seu plano Claude Pro ou Max, sem chaves de API diff --git a/packages/docs/content/docs/uk/integrations.mdx b/packages/docs/content/docs/uk/integrations.mdx index 94925355..50c620b9 100644 --- a/packages/docs/content/docs/uk/integrations.mdx +++ b/packages/docs/content/docs/uk/integrations.mdx @@ -9,6 +9,8 @@ description: Використовуйте підписки Claude, Command Code > **Експериментальна функція:** інтеграції можуть змінюватися або перестати працювати. Використовуйте їх на власний розсуд. +Ми розробили ці інтеграції так, щоб вони використовували передбачені провайдерами способи входу й не порушували відомі нам умови користування. Ми не можемо гарантувати, що провайдер прийме кожен спосіб використання або кожен обліковий запис. Ознайомтеся з умовами провайдера й використовуйте інтеграції на власний ризик. OpenChamber не може вирішувати обмеження, блокування облікових записів або суперечки з провайдером. + Доступні інтеграції: - **Claude Code** — ваша підписка Claude Pro або Max, без API-ключів diff --git a/packages/docs/content/docs/zh-cn/integrations.mdx b/packages/docs/content/docs/zh-cn/integrations.mdx index e13b5ee9..a0a081fe 100644 --- a/packages/docs/content/docs/zh-cn/integrations.mdx +++ b/packages/docs/content/docs/zh-cn/integrations.mdx @@ -9,6 +9,8 @@ description: 将你的 Claude、Command Code 或 Cursor 订阅用作提供商。 > **实验性功能:**集成可能会变更或停止工作。请自行酌情使用。 +我们设计这些集成时,力求遵循提供商预期的登录流程,并避免已知的服务条款违规。我们无法保证提供商会接受每种使用方式或每个帐户。请查看提供商的条款,并自行承担使用集成的风险。OpenChamber 无法处理提供商施加的帐户限制、暂停或争议。 + 可用的集成: - **Claude Code** — 你的 Claude Pro 或 Max 套餐,无需 API 密钥 From 0b01f5ae2d2beada53066afce5e633661808efff Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Fri, 21 Aug 2026 23:16:58 +0300 Subject: [PATCH 007/157] feat(diff): add branch scope to context panel diff view Show every change on the current branch relative to its base in the Changed/Staged/Last turn dropdown. The base comes from the branch's reflog record or an explicit per-branch user choice (persisted), never a main/master guess; when git has no record the user picks a base once from a searchable branch list. - server: GET /api/git/branch-base (reflog-derived base), GET /api/git/range-files (name-status -z with rename/copy destination paths and -C copy detection) - shared UI: optional getBranchBase/getGitRangeFiles runtime APIs with boundary parsing; persisted per-branch overrides keyed by runtime+directory+branch - DiffView: branch scope with confirmed-unavailability coercion of persisted tabs (detached HEAD, default-branch checkout, metadata settled without a default), range-invalidated diff cache guarded against stale completions, bounded branch-metadata retry, read-only diff actions in branch scope; hidden in VS Code - helper module branchDiffScope.ts with tests for coercion, availability, race conditions, and retry exhaustion --- packages/ui/src/components/views/DiffView.tsx | 382 +++++++++++-- .../components/views/branchDiffScope.test.ts | 503 ++++++++++++++++++ .../src/components/views/branchDiffScope.ts | 211 ++++++++ packages/ui/src/lib/api/types.ts | 18 + packages/ui/src/lib/gitApi.ts | 18 + packages/ui/src/lib/gitApiHttp.ts | 46 ++ packages/ui/src/lib/i18n/messages/de.ts | 7 + packages/ui/src/lib/i18n/messages/en.ts | 7 + packages/ui/src/lib/i18n/messages/es.ts | 7 + packages/ui/src/lib/i18n/messages/fr.ts | 7 + packages/ui/src/lib/i18n/messages/ja.ts | 7 + packages/ui/src/lib/i18n/messages/ko.ts | 7 + packages/ui/src/lib/i18n/messages/pl.ts | 7 + packages/ui/src/lib/i18n/messages/pt-BR.ts | 7 + packages/ui/src/lib/i18n/messages/uk.ts | 7 + packages/ui/src/lib/i18n/messages/zh-CN.ts | 7 + packages/ui/src/lib/i18n/messages/zh-TW.ts | 7 + .../src/stores/useGitBaseBranchStore.test.ts | 62 +++ .../ui/src/stores/useGitBaseBranchStore.ts | 71 +++ packages/ui/src/stores/useUIStore.ts | 4 +- packages/web/server/lib/git/routes.js | 43 ++ packages/web/server/lib/git/service.js | 90 +++- packages/web/server/lib/git/service.test.js | 93 ++++ packages/web/src/api/git.ts | 2 + 24 files changed, 1582 insertions(+), 38 deletions(-) create mode 100644 packages/ui/src/components/views/branchDiffScope.test.ts create mode 100644 packages/ui/src/components/views/branchDiffScope.ts create mode 100644 packages/ui/src/stores/useGitBaseBranchStore.test.ts create mode 100644 packages/ui/src/stores/useGitBaseBranchStore.ts diff --git a/packages/ui/src/components/views/DiffView.tsx b/packages/ui/src/components/views/DiffView.tsx index 0dfd659e..896208b7 100644 --- a/packages/ui/src/components/views/DiffView.tsx +++ b/packages/ui/src/components/views/DiffView.tsx @@ -3,9 +3,12 @@ import React from 'react'; import { useUIStore } from '@/stores/useUIStore'; import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory'; import { useGitStore, useGitStatus, useIsGitRepo, useGitLoadingStatus } from '@/stores/useGitStore'; +import { useGitBaseBranchStore, gitBaseBranchEntryKey } from '@/stores/useGitBaseBranchStore'; +import { coerceDiffScope, branchRangeKey, isBranchScopeAvailable, isBranchScopeDefinitelyUnavailable, useRangeKeyedCache, useBoundedDirectoryRetry } from './branchDiffScope'; +import { getBranchBase, getGitRangeDiff, getGitRangeFiles } from '@/lib/gitApi'; import { getRuntimeKey } from '@/lib/runtime-switch'; import { cn } from '@/lib/utils'; -import type { GitStatus } from '@/lib/api/types'; +import type { GitStatus, GitRangeFileEntry } from '@/lib/api/types'; import { DropdownMenu, DropdownMenuContent, @@ -79,7 +82,7 @@ type DiffData = { fileDiff?: FileDiffMetadata; contextMode?: DiffContextMode; }; -type DiffScope = 'all' | 'staged' | 'working' | 'turn'; +type DiffScope = 'all' | 'staged' | 'working' | 'turn' | 'branch'; type TurnSnapshotDiff = { file?: string; @@ -91,6 +94,17 @@ type TurnSnapshotDiff = { deletions?: number; }; +/** Reservation slot for a branch range diff while its fetch is in flight. */ +const EMPTY_BRANCH_DIFF_PLACEHOLDER: DiffData = { + original: '', + modified: '', + isBinary: false, + contextMode: 'patch', +}; + +/** Bounded retries for branch metadata in the context diff panel (see effect). */ +const BRANCH_METADATA_MAX_ATTEMPTS = 3; + const BinaryDiffPlaceholder = React.memo(() => { const { t } = useI18n(); return ( @@ -230,11 +244,13 @@ const formatDiffTotals = ( }; interface ChangeScopeSelectorProps { - scope: Extract; + scope: Extract; workingCount: number; stagedCount: number; turnCount: number; - onScopeChange?: (scope: Extract) => void; + branchCount: number | null; + showBranchOption: boolean; + onScopeChange?: (scope: Extract) => void; } const ChangeScopeSelector = React.memo(({ @@ -242,16 +258,20 @@ const ChangeScopeSelector = React.memo(({ workingCount, stagedCount, turnCount, + branchCount, + showBranchOption, onScopeChange, }) => { const { t } = useI18n(); const [open, setOpen] = React.useState(false); - const currentCount = scope === 'staged' ? stagedCount : scope === 'turn' ? turnCount : workingCount; + const currentCount = scope === 'staged' ? stagedCount : scope === 'turn' ? turnCount : scope === 'branch' ? (branchCount ?? 0) : workingCount; const currentLabel = scope === 'staged' ? t('diffView.scope.staged') : scope === 'turn' ? t('diffView.scope.lastTurn') - : t('diffView.scope.changed'); + : scope === 'branch' + ? t('diffView.scope.branch') + : t('diffView.scope.changed'); return ( @@ -271,7 +291,7 @@ const ChangeScopeSelector = React.memo(({ { - if (value === 'working' || value === 'staged' || value === 'turn') { + if (value === 'working' || value === 'staged' || value === 'turn' || value === 'branch') { onScopeChange?.(value); setOpen(false); } @@ -295,6 +315,14 @@ const ChangeScopeSelector = React.memo(({ {turnCount} + {showBranchOption ? ( + + + {t('diffView.scope.branch')} + {branchCount ?? '…'} + + + ) : null} @@ -574,6 +602,8 @@ interface MultiFileDiffEntryProps { staged?: boolean; loadFullFiles?: boolean; initialDiffData?: DiffData | null; + /** Hide stage/unstage/revert actions (read-only scopes like branch diffs). */ + readOnlyActions?: boolean; } const MultiFileDiffEntry = React.memo(({ @@ -593,6 +623,7 @@ const MultiFileDiffEntry = React.memo(({ staged = false, loadFullFiles = false, initialDiffData = null, + readOnlyActions = false, }) => { const { t } = useI18n(); const { git } = useRuntimeAPIs(); @@ -922,13 +953,15 @@ const MultiFileDiffEntry = React.memo(({ />
- + {!readOnlyActions ? ( + + ) : null}
@@ -945,7 +978,7 @@ interface DiffViewProps { pinSelectedFileHeaderToTopOnNavigate?: boolean; showOpenInEditorAction?: boolean; diffScope?: DiffScope; - onDiffScopeChange?: (scope: Extract) => void; + onDiffScopeChange?: (scope: Extract) => void; targetFilePath?: string | null; /** Render diff content flush with the container edges (no outer padding). */ flushContent?: boolean; @@ -974,6 +1007,7 @@ export const DiffView: React.FC = ({ const setActiveDirectory = useGitStore((state) => state.setActiveDirectory); const ensureStatus = useGitStore((state) => state.ensureStatus); const fetchStatus = useGitStore((state) => state.fetchStatus); + const fetchBranches = useGitStore((state) => state.fetchBranches); const clearDiffCache = useGitStore((state) => state.clearDiffCache); const setDiff = useGitStore((state) => state.setDiff); const [displayFile, setDisplayFile] = React.useState(null); @@ -1083,7 +1117,213 @@ export const DiffView: React.FC = ({ return map; }, [lastTurnDiffs]); + const workingFileCount = React.useMemo(() => { + if (!status?.files) return 0; + return status.files.filter(isWorkingStatusFile).length; + }, [status]); + + const stagedFileCount = React.useMemo(() => { + if (!status?.files) return 0; + return status.files.filter(isStagedStatusFile).length; + }, [status]); + + const turnFileCount = lastTurnDiffs.length; + + // ----- Branch scope (all changes on this branch vs its base) ----- + const currentBranch = status?.current ?? null; + const branches = useGitStore((state) => (effectiveDirectory ? state.directories.get(effectiveDirectory)?.branches ?? null : null)); + const isLoadingBranches = useGitStore((state) => (effectiveDirectory ? state.directories.get(effectiveDirectory)?.isLoadingBranches ?? false : false)); + + // The Branch scope needs defaultBranches metadata that nothing else loads + // when only the context diff panel is open (GitView and the composer fetch + // it, and their absence must not hide the option), so load it here. A + // failed fetch leaves `branches` null and the loading flag settles back to + // false; the bounded retry below re-issues it a few times per directory and + // reports exhaustion so a dead repository neither loops forever nor spins + // the Branch scope on base resolution. + const startBranchMetadataFetch = React.useCallback(() => { + if (effectiveDirectory) { + void fetchBranches(effectiveDirectory, git); + } + }, [effectiveDirectory, fetchBranches, git]); + const branchMetadataExhausted = useBoundedDirectoryRetry( + effectiveDirectory ?? null, + isGitRepo !== false, + isLoadingBranches, + Boolean(branches), + startBranchMetadataFetch, + BRANCH_METADATA_MAX_ATTEMPTS + ); + + const repositoryDefaultBranch = React.useMemo(() => { + const trackingRemote = status?.tracking?.trim().split('/')[0]; + return (trackingRemote && branches?.defaultBranches?.[trackingRemote]) + ?? branches?.defaultBranches?.origin + ?? null; + }, [branches, status?.tracking]); + // Offered only while the default branch is known and the current branch is + // not it (an unknown default must not flash the option on a guess), and + // only outside VS Code (the extension has no context diff panel). + const showBranchOption = !isVSCodeRuntime() && isBranchScopeAvailable(currentBranch, repositoryDefaultBranch); + // Coercion acts only on CONFIRMED unavailability: the runtime has no branch + // scope at all, a settled status has no branch (detached HEAD), the default + // branch is known and we are on it, or metadata retries were exhausted. + // While status/metadata are still loading a persisted branch scope must + // survive instead of being rewritten to working on the first render. + // `status !== null` is the settled test: before the first status request + // even starts, status is null with loading still false, and that must not + // read as "settled without a branch". + const isBranchStatusResolved = status !== null; + const branchScopeDefinitelyUnavailable = isVSCodeRuntime() + || branchMetadataExhausted + || isBranchScopeDefinitelyUnavailable( + currentBranch, + repositoryDefaultBranch, + isBranchStatusResolved, + branches !== null + ); + + const setBaseOverride = useGitBaseBranchStore((state) => state.setOverride); + // Subscribe to the overrides map directly: `getOverride` reads `get()` + // imperatively, so a memo over it never recomputes when the store changes + // and a freshly picked base would be invisible until an unrelated rerender. + // The key includes the current branch: a base picked for one feature branch + // is not an answer for another branch of the same repository. + const baseOverride = useGitBaseBranchStore( + React.useCallback( + (state) => (effectiveDirectory && currentBranch + ? state.overrides[gitBaseBranchEntryKey(effectiveDirectory, currentBranch)] ?? null + : null), + [currentBranch, effectiveDirectory] + ) + ); + const [detectedBranchBase, setDetectedBranchBase] = React.useState(null); + const [isBranchBaseResolved, setIsBranchBaseResolved] = React.useState(false); + const [basePickerSearch, setBasePickerSearch] = React.useState(''); + + // A context tab persists its scope across branch checkouts and runtime + // switches. When the Branch scope is CONFIRMED unavailable (checked out the + // known default branch, VS Code runtime), fall back to Working instead of + // rendering the base-resolution spinner forever. Persist the coercion so + // the tab and the selector agree. Note it keys off confirmed + // unavailability, not off `showBranchOption`: while metadata loads the + // option is hidden but a persisted branch scope must not be rewritten. + React.useEffect(() => { + const coercedScope = coerceDiffScope(activeDiffScope, !branchScopeDefinitelyUnavailable); + if (coercedScope !== activeDiffScope) { + setActiveDiffScope(coercedScope); + // The only coercion is 'branch' -> 'working', so the persisted + // value always fits the callback domain. + if (coercedScope === 'working') { + onDiffScopeChange?.('working'); + } + } + }, [activeDiffScope, branchScopeDefinitelyUnavailable, onDiffScopeChange]); + + React.useEffect(() => { + if (!showBranchOption || !effectiveDirectory || !currentBranch) { + setDetectedBranchBase(null); + setIsBranchBaseResolved(false); + return; + } + + let cancelled = false; + setIsBranchBaseResolved(false); + getBranchBase(effectiveDirectory, currentBranch) + .then((result) => { + if (!cancelled) setDetectedBranchBase(result.base); + }) + .catch(() => { + if (!cancelled) setDetectedBranchBase(null); + }) + .finally(() => { + if (!cancelled) setIsBranchBaseResolved(true); + }); + return () => { + cancelled = true; + }; + }, [currentBranch, effectiveDirectory, showBranchOption]); + + // Explicit user choice outranks the detected source; both are real answers + // from git or the user — never a main/master guess. + const branchBase = baseOverride ?? detectedBranchBase; + + const [branchFiles, setBranchFiles] = React.useState(null); + const [branchFilesError, setBranchFilesError] = React.useState(null); + + // Shared by the scope/base effect and the error-state Retry button; the + // fetch id discards completions from a superseded run (base or head + // changed, or an earlier retry is still in flight). + const branchFilesFetchIdRef = React.useRef(0); + const reloadBranchFiles = React.useCallback(() => { + if (!effectiveDirectory || !currentBranch || !branchBase) return; + const fetchId = branchFilesFetchIdRef.current + 1; + branchFilesFetchIdRef.current = fetchId; + setBranchFiles(null); + setBranchFilesError(null); + getGitRangeFiles(effectiveDirectory, { base: branchBase, head: currentBranch }) + .then((files) => { + if (branchFilesFetchIdRef.current === fetchId) setBranchFiles(files); + }) + .catch((error) => { + if (branchFilesFetchIdRef.current === fetchId) { + setBranchFilesError(error instanceof Error ? error.message : t('diffView.branch.loadError')); + } + }); + }, [branchBase, currentBranch, effectiveDirectory, t]); + + React.useEffect(() => { + if (activeDiffScope === 'branch') { + reloadBranchFiles(); + } + }, [activeDiffScope, reloadBranchFiles]); + + // Range diffs are fetched per expanded file: unlike working/staged diffs + // there is no per-file cache channel, so patch data lives in a range-keyed + // local cache. Stale completions from a previous range cannot write into + // the new range's cache (see useRangeKeyedCache). + const branchDiffRangeKey = activeDiffScope === 'branch' && effectiveDirectory && currentBranch && branchBase + ? branchRangeKey(effectiveDirectory, branchBase, currentBranch) + : null; + const branchDiffPathsKey = React.useMemo( + () => (activeDiffScope === 'branch' ? Array.from(expandedFiles).sort().join('\0') : ''), + [activeDiffScope, expandedFiles] + ); + + const fetchBranchDiffEntry = React.useCallback( + (filePath: string) => { + if (!effectiveDirectory || !branchBase || !currentBranch) { + return Promise.reject(new Error('branch range is unavailable')); + } + return getGitRangeDiff(effectiveDirectory, { base: branchBase, head: currentBranch, path: filePath }) + .then((response) => createTextDiffDataFromPatch(filePath, response.diff, 'patch')); + }, + [branchBase, currentBranch, effectiveDirectory] + ); + + const branchDiffData = useRangeKeyedCache( + branchDiffRangeKey, + branchDiffPathsKey, + branchDiffRangeKey ? fetchBranchDiffEntry : null, + EMPTY_BRANCH_DIFF_PLACEHOLDER + ); + + const branchFileCount = branchFiles?.length ?? null; + const changedFiles: FileEntry[] = React.useMemo(() => { + if (activeDiffScope === 'branch') { + return (branchFiles ?? []) + .map((file) => ({ + path: file.path, + index: '', + working_dir: file.status, + insertions: 0, + deletions: 0, + isNew: file.status === 'A', + })) + .sort((a, b) => a.path.localeCompare(b.path)); + } + if (activeDiffScope === 'turn') { return lastTurnDiffs .map((diff) => ({ @@ -1115,19 +1355,7 @@ export const DiffView: React.FC = ({ isNew: isNewStatusFile(file), })) .sort((a, b) => a.path.localeCompare(b.path)); - }, [activeDiffScope, lastTurnDiffs, status]); - - const workingFileCount = React.useMemo(() => { - if (!status?.files) return 0; - return status.files.filter(isWorkingStatusFile).length; - }, [status]); - - const stagedFileCount = React.useMemo(() => { - if (!status?.files) return 0; - return status.files.filter(isStagedStatusFile).length; - }, [status]); - - const turnFileCount = lastTurnDiffs.length; + }, [activeDiffScope, branchFiles, lastTurnDiffs, status]); const changedFilePathsKey = React.useMemo( () => changedFiles.map((file) => file.path).join('\0'), @@ -1670,7 +1898,14 @@ export const DiffView: React.FC = ({ }} staged={getFileStaged(file.path)} loadFullFiles={loadFullFiles} - initialDiffData={activeDiffScope === 'turn' ? lastTurnDiffData.get(file.path) ?? null : null} + readOnlyActions={activeDiffScope === 'branch'} + initialDiffData={ + activeDiffScope === 'turn' + ? lastTurnDiffData.get(file.path) ?? null + : activeDiffScope === 'branch' + ? branchDiffData.get(file.path) ?? null + : null + } /> ))}
@@ -1707,10 +1942,93 @@ export const DiffView: React.FC = ({ ); } + if (activeDiffScope === 'branch') { + if (!isBranchBaseResolved) { + return ( +
+ + {t('diffView.branch.resolvingBase')} +
+ ); + } + + if (!branchBase) { + const searchTerm = basePickerSearch.trim().toLowerCase(); + const candidateBranches = (branches?.all ?? []) + .map((name: string) => name.replace(/^remotes\//, '')) + .filter((name: string) => name !== currentBranch && !name.endsWith(`/${currentBranch}`)) + .filter((name: string) => !searchTerm || name.toLowerCase().includes(searchTerm)) + .sort(); + return ( +
+ +
{t('diffView.branch.noBaseTitle')}
+
{t('diffView.branch.noBaseDescription')}
+ setBasePickerSearch(event.target.value)} + placeholder={t('gitView.branch.searchPlaceholder')} + aria-label={t('gitView.branch.searchPlaceholder')} + className="w-full max-w-sm rounded-md border border-border/60 bg-[var(--surface-elevated)] px-2.5 py-1.5 typography-meta text-foreground outline-none placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-[var(--interactive-focus-ring)]" + /> + + {candidateBranches.length === 0 ? ( +
+ {t('gitView.branch.empty')} +
+ ) : ( +
+ {candidateBranches.map((branch: string) => ( + + ))} +
+ )} +
+
+ ); + } + + if (branchFilesError) { + return ( +
+
{t('diffView.branch.loadError')}
+
{branchFilesError}
+ +
+ ); + } + + if (branchFiles === null) { + return ( +
+ + {t('diffView.branch.loadingFiles')} +
+ ); + } + } + if (changedFiles.length === 0) { return (
- {activeDiffScope === 'turn' ? t('diffView.state.noLastTurnChanges') : t('diffView.state.cleanWorkingTree')} + {activeDiffScope === 'turn' ? t('diffView.state.noLastTurnChanges') + : activeDiffScope === 'branch' && branchBase ? t('diffView.branch.empty', { base: branchBase }) + : t('diffView.state.cleanWorkingTree')}
); } @@ -1722,12 +2040,14 @@ export const DiffView: React.FC = ({
{!isMobile && ( - activeDiffScope === 'working' || activeDiffScope === 'staged' || activeDiffScope === 'turn' ? ( + activeDiffScope === 'working' || activeDiffScope === 'staged' || activeDiffScope === 'turn' || activeDiffScope === 'branch' ? ( { setActiveDiffScope(scope); onDiffScopeChange?.(scope); diff --git a/packages/ui/src/components/views/branchDiffScope.test.ts b/packages/ui/src/components/views/branchDiffScope.test.ts new file mode 100644 index 00000000..f365b858 --- /dev/null +++ b/packages/ui/src/components/views/branchDiffScope.test.ts @@ -0,0 +1,503 @@ +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { describe, expect, test } from 'bun:test'; + +import { + branchRangeKey, + coerceDiffScope, + isBranchScopeAvailable, + isBranchScopeDefinitelyUnavailable, + useRangeKeyedCache, + useBoundedDirectoryRetry, +} from './branchDiffScope'; + +describe('coerceDiffScope', () => { + test('keeps the branch scope while it is offered', () => { + expect(coerceDiffScope('branch', true)).toBe('branch'); + }); + + test('falls back to working when the branch scope disappears', () => { + // Covers a persisted context tab after checking out the default branch + // or switching to a runtime without the branch scope: the tab must land + // on a renderable scope instead of a permanent spinner. + expect(coerceDiffScope('branch', false)).toBe('working'); + }); + + test('leaves every other scope untouched regardless of availability', () => { + for (const scope of ['working', 'staged', 'turn', 'all'] as const) { + expect(coerceDiffScope(scope, false)).toBe(scope); + expect(coerceDiffScope(scope, true)).toBe(scope); + } + }); +}); + +describe('isBranchScopeAvailable', () => { + test('available when the default branch is known and different', () => { + expect(isBranchScopeAvailable('feature-a', 'main')).toBe(true); + }); + + test('unavailable on the default branch itself', () => { + expect(isBranchScopeAvailable('main', 'main')).toBe(false); + }); + + test('unavailable while the default branch is unknown', () => { + // Branch metadata loads asynchronously; an unknown default must not + // flash the Branch option on the guess that the branch differs from it. + expect(isBranchScopeAvailable('feature-a', null)).toBe(false); + }); + + test('unavailable without a current branch', () => { + expect(isBranchScopeAvailable(null, 'main')).toBe(false); + expect(isBranchScopeAvailable(null, null)).toBe(false); + }); +}); + +describe('isBranchScopeDefinitelyUnavailable', () => { + test('unknown metadata is not confirmed unavailability', () => { + // While branch metadata loads the option stays hidden, but this is + // "unknown", not "confirmed gone" — coercion must not act on it. + expect(isBranchScopeDefinitelyUnavailable('feature-a', null, true, false)).toBe(false); + expect(isBranchScopeDefinitelyUnavailable('feature-a', 'main', true, false)).toBe(false); + }); + + test('unresolved status means the branch is unknown, not gone', () => { + // During the first status load a null currentBranch is "not loaded + // yet"; coercing on it would discard a persisted branch scope before + // the answer arrives. + expect(isBranchScopeDefinitelyUnavailable(null, 'main', false, false)).toBe(false); + expect(isBranchScopeDefinitelyUnavailable(null, null, false, true)).toBe(false); + }); + + test('detached HEAD after a settled status is confirmed unavailability', () => { + // Status finished (or failed) without a branch: the Branch scope is + // impossible, so a persisted branch scope must coerce away instead of + // spinning on base resolution forever. + expect(isBranchScopeDefinitelyUnavailable(null, 'main', true, false)).toBe(true); + expect(isBranchScopeDefinitelyUnavailable(null, null, true, true)).toBe(true); + }); + + test('metadata settled without a default branch is confirmed unavailability', () => { + // `getBranches` can succeed while git/remote never reported a default + // branch: retries will not change that, the option stays hidden, and a + // persisted branch scope must coerce instead of spinning on base + // resolution forever. + expect(isBranchScopeDefinitelyUnavailable('feature-a', null, true, true)).toBe(true); + expect(isBranchScopeAvailable('feature-a', null)).toBe(false); + expect(coerceDiffScope('branch', !isBranchScopeDefinitelyUnavailable('feature-a', null, true, true))).toBe('working'); + }); + + test('confirmed when the default branch is known and we are on it', () => { + expect(isBranchScopeDefinitelyUnavailable('main', 'main', true, true)).toBe(true); + expect(isBranchScopeDefinitelyUnavailable('feature-a', 'main', true, true)).toBe(false); + }); + + test('a persisted branch scope survives loading metadata and is coerced once the answer arrives', () => { + // The scenario: a context tab persisted scope='branch' and the panel + // reopens while branch metadata is still loading (null default). + // First render — option hidden, but NOT coerced away: + expect(isBranchScopeAvailable('feature-a', null)).toBe(false); + expect(isBranchScopeDefinitelyUnavailable('feature-a', null, true, false)).toBe(false); + expect(coerceDiffScope('branch', !isBranchScopeDefinitelyUnavailable('feature-a', null, true, false))).toBe('branch'); + + // Metadata arrives and confirms a feature branch — still available: + expect(isBranchScopeAvailable('feature-a', 'main')).toBe(true); + expect(isBranchScopeDefinitelyUnavailable('feature-a', 'main', true, true)).toBe(false); + expect(coerceDiffScope('branch', !isBranchScopeDefinitelyUnavailable('feature-a', 'main', true, true))).toBe('branch'); + + // User checks out the default branch — now confirmed, coerce: + expect(isBranchScopeDefinitelyUnavailable('main', 'main', true, true)).toBe(true); + expect(coerceDiffScope('branch', !isBranchScopeDefinitelyUnavailable('main', 'main', true, true))).toBe('working'); + }); + + test('a persisted branch scope coerces after detached HEAD once status settles', () => { + // Status still loading with a persisted branch scope — keep it: + expect(coerceDiffScope('branch', !isBranchScopeDefinitelyUnavailable(null, 'main', false, false))).toBe('branch'); + // Status settles on detached HEAD — coerce: + expect(coerceDiffScope('branch', !isBranchScopeDefinitelyUnavailable(null, 'main', true, false))).toBe('working'); + }); + + test('first render before the status request starts does not read as settled detached HEAD', () => { + // Sequence of a fresh mount with a persisted branch scope: + // 1. status===null, loading===false (request has not started yet), + // 2. loading===true, + // 3. settled status object with current===null (true detached HEAD). + // Only step 3 may coerce; steps 1-2 are "unknown" and keep the scope. + expect(isBranchScopeDefinitelyUnavailable(null, null, false, false)).toBe(false); + expect(coerceDiffScope('branch', !isBranchScopeDefinitelyUnavailable(null, null, false, false))).toBe('branch'); + expect(isBranchScopeDefinitelyUnavailable(null, null, false, true)).toBe(false); + expect(isBranchScopeDefinitelyUnavailable(null, null, true, false)).toBe(true); + expect(coerceDiffScope('branch', !isBranchScopeDefinitelyUnavailable(null, null, true, false))).toBe('working'); + }); +}); + +describe('branchRangeKey', () => { + test('distinguishes bases, heads, and directories for the same path', () => { + // The same file path can carry different diff content per range; a cache + // keyed by path alone would leak a previous branch's patch. + const keys = [ + branchRangeKey('/repo', 'main', 'feature-a'), + branchRangeKey('/repo', 'develop', 'feature-a'), + branchRangeKey('/repo', 'main', 'feature-b'), + branchRangeKey('/other', 'main', 'feature-a'), + ]; + expect(new Set(keys).size).toBe(4); + }); +}); + +// --------------------------------------------------------------------------- +// useRangeKeyedCache +// --------------------------------------------------------------------------- + +const deferred = () => { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((next, decline) => { + resolve = next; + reject = decline; + }); + return { promise, resolve, reject }; +}; + +const installMinimalDom = () => { + const descriptors = new Map(); + const setGlobal = (name: string, value: unknown) => { + descriptors.set(name, Object.getOwnPropertyDescriptor(globalThis, name)); + Object.defineProperty(globalThis, name, { configurable: true, writable: true, value }); + }; + class ElementStub {} + /** Minimal Document surface createRoot touches in these tests. */ + type DocumentStub = { + nodeType: 9; + defaultView: typeof globalThis; + activeElement: Element | null; + addEventListener: (type: string, listener: () => void) => void; + removeEventListener: (type: string, listener: () => void) => void; + documentElement: typeof container; + body: typeof container; + }; + const container = { + nodeType: 1, + tagName: 'DIV', + nodeName: 'DIV', + namespaceURI: 'http://www.w3.org/1999/xhtml', + ownerDocument: null as DocumentStub | null, + addEventListener: () => undefined, + removeEventListener: () => undefined, + }; + const documentStub: DocumentStub = { + nodeType: 9, + defaultView: globalThis, + activeElement: null, + addEventListener: () => undefined, + removeEventListener: () => undefined, + documentElement: container, + body: container, + }; + container.ownerDocument = documentStub; + setGlobal('document', documentStub); + setGlobal('window', globalThis); + setGlobal('location', { search: '', protocol: 'http:', hostname: 'localhost' }); + setGlobal('Element', ElementStub); + setGlobal('HTMLElement', ElementStub); + setGlobal('HTMLIFrameElement', ElementStub); + setGlobal('IS_REACT_ACT_ENVIRONMENT', true); + setGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => setTimeout(() => callback(Date.now()), 0)); + setGlobal('cancelAnimationFrame', (id: ReturnType) => clearTimeout(id)); + return { + // SAFETY: the container stub implements the Element surface createRoot + // touches (nodeType/tagName/listeners); the real Element type is not + // constructible without a DOM implementation, so the gap goes through + // unknown deliberately. + container: container as unknown as Element, + restore: () => { + for (const [name, descriptor] of descriptors) { + if (descriptor) Object.defineProperty(globalThis, name, descriptor); + else Reflect.deleteProperty(globalThis, name); + } + }, + }; +}; + +describe('useRangeKeyedCache', () => { + test('a stale completion from the previous range cannot write into the new range', async () => { + const dom = installMinimalDom(); + const root: Root = createRoot(dom.container); + // One shared path so the same key would be overwritten if the guard + // was missing. + const pathsKey = 'src/shared.ts'; + const rangeA = '["/repo","main","feature-a"]'; + const rangeB = '["/repo","develop","feature-b"]'; + const fetchA = deferred(); + const fetchB = deferred(); + type CapturedEntries = { entries: ReadonlyMap | null }; + const captured: CapturedEntries = { entries: null }; + + let currentFetcher: (path: string) => Promise = () => fetchA.promise; + + const Harness = () => { + captured.entries = useRangeKeyedCache( + rangeKey, + pathsKey, + currentFetcher, + 'placeholder' + ); + return null; + }; + let rangeKey: string = rangeA; + + try { + await act(async () => root.render(React.createElement(Harness))); + expect(captured.entries?.get(pathsKey)).toBe('placeholder'); + + // Switch the range while A's fetch is still in flight. + rangeKey = rangeB; + currentFetcher = () => fetchB.promise; + await act(async () => root.render(React.createElement(Harness))); + expect(captured.entries?.get(pathsKey)).toBe('placeholder'); + + // B completes first: its value must land. + await act(async () => { + fetchB.resolve('diff-from-develop'); + await Promise.resolve(); + }); + expect(captured.entries?.get(pathsKey)).toBe('diff-from-develop'); + + // A completes last: the stale result must be discarded, not written + // over range B's entry. + await act(async () => { + fetchA.resolve('diff-from-main'); + await Promise.resolve(); + }); + expect(captured.entries?.get(pathsKey)).toBe('diff-from-develop'); + } finally { + await act(async () => root.unmount()); + dom.restore(); + } + }); + + test('a stale rejection from the previous range cannot delete the new range entry', async () => { + const dom = installMinimalDom(); + const root: Root = createRoot(dom.container); + const pathsKey = 'src/shared.ts'; + const rangeA = '["/repo","main","feature-a"]'; + const rangeB = '["/repo","develop","feature-b"]'; + const fetchA = deferred(); + const fetchB = deferred(); + type CapturedEntries = { entries: ReadonlyMap | null }; + const captured: CapturedEntries = { entries: null }; + + let currentFetcher: (path: string) => Promise = () => fetchA.promise; + let rangeKey: string = rangeA; + + const Harness = () => { + captured.entries = useRangeKeyedCache(rangeKey, pathsKey, currentFetcher, 'placeholder'); + return null; + }; + + try { + await act(async () => root.render(React.createElement(Harness))); + rangeKey = rangeB; + currentFetcher = () => fetchB.promise; + await act(async () => root.render(React.createElement(Harness))); + await act(async () => { + fetchB.resolve('diff-from-develop'); + await Promise.resolve(); + }); + expect(captured.entries?.get(pathsKey)).toBe('diff-from-develop'); + + // The old range's fetch fails after the switch: it must not delete + // the new range's completed entry. + await act(async () => { + fetchA.reject(new Error('stale failure')); + await Promise.resolve(); + }); + expect(captured.entries?.get(pathsKey)).toBe('diff-from-develop'); + } finally { + await act(async () => root.unmount()); + dom.restore(); + } + }); + + test('releases reservations for paths that never completed so a later run retries them', async () => { + const dom = installMinimalDom(); + const root: Root = createRoot(dom.container); + const pathsKey = 'src/first.ts'; + const stuck = deferred(); + type CapturedEntries = { entries: ReadonlyMap | null }; + const captured: CapturedEntries = { entries: null }; + + let currentPathsKey = pathsKey; + const fetched: string[] = []; + + const Harness = () => { + captured.entries = useRangeKeyedCache( + 'range', + currentPathsKey, + (path) => { + fetched.push(path); + return currentPathsKey === pathsKey ? stuck.promise : Promise.resolve(`resolved-${path}`); + }, + 'placeholder' + ); + return null; + }; + + try { + await act(async () => root.render(React.createElement(Harness))); + expect(fetched).toEqual(['src/first.ts']); + expect(captured.entries?.get('src/first.ts')).toBe('placeholder'); + + // Expand a different set of paths; the stuck reservation for + // src/first.ts is released, and a later run fetches it again. + currentPathsKey = 'src/first.ts\u0000src/second.ts'; + await act(async () => root.render(React.createElement(Harness))); + expect(fetched).toEqual(['src/first.ts', 'src/first.ts', 'src/second.ts']); + expect(captured.entries?.get('src/first.ts')).toBe('resolved-src/first.ts'); + expect(captured.entries?.get('src/second.ts')).toBe('resolved-src/second.ts'); + } finally { + await act(async () => root.unmount()); + dom.restore(); + } + }); +}); + +describe('useBoundedDirectoryRetry', () => { + test('starts once and reports no exhaustion on success', async () => { + const dom = installMinimalDom(); + const root: Root = createRoot(dom.container); + const started: string[] = []; + let hasResult = false; + let latestExhausted: boolean | null = null; + + const Harness = () => { + latestExhausted = useBoundedDirectoryRetry( + '/repo', true, false, hasResult, + () => { started.push('/repo'); }, 3 + ); + return null; + }; + + try { + await act(async () => root.render(React.createElement(Harness))); + expect(started).toEqual(['/repo']); + expect(latestExhausted).toBe(false); + + // Result arrives: no further starts, no exhaustion. + hasResult = true; + await act(async () => root.render(React.createElement(Harness))); + expect(started).toEqual(['/repo']); + expect(latestExhausted).toBe(false); + } finally { + await act(async () => root.unmount()); + dom.restore(); + } + }); + + test('retries bounded times on failure, then reports exhaustion without looping', async () => { + const dom = installMinimalDom(); + const root: Root = createRoot(dom.container); + const started: string[] = []; + let inFlight = false; + let latestExhausted: boolean | null = null; + + const Harness = () => { + latestExhausted = useBoundedDirectoryRetry( + '/repo', true, inFlight, false, + () => { started.push('/repo'); }, 3 + ); + return null; + }; + + try { + // Each attempt is one in-flight transition: the start flips the + // caller's flag up, the failed request settles it back down. + for (let attempt = 1; attempt <= 3; attempt += 1) { + inFlight = false; + await act(async () => root.render(React.createElement(Harness))); + expect(started).toHaveLength(attempt); + inFlight = true; + await act(async () => root.render(React.createElement(Harness))); + } + expect(latestExhausted).toBe(false); + + // Fourth transition: attempts exhausted, no more starts. + inFlight = false; + await act(async () => root.render(React.createElement(Harness))); + await act(async () => root.render(React.createElement(Harness))); + expect(started).toHaveLength(3); + expect(latestExhausted).toBe(true); + } finally { + await act(async () => root.unmount()); + dom.restore(); + } + }); + + test('exhaustion does not leak into the next directory on the first render', async () => { + const dom = installMinimalDom(); + const root: Root = createRoot(dom.container); + const started: string[] = []; + let directory: string = '/repo-a'; + let inFlight = false; + let hasResult = false; + let latestExhausted: boolean | null = null; + + const Harness = () => { + latestExhausted = useBoundedDirectoryRetry( + directory, true, inFlight, hasResult, + () => { started.push(directory); }, 2 + ); + return null; + }; + + try { + // Burn through both retries for /repo-a until exhausted. + for (let attempt = 1; attempt <= 2; attempt += 1) { + inFlight = false; + await act(async () => root.render(React.createElement(Harness))); + inFlight = true; + await act(async () => root.render(React.createElement(Harness))); + } + inFlight = false; + await act(async () => root.render(React.createElement(Harness))); + expect(latestExhausted).toBe(true); + + // Switch to another directory (a new tab with a persisted branch + // scope): exhaustion must reset in the SAME render, before any + // effect could rewrite the scope, and retries restart for it. + directory = '/repo-b'; + await act(async () => root.render(React.createElement(Harness))); + expect(latestExhausted).toBe(false); + expect(started).toEqual(['/repo-a', '/repo-a', '/repo-b']); + + hasResult = true; + await act(async () => root.render(React.createElement(Harness))); + expect(latestExhausted).toBe(false); + } finally { + await act(async () => root.unmount()); + dom.restore(); + } + }); + + test('an in-flight request suppresses duplicate starts from another consumer', async () => { + const dom = installMinimalDom(); + const root: Root = createRoot(dom.container); + const started: string[] = []; + + const Harness = () => { + useBoundedDirectoryRetry( + '/repo', true, true, false, + () => { started.push('/repo'); }, 3 + ); + return null; + }; + + try { + await act(async () => root.render(React.createElement(Harness))); + await act(async () => root.render(React.createElement(Harness))); + expect(started).toEqual([]); + } finally { + await act(async () => root.unmount()); + dom.restore(); + } + }); +}); diff --git a/packages/ui/src/components/views/branchDiffScope.ts b/packages/ui/src/components/views/branchDiffScope.ts new file mode 100644 index 00000000..19d50d64 --- /dev/null +++ b/packages/ui/src/components/views/branchDiffScope.ts @@ -0,0 +1,211 @@ +import React from 'react'; + +/** + * Pure helpers backing the "Branch" diff scope in DiffView. Extracted so the + * coercion, availability, and range-cache invalidation contracts are testable + * without mounting the full diff surface. + */ + +/** + * The "Branch" scope only exists while the repository's default branch is + * known and the current branch differs from it (the caller decides runtime + * availability). An unknown default must NOT show the option: the scope is + * "this branch is not the default", which cannot be established, and offering + * it on a guess flashes the option while branch metadata is still loading. + */ +export const isBranchScopeAvailable = ( + currentBranch: string | null, + repositoryDefaultBranch: string | null +): boolean => ( + Boolean(currentBranch) + && repositoryDefaultBranch !== null + && currentBranch !== repositoryDefaultBranch +); + +/** + * Confirmed unavailability of the Branch scope, as opposed to "not (yet) + * known". Coercion of a persisted branch scope must wait for this: while + * metadata is loading the default branch is unknown, the option stays hidden, + * but rewriting the persisted scope to working on that first render would + * discard the user's choice the moment metadata arrives and confirms the + * branch differs from the default. + * + * - `isBranchStatusResolved` distinguishes "no branch yet because the first + * status load has not settled" (unknown — keep the persisted scope) from + * "status finished and there is no branch" (detached HEAD / failed load — + * the Branch scope is impossible and the scope must coerce away). + * - `isBranchMetadataLoaded` + a null default means the branch list settled + * WITHOUT a resolvable default branch (git/remote never reported one): the + * Branch scope is impossible in a different way, and must coerce too, + * otherwise the persisted scope spins on base resolution forever. + */ +export const isBranchScopeDefinitelyUnavailable = ( + currentBranch: string | null, + repositoryDefaultBranch: string | null, + isBranchStatusResolved: boolean, + isBranchMetadataLoaded: boolean +): boolean => { + if (!isBranchStatusResolved) return false; + if (currentBranch === null) return true; + if (isBranchMetadataLoaded && repositoryDefaultBranch === null) return true; + return repositoryDefaultBranch !== null && currentBranch === repositoryDefaultBranch; +}; + +/** + * A context tab persists its scope across branch checkouts and runtime + * switches. When the Branch scope stops being offered (checked out the + * default branch, VS Code runtime), fall back to a always-available one instead + * of rendering the base-resolution spinner forever. + */ +export const coerceDiffScope = ( + scope: T, + branchScopeAvailable: boolean +): T | 'working' => (scope === 'branch' && !branchScopeAvailable ? 'working' : scope); + +/** + * Identity of one `base...head` range in one repository. Range-cache entries + * are only valid within a single range: the same file path can carry different + * content under a different base or head, so a cache keyed by path alone leaks + * stale patches across branch and base switches. + */ +export const branchRangeKey = (directory: string, base: string, head: string): string => + JSON.stringify([directory, base, head]); + +/** + * Bounded per-directory retry for a request whose failure leaves no result and + * no signal beyond the in-flight flag settling back to false. + * + * - State carries its directory: after a directory switch the derived + * attempts/exhausted values reset IMMEDIATELY on the first render of the new + * directory (no reset effect, so no one-render window where a stale + * `exhausted: true` from the previous directory leaks into decisions). + * - Retries stop after `maxAttempts` and report exhaustion instead of looping + * forever against a dead target. + * - An in-flight request (possibly started by another mounted consumer of the + * same directory) suppresses duplicate starts. + */ +export const useBoundedDirectoryRetry = ( + directory: string | null, + isEnabled: boolean, + isRequestInFlight: boolean, + hasResult: boolean, + startRequest: () => void, + maxAttempts: number +): boolean => { + // Attempts live in a ref and the effect's deps deliberately exclude them: + // a retry may only be triggered by an EXTERNAL transition (the in-flight + // flag settling back to false, a directory switch, a result appearing), never + // by the attempt counter itself — otherwise one start cascades into all + // remaining attempts in a single commit. + const attemptsRef = React.useRef<{ directory: string; attempts: number }>({ directory: '', attempts: 0 }); + const [exhaustedState, setExhaustedState] = React.useState<{ directory: string; exhausted: boolean }>( + () => ({ directory: '', exhausted: false }) + ); + // The starter is read through a ref so an inline arrow from the caller + // cannot restart the effect in a render loop. + const startRequestRef = React.useRef(startRequest); + startRequestRef.current = startRequest; + + // A different directory's (or the initial empty) exhaustion state reads as + // not exhausted; this derivation is the instant-reset guarantee above. + const exhausted = Boolean(directory) && exhaustedState.directory === directory && exhaustedState.exhausted; + + React.useEffect(() => { + if (!directory || !isEnabled || hasResult || isRequestInFlight) { + return; + } + const attempts = attemptsRef.current.directory === directory ? attemptsRef.current.attempts : 0; + if (attempts >= maxAttempts) { + if (!(exhaustedState.directory === directory && exhaustedState.exhausted)) { + setExhaustedState({ directory, exhausted: true }); + } + return; + } + attemptsRef.current = { directory, attempts: attempts + 1 }; + startRequestRef.current(); + }, [directory, exhaustedState, hasResult, isEnabled, isRequestInFlight, maxAttempts]); + + return exhausted; +}; + +/** + * Per-path cache of lazily fetched values, valid within a single range. + * + * - Changing `rangeKey` clears every entry (new base/head/directory = new + * content for the same paths). + * - Each expanded path is reserved with `placeholder` before its fetch starts, + * so a re-run does not issue a duplicate request. + * - Completions from a previous run can never write into the new range's + * cache: every run is cancelled in its cleanup, and its callbacks ignore + * results after cancellation. This covers the stale-completion case where an + * old `fetchEntry` promise resolves (or rejects) after the range switched. + * - Reservations that never completed are released on cleanup so a later run + * retries those paths instead of showing the placeholder forever. + */ +export const useRangeKeyedCache = ( + rangeKey: string | null, + pathsKey: string, + fetchEntry: ((path: string) => Promise) | null, + placeholder: T +): ReadonlyMap => { + const [entries, setEntries] = React.useState>(() => new Map()); + const entriesRef = React.useRef(entries); + entriesRef.current = entries; + + // The fetcher is read through a ref so a caller passing an inline arrow (a + // new function every render) cannot restart the fetch effect in a loop. + const fetchEntryRef = React.useRef(fetchEntry); + fetchEntryRef.current = fetchEntry; + + const writeEntry = React.useCallback((path: string, value: T | null) => { + const next = new Map(entriesRef.current); + if (value === null) { + if (!next.delete(path)) return; + } else { + next.set(path, value); + } + entriesRef.current = next; + setEntries(next); + }, []); + + React.useEffect(() => { + if (!rangeKey) return; + entriesRef.current = new Map(); + setEntries(entriesRef.current); + }, [rangeKey]); + + React.useEffect(() => { + const fetcher = fetchEntryRef.current; + if (!rangeKey || !fetcher || !pathsKey) { + return; + } + let cancelled = false; + const pendingReservations = new Set(); + + for (const path of pathsKey.split('\0')) { + if (entriesRef.current.has(path)) continue; + pendingReservations.add(path); + writeEntry(path, placeholder); + fetcher(path) + .then((value) => { + if (cancelled) return; + pendingReservations.delete(path); + writeEntry(path, value); + }) + .catch(() => { + if (cancelled) return; + // Release the reservation so a later run can retry this path. + pendingReservations.delete(path); + writeEntry(path, null); + }); + } + return () => { + cancelled = true; + for (const path of pendingReservations) { + writeEntry(path, null); + } + }; + }, [pathsKey, placeholder, rangeKey, writeEntry]); + + return entries; +}; diff --git a/packages/ui/src/lib/api/types.ts b/packages/ui/src/lib/api/types.ts index 7c44cc62..fd555f3f 100644 --- a/packages/ui/src/lib/api/types.ts +++ b/packages/ui/src/lib/api/types.ts @@ -157,6 +157,22 @@ export interface GetGitRangeDiffOptions { contextLines?: number; } +export interface GetGitRangeFilesOptions { + base: string; + head: string; +} + +/** One changed file in a `base...head` range, with its change letter (A/M/D/R/C). */ +export interface GitRangeFileEntry { + path: string; + status: string; +} + +export interface GitBranchBaseResponse { + /** Null when git has no authoritative record of where the branch started. */ + base: string | null; +} + export interface GitFileDiffResponse { original: string; modified: string; @@ -466,6 +482,8 @@ export interface GitAPI { getGitDiff(directory: string, options: GetGitDiffOptions): Promise; getGitFileDiff(directory: string, options: GetGitFileDiffOptions): Promise; getGitRangeDiff?(directory: string, options: GetGitRangeDiffOptions): Promise; + getGitRangeFiles?(directory: string, options: GetGitRangeFilesOptions): Promise; + getBranchBase?(directory: string, branch: string): Promise; revertGitFile(directory: string, filePath: string, options?: { scope?: 'all' | 'working' }): Promise; stageGitFile(directory: string, filePath: string): Promise; stageGitFiles?(directory: string, filePaths: string[]): Promise; diff --git a/packages/ui/src/lib/gitApi.ts b/packages/ui/src/lib/gitApi.ts index 56203ebb..1192cb06 100644 --- a/packages/ui/src/lib/gitApi.ts +++ b/packages/ui/src/lib/gitApi.ts @@ -119,6 +119,24 @@ export async function getGitRangeDiff( return gitHttp.getGitRangeDiff(directory, options); } +export async function getGitRangeFiles( + directory: string, + options: import('./api/types').GetGitRangeFilesOptions +): Promise { + const runtime = getRuntimeGit(); + if (runtime?.getGitRangeFiles) return runtime.getGitRangeFiles(directory, options); + return gitHttp.getGitRangeFiles(directory, options); +} + +export async function getBranchBase( + directory: string, + branch: string +): Promise { + const runtime = getRuntimeGit(); + if (runtime?.getBranchBase) return runtime.getBranchBase(directory, branch); + return gitHttp.getBranchBase(directory, branch); +} + export async function revertGitFile( directory: string, filePath: string, diff --git a/packages/ui/src/lib/gitApiHttp.ts b/packages/ui/src/lib/gitApiHttp.ts index 24317b24..2c0c6603 100644 --- a/packages/ui/src/lib/gitApiHttp.ts +++ b/packages/ui/src/lib/gitApiHttp.ts @@ -3,6 +3,7 @@ import type { GitDiffResponse, GetGitDiffOptions, GetGitRangeDiffOptions, + GetGitRangeFilesOptions, GitFileDiffResponse, GetGitFileDiffOptions, GitBranch, @@ -248,6 +249,51 @@ export async function getGitRangeDiff( return response.json(); } +export async function getGitRangeFiles( + directory: string, + options: GetGitRangeFilesOptions +): Promise { + const { base, head } = options; + if (!base || !head) { + throw new Error('base and head are required to fetch git range files'); + } + + const response = await runtimeFetch( + buildUrl(`${API_BASE}/range-files`, directory, { base, head }) + ); + + if (!response.ok) { + throw new Error(`Failed to get git range files: ${response.statusText}`); + } + + const payload = (await response.json()) as { files?: unknown }; + if (!Array.isArray(payload.files)) return []; + return payload.files.filter((entry): entry is import('./api/types').GitRangeFileEntry => { + if (!entry || typeof entry !== 'object') return false; + const candidate = entry as { path?: unknown; status?: unknown }; + return typeof candidate.path === 'string' && typeof candidate.status === 'string'; + }); +} + +export async function getBranchBase( + directory: string, + branch: string +): Promise { + if (!branch) { + throw new Error('branch is required to get branch base'); + } + + const response = await runtimeFetch( + buildUrl(`${API_BASE}/branch-base`, directory, { branch }) + ); + + if (!response.ok) { + throw new Error(`Failed to get branch base: ${response.statusText}`); + } + + return response.json(); +} + export async function getGitFileDiff(directory: string, options: GetGitFileDiffOptions): Promise { const { path, staged } = options; if (!path) { diff --git a/packages/ui/src/lib/i18n/messages/de.ts b/packages/ui/src/lib/i18n/messages/de.ts index 9fcf7760..d957a7b9 100644 --- a/packages/ui/src/lib/i18n/messages/de.ts +++ b/packages/ui/src/lib/i18n/messages/de.ts @@ -1360,6 +1360,13 @@ export const dict = { 'diffView.scope.changed': 'Geändert', 'diffView.scope.staged': 'Staged', 'diffView.scope.lastTurn': 'Letzter Zug', + 'diffView.scope.branch': 'Branch', + 'diffView.branch.resolvingBase': 'Basis-Branch wird ermittelt...', + 'diffView.branch.noBaseTitle': 'Kein Basis-Branch', + 'diffView.branch.noBaseDescription': 'Git enthält keinen Eintrag, wo dieser Branch entstanden ist. Wähle einen Basis-Branch für den Vergleich.', + 'diffView.branch.loadError': 'Branch-Änderungen konnten nicht geladen werden', + 'diffView.branch.loadingFiles': 'Branch-Änderungen werden geladen...', + 'diffView.branch.empty': 'Keine Änderungen in diesem Branch gegenüber {base}', 'diffView.scope.selectorAria': 'Änderungsmodus auswählen', 'diffView.actions.retry': 'Erneut versuchen', 'diffView.actions.renderAnyway': 'Trotzdem rendern', diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts index e69452de..981f2b8f 100644 --- a/packages/ui/src/lib/i18n/messages/en.ts +++ b/packages/ui/src/lib/i18n/messages/en.ts @@ -1517,6 +1517,13 @@ export const dict = { 'diffView.scope.changed': 'Changed', 'diffView.scope.staged': 'Staged', 'diffView.scope.lastTurn': 'Last turn', + 'diffView.scope.branch': 'Branch', + 'diffView.branch.resolvingBase': 'Detecting base branch...', + 'diffView.branch.noBaseTitle': 'No base branch', + 'diffView.branch.noBaseDescription': 'Git has no record of where this branch started. Choose a base branch to compare against.', + 'diffView.branch.loadError': 'Failed to load branch changes', + 'diffView.branch.loadingFiles': 'Loading branch changes...', + 'diffView.branch.empty': 'No changes on this branch relative to {base}', 'diffView.scope.selectorAria': 'Select change mode', 'diffView.actions.retry': 'Retry', 'diffView.actions.renderAnyway': 'Render anyway', diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts index acf65e36..394cc771 100644 --- a/packages/ui/src/lib/i18n/messages/es.ts +++ b/packages/ui/src/lib/i18n/messages/es.ts @@ -1483,6 +1483,13 @@ export const dict: Record = { "diffView.scope.changed": "Cambiados", "diffView.scope.staged": "Staged", "diffView.scope.lastTurn": "Último turno", + "diffView.scope.branch": "Rama", + "diffView.branch.resolvingBase": "Detectando rama base...", + "diffView.branch.noBaseTitle": "Sin rama base", + "diffView.branch.noBaseDescription": "Git no tiene registro de dónde surgió esta rama. Elige una rama base para comparar.", + "diffView.branch.loadError": "No se pudieron cargar los cambios de la rama", + "diffView.branch.loadingFiles": "Cargando cambios de la rama...", + "diffView.branch.empty": "No hay cambios en esta rama respecto a {base}", "diffView.scope.selectorAria": "Seleccionar modo de cambios", "diffView.actions.retry": "Volver a intentar", "diffView.actions.renderAnyway": "Renderizar de todos modos", diff --git a/packages/ui/src/lib/i18n/messages/fr.ts b/packages/ui/src/lib/i18n/messages/fr.ts index 94d915e4..1315c8f7 100644 --- a/packages/ui/src/lib/i18n/messages/fr.ts +++ b/packages/ui/src/lib/i18n/messages/fr.ts @@ -1282,6 +1282,13 @@ export const dict = { "diffView.scope.changed": "Modifiés", "diffView.scope.staged": "Staged", "diffView.scope.lastTurn": "Dernier tour", + "diffView.scope.branch": "Branche", + "diffView.branch.resolvingBase": "Détection de la branche de base...", + "diffView.branch.noBaseTitle": "Aucune branche de base", + "diffView.branch.noBaseDescription": "Git ne conserve aucune trace de la branche d’origine de cette branche. Choisissez une branche de base pour la comparaison.", + "diffView.branch.loadError": "Échec du chargement des modifications de la branche", + "diffView.branch.loadingFiles": "Chargement des modifications de la branche...", + "diffView.branch.empty": "Aucune modification sur cette branche par rapport à {base}", "diffView.scope.selectorAria": "Sélectionner le mode de changements", 'diffView.actions.retry': 'Réessayer', 'diffView.actions.renderAnyway': 'Afficher quand même', diff --git a/packages/ui/src/lib/i18n/messages/ja.ts b/packages/ui/src/lib/i18n/messages/ja.ts index 0a2529c5..09882066 100644 --- a/packages/ui/src/lib/i18n/messages/ja.ts +++ b/packages/ui/src/lib/i18n/messages/ja.ts @@ -1513,6 +1513,13 @@ export const dict: Record = { 'diffView.scope.changed': '変更済み', 'diffView.scope.staged': 'ステージ済み', 'diffView.scope.lastTurn': '最後のターン', + 'diffView.scope.branch': 'ブランチ', + 'diffView.branch.resolvingBase': 'ベースブランチを検出中...', + 'diffView.branch.noBaseTitle': 'ベースブランチがありません', + 'diffView.branch.noBaseDescription': 'このブランチがどこから作られたかの記録がGitにありません。比較するベースブランチを選択してください。', + 'diffView.branch.loadError': 'ブランチの変更を読み込めませんでした', + 'diffView.branch.loadingFiles': 'ブランチの変更を読み込み中...', + 'diffView.branch.empty': 'このブランチには{base}に対する変更はありません', 'diffView.scope.selectorAria': '変更モードを選択', 'diffView.actions.retry': '再試行', 'diffView.actions.renderAnyway': 'とにかくレンダリング', diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index fc164a8f..ef0eca76 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -1519,6 +1519,13 @@ export const dict: Record = { "diffView.scope.changed": "Changed", "diffView.scope.staged": "Staged", "diffView.scope.lastTurn": "마지막 턴", + "diffView.scope.branch": "브랜치", + "diffView.branch.resolvingBase": "베이스 브랜치 감지 중...", + "diffView.branch.noBaseTitle": "베이스 브랜치 없음", + "diffView.branch.noBaseDescription": "이 브랜치가 어디서 시작되었는지 Git에 기록이 없습니다. 비교할 베이스 브랜치를 선택하세요.", + "diffView.branch.loadError": "브랜치 변경 사항을 불러오지 못했습니다", + "diffView.branch.loadingFiles": "브랜치 변경 사항 불러오는 중...", + "diffView.branch.empty": "이 브랜치에는 {base}에 대한 변경 사항이 없습니다", "diffView.scope.selectorAria": "변경 모드 선택", 'diffView.actions.retry': '다시 시도', 'diffView.actions.renderAnyway': '그래도 렌더링', diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts index ba9bc45e..fbe1dc4f 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -1795,6 +1795,13 @@ export const dict: Record = { "diffView.scope.changed": "Zmienione", "diffView.scope.staged": "Staged", "diffView.scope.lastTurn": "Ostatnia tura", + "diffView.scope.branch": "Gałąź", + "diffView.branch.resolvingBase": "Wykrywanie gałęzi bazowej...", + "diffView.branch.noBaseTitle": "Brak gałęzi bazowej", + "diffView.branch.noBaseDescription": "Git nie zapisuje, od której gałęzi ta gałąź powstała. Wybierz gałąź bazową do porównania.", + "diffView.branch.loadError": "Nie udało się wczytać zmian gałęzi", + "diffView.branch.loadingFiles": "Wczytywanie zmian gałęzi...", + "diffView.branch.empty": "Brak zmian w tej gałęzi względem {base}", "diffView.scope.selectorAria": "Wybierz tryb zmian", 'diffView.summary.changedFilesSingle': 'Zmieniono {count} plik', 'directoryExplorerDialog.actions.addProject': 'Dodaj projekt', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index f13f1748..a467bc91 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -1483,6 +1483,13 @@ export const dict: Record = { "diffView.scope.changed": "Alteradas", "diffView.scope.staged": "Staged", "diffView.scope.lastTurn": "Último turno", + "diffView.scope.branch": "Branch", + "diffView.branch.resolvingBase": "Detectando branch base...", + "diffView.branch.noBaseTitle": "Sem branch base", + "diffView.branch.noBaseDescription": "O Git não tem registro de onde este branch começou. Escolha um branch base para comparar.", + "diffView.branch.loadError": "Falha ao carregar as alterações do branch", + "diffView.branch.loadingFiles": "Carregando alterações do branch...", + "diffView.branch.empty": "Nenhuma alteração neste branch em relação a {base}", "diffView.scope.selectorAria": "Selecionar modo de alterações", "diffView.actions.retry": "Tentar novamente", "diffView.actions.renderAnyway": "Renderizar mesmo assim", diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index a6867e50..b87b7876 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -1483,6 +1483,13 @@ export const dict: Record = { "diffView.scope.changed": "Змінені", "diffView.scope.staged": "Індексовані", "diffView.scope.lastTurn": "Останній хід", + "diffView.scope.branch": "Гілка", + "diffView.branch.resolvingBase": "Визначаємо базову гілку...", + "diffView.branch.noBaseTitle": "Немає базової гілки", + "diffView.branch.noBaseDescription": "Git не зберігає, від якої гілки почалася ця гілка. Виберіть базову гілку для порівняння.", + "diffView.branch.loadError": "Не вдалося завантажити зміни гілки", + "diffView.branch.loadingFiles": "Завантаження змін гілки...", + "diffView.branch.empty": "Немає змін у цій гілці відносно {base}", "diffView.scope.selectorAria": "Вибрати режим змін", "diffView.actions.retry": "Повторити спробу", "diffView.actions.renderAnyway": "Все одно відрендерити", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index 71c92446..e7852359 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -1483,6 +1483,13 @@ export const dict: Record = { "diffView.scope.changed": "已更改", "diffView.scope.staged": "已暂存", "diffView.scope.lastTurn": "上一轮", + "diffView.scope.branch": "分支", + "diffView.branch.resolvingBase": "正在检测基础分支...", + "diffView.branch.noBaseTitle": "没有基础分支", + "diffView.branch.noBaseDescription": "Git 中没有记录此分支的起点。请选择一个基础分支进行比较。", + "diffView.branch.loadError": "加载分支更改失败", + "diffView.branch.loadingFiles": "正在加载分支更改...", + "diffView.branch.empty": "此分支相对于 {base} 没有更改", "diffView.scope.selectorAria": "选择更改模式", 'diffView.actions.retry': '重试', 'diffView.actions.renderAnyway': '仍然渲染', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.ts b/packages/ui/src/lib/i18n/messages/zh-TW.ts index 58c52dce..c8122b4c 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts @@ -1493,6 +1493,13 @@ export const dict: Record = { "diffView.scope.changed": "已變更", "diffView.scope.staged": "已暫存", "diffView.scope.lastTurn": "上一輪", + "diffView.scope.branch": "分支", + "diffView.branch.resolvingBase": "正在偵測基礎分支...", + "diffView.branch.noBaseTitle": "沒有基礎分支", + "diffView.branch.noBaseDescription": "Git 中沒有記錄此分支的起點。請選擇基礎分支進行比較。", + "diffView.branch.loadError": "載入分支變更失敗", + "diffView.branch.loadingFiles": "正在載入分支變更...", + "diffView.branch.empty": "此分支相對於 {base} 沒有變更", "diffView.scope.selectorAria": "選擇變更模式", 'diffView.actions.retry': '重試', 'diffView.actions.renderAnyway': '仍然渲染', diff --git a/packages/ui/src/stores/useGitBaseBranchStore.test.ts b/packages/ui/src/stores/useGitBaseBranchStore.test.ts new file mode 100644 index 00000000..3909d4e2 --- /dev/null +++ b/packages/ui/src/stores/useGitBaseBranchStore.test.ts @@ -0,0 +1,62 @@ +import { beforeEach, describe, expect, mock, test } from "bun:test" + +let runtimeKey = "runtime-a" +mock.module("@/lib/runtime-switch", () => ({ getRuntimeKey: () => runtimeKey })) + +const { gitBaseBranchEntryKey, useGitBaseBranchStore } = await import("./useGitBaseBranchStore") + +describe("git base branch overrides", () => { + beforeEach(() => { + runtimeKey = "runtime-a" + useGitBaseBranchStore.setState({ overrides: {} }) + }) + + test("keys the same repository per branch and runtime", () => { + const featureA = gitBaseBranchEntryKey("/repo", "feature-a") + const featureB = gitBaseBranchEntryKey("/repo", "feature-b") + runtimeKey = "runtime-b" + const featureARemote = gitBaseBranchEntryKey("/repo", "feature-a") + + expect(new Set([featureA, featureB, featureARemote]).size).toBe(3) + }) + + test("a base picked for one branch does not apply to another branch", () => { + const store = useGitBaseBranchStore.getState() + store.setOverride("/repo", "feature-a", "main") + + expect(store.getOverride("/repo", "feature-a")).toBe("main") + // feature-b must fall back to its own detection, not feature-a's choice. + expect(store.getOverride("/repo", "feature-b")).toBeNull() + }) + + test("different branches of one repository keep independent bases", () => { + const store = useGitBaseBranchStore.getState() + store.setOverride("/repo", "feature-a", "main") + store.setOverride("/repo", "feature-b", "develop") + + expect(store.getOverride("/repo", "feature-a")).toBe("main") + expect(store.getOverride("/repo", "feature-b")).toBe("develop") + }) + + test("clearOverride removes only the targeted branch's choice", () => { + const store = useGitBaseBranchStore.getState() + store.setOverride("/repo", "feature-a", "main") + store.setOverride("/repo", "feature-b", "develop") + store.clearOverride("/repo", "feature-a") + + expect(store.getOverride("/repo", "feature-a")).toBeNull() + expect(store.getOverride("/repo", "feature-b")).toBe("develop") + }) + + test("rejects empty directory, branch, or base", () => { + const store = useGitBaseBranchStore.getState() + store.setOverride("", "feature-a", "main") + store.setOverride("/repo", "", "main") + store.setOverride("/repo", "feature-a", "") + store.clearOverride("", "feature-a") + + expect(useGitBaseBranchStore.getState().overrides).toEqual({}) + expect(store.getOverride("", "feature-a")).toBeNull() + expect(store.getOverride("/repo", "")).toBeNull() + }) +}) diff --git a/packages/ui/src/stores/useGitBaseBranchStore.ts b/packages/ui/src/stores/useGitBaseBranchStore.ts new file mode 100644 index 00000000..0d3d993f --- /dev/null +++ b/packages/ui/src/stores/useGitBaseBranchStore.ts @@ -0,0 +1,71 @@ +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; +import { getRuntimeKey } from '@/lib/runtime-switch'; +import { createDeferredSafeJSONStorage } from './utils/safeStorage'; + +const GIT_BASE_BRANCH_STORAGE_KEY = 'openchamber.git-base-branch'; +const MAX_BASE_BRANCH_ENTRIES = 100; + +/** + * Build the persisted override key for one branch of one repository. + * + * The branch is part of the identity on purpose: a base picked for one feature + * branch is not an answer for a different branch of the same repository, and a + * directory-only key would silently shadow reflog detection after checkout. + * Keys include the runtime identity so a remote runtime's paths never shadow + * local ones. + */ +export const gitBaseBranchEntryKey = (directory: string, branch: string): string => + JSON.stringify([getRuntimeKey(), directory, branch]); + +type GitBaseBranchState = { + overrides: Record; + getOverride: (directory: string, branch: string) => string | null; + setOverride: (directory: string, branch: string, base: string) => void; + clearOverride: (directory: string, branch: string) => void; +}; + +/** + * Explicit per-branch base choices for the "Branch" diff scope. + * + * Git does not record a parent branch for every branch (clones, detached + * starts). When no authoritative source exists, the user picks a base once and + * the choice is remembered for that branch. + */ +export const useGitBaseBranchStore = create()( + persist( + (set, get) => ({ + overrides: {}, + getOverride: (directory, branch) => { + if (!directory || !branch) return null; + return get().overrides[gitBaseBranchEntryKey(directory, branch)] ?? null; + }, + setOverride: (directory, branch, base) => { + if (!directory || !branch || !base) return; + set((state) => { + const key = gitBaseBranchEntryKey(directory, branch); + const entries = Object.entries({ ...state.overrides, [key]: base }); + while (entries.length > MAX_BASE_BRANCH_ENTRIES) { + entries.shift(); + } + return { overrides: Object.fromEntries(entries) }; + }); + }, + clearOverride: (directory, branch) => { + if (!directory || !branch) return; + set((state) => { + const key = gitBaseBranchEntryKey(directory, branch); + if (!(key in state.overrides)) return state; + const next = { ...state.overrides }; + delete next[key]; + return { overrides: next }; + }); + }, + }), + { + name: GIT_BASE_BRANCH_STORAGE_KEY, + storage: createDeferredSafeJSONStorage(), + partialize: (state) => ({ overrides: state.overrides }), + } + ) +); diff --git a/packages/ui/src/stores/useUIStore.ts b/packages/ui/src/stores/useUIStore.ts index 7c772122..8ed14467 100644 --- a/packages/ui/src/stores/useUIStore.ts +++ b/packages/ui/src/stores/useUIStore.ts @@ -20,7 +20,7 @@ import { isVSCodeRuntime } from '@/lib/desktop'; export type WorkspaceSurface = 'chat' | 'plan' | 'git' | 'diff' | 'terminal' | 'files' | 'context' | 'diagram'; /** @deprecated Use WorkspaceSurface. */ export type MainTab = WorkspaceSurface; -export type PendingDiffScope = 'working' | 'staged' | 'turn'; +export type PendingDiffScope = 'working' | 'staged' | 'turn' | 'branch'; export type ContextPanelMode = 'diff' | 'walkthrough' | 'file' | 'context' | 'plan' | 'chat' | 'browser' | 'git' | 'pr' | 'notes' | 'terminal'; export type MermaidRenderingMode = 'svg' | 'ascii'; export type UserMessageRenderingMode = 'markdown' | 'plain'; @@ -205,7 +205,7 @@ const normalizeContextTabLabel = (value: string | null | undefined): string | nu }; const normalizePendingDiffScope = (value: unknown): PendingDiffScope | null => { - return value === 'working' || value === 'staged' || value === 'turn' ? value : null; + return value === 'working' || value === 'staged' || value === 'turn' || value === 'branch' ? value : null; }; const buildDefaultContextPanelTabDedupeKey = (mode: ContextPanelMode, targetPath: string | null): string => { diff --git a/packages/web/server/lib/git/routes.js b/packages/web/server/lib/git/routes.js index 3766fea2..0fe38cdf 100644 --- a/packages/web/server/lib/git/routes.js +++ b/packages/web/server/lib/git/routes.js @@ -428,6 +428,49 @@ export function registerGitRoutes(app) { } }); + app.get('/api/git/branch-base', async (req, res) => { + const { getBranchBase } = await getGitLibraries(); + try { + const directory = resolveDirectoryQuery(req.query.directory); + if (!directory) { + return res.status(400).json({ error: 'directory parameter is required' }); + } + + const branch = resolveDirectoryQuery(req.query.branch); + if (!branch) { + return res.status(400).json({ error: 'branch parameter is required' }); + } + + const result = await getBranchBase(directory, branch); + res.json(result); + } catch (error) { + console.error('Failed to get branch base:', error); + res.status(500).json({ error: error.message || 'Failed to get branch base' }); + } + }); + + app.get('/api/git/range-files', async (req, res) => { + const { getRangeFiles } = await getGitLibraries(); + try { + const directory = resolveDirectoryQuery(req.query.directory); + if (!directory) { + return res.status(400).json({ error: 'directory parameter is required' }); + } + + const base = resolveDirectoryQuery(req.query.base); + const head = resolveDirectoryQuery(req.query.head); + if (!base || !head) { + return res.status(400).json({ error: 'base and head parameters are required' }); + } + + const files = await getRangeFiles(directory, { base, head }); + res.json({ files }); + } catch (error) { + console.error('Failed to get git range files:', error); + res.status(500).json({ error: error.message || 'Failed to get git range files' }); + } + }); + app.post('/api/git/revert', async (req, res) => { const { revertFile } = await getGitLibraries(); try { diff --git a/packages/web/server/lib/git/service.js b/packages/web/server/lib/git/service.js index e82d3774..d8e97b37 100644 --- a/packages/web/server/lib/git/service.js +++ b/packages/web/server/lib/git/service.js @@ -2654,6 +2654,71 @@ export async function getRangeDiff(directory, { base, head, path: filePath, cont return diff; } +const BRANCH_CREATION_SOURCE_RE = /^branch: Created from (.+)$/; + +/** + * Parse a branch reflog (`git reflog show --format=%gs `) and return the + * ref the branch was created from, when that source is itself a named ref. + * + * Returns null when the branch was created from `HEAD@{...}` or a raw commit + * (detached start): the original branch name is not recorded anywhere in that + * case, and guessing a base from commit topology would be a heuristic, not an + * answer. Callers should ask the user to pick a base instead. + */ +export function parseBranchCreationSource(reflogText) { + const lines = String(reflogText || '') + .split('\n') + .map((line) => line.trim()) + .filter(Boolean); + // Reflog lists newest entries first; the creation entry is the oldest one. + for (let index = lines.length - 1; index >= 0; index -= 1) { + const match = lines[index].match(BRANCH_CREATION_SOURCE_RE); + if (!match) continue; + const source = match[1].trim(); + if (!source || /^HEAD@/.test(source) || /^[0-9a-f]{7,40}$/i.test(source)) { + return null; + } + return source; + } + return null; +} + +/** + * Resolve the branch the given branch was created from, from its reflog. + * Returns { base: null } when git has no authoritative record (clone, detached + * start, reflog expired) — callers must not fall back to main/master. + */ +export async function getBranchBase(directory, branch) { + const branchName = String(branch || '').trim(); + if (!branchName) { + throw new Error('branch is required'); + } + + const { git } = await createRepositoryGitContext(directory); + + let reflog = ''; + try { + reflog = await git.raw(['reflog', 'show', '--format=%gs', branchName]); + } catch { + return { base: null }; + } + + const source = parseBranchCreationSource(reflog); + if (!source || source === branchName) { + return { base: null }; + } + + const resolves = await git + .raw(['rev-parse', '--verify', '--quiet', source]) + .then((value) => Boolean(String(value || '').trim())) + .catch(() => false); + if (!resolves) { + return { base: null }; + } + + return { base: source }; +} + export async function getRangeFiles(directory, { base, head } = {}) { const { git } = await createRepositoryGitContext(directory); const baseRef = typeof base === 'string' ? base.trim() : ''; @@ -2673,11 +2738,26 @@ export async function getRangeFiles(directory, { base, head } = {}) { // ignore } - const raw = await git.raw(['diff', '--name-only', `${resolvedBase}...${headRef}`]); - return String(raw || '') - .split('\n') - .map((l) => l.trim()) - .filter(Boolean); + // `-C` (copy detection among changed files only, so cheap) makes copies + // surface as C entries instead of plain additions; rename detection is on + // by default. + const raw = await git.raw(['diff', '--name-status', '-z', '-C', `${resolvedBase}...${headRef}`]); + // -z format: STATUS\0PATH\0[ORIG\0] repeated. For rename/copy entries + // (`R100`, `C75`) the first path token is the ORIGINAL path and the second + // is the DESTINATION — the diff (and the UI) must address the destination. + const tokens = String(raw || '').split('\0'); + const files = []; + for (let index = 0; index < tokens.length; index += 1) { + const status = (tokens[index] || '').trim(); + if (!status) continue; + const isRenameOrCopy = status.startsWith('R') || status.startsWith('C'); + const path = isRenameOrCopy ? (tokens[index + 2] || '').trim() : (tokens[index + 1] || '').trim(); + index += isRenameOrCopy ? 2 : 1; + if (path) { + files.push({ path, status: status.charAt(0) }); + } + } + return files; } const IMAGE_EXTENSIONS = ['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'ico', 'bmp', 'avif']; diff --git a/packages/web/server/lib/git/service.test.js b/packages/web/server/lib/git/service.test.js index 88baecc3..eb86c2ac 100644 --- a/packages/web/server/lib/git/service.test.js +++ b/packages/web/server/lib/git/service.test.js @@ -28,6 +28,8 @@ import { getDiff, getFileDiff, validateWorktreeCreate, + parseBranchCreationSource, + getRangeFiles, } from './service.js'; // --------------------------------------------------------------------------- @@ -1336,3 +1338,94 @@ describe.runIf(canRunGit())('getRangeDiff', () => { expect(diff).toContain('feature.txt'); }); }); + +describe('parseBranchCreationSource', () => { + it('returns the source ref from the oldest creation entry', () => { + // Reflog lists newest entries first; creation is the last line. + const reflog = [ + 'commit: abc123', + 'branch: Created from origin/main', + ].join('\n'); + expect(parseBranchCreationSource(reflog)).toBe('origin/main'); + }); + + it('returns null when the branch was created from a detached HEAD pointer', () => { + const reflog = 'branch: Created from HEAD@{0}'; + expect(parseBranchCreationSource(reflog)).toBeNull(); + }); + + it('returns null when the branch was created from a raw commit', () => { + const reflog = 'branch: Created from 9a3b2c1d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b'; + expect(parseBranchCreationSource(reflog)).toBeNull(); + }); + + it('returns null when there is no creation entry', () => { + const reflog = ['commit: abc123', 'reset: moving to HEAD'].join('\n'); + expect(parseBranchCreationSource(reflog)).toBeNull(); + }); + + it('returns null for empty input', () => { + expect(parseBranchCreationSource('')).toBeNull(); + expect(parseBranchCreationSource(undefined)).toBeNull(); + }); +}); + +describe.runIf(canRunGit())('getRangeFiles', () => { + it('returns added and modified paths with their status letters', async () => { + const { repository } = createRepositoryWithRemote(); + fs.writeFileSync(path.join(repository, 'added.txt'), 'new\n'); + fs.writeFileSync(path.join(repository, 'README.md'), '# Test\nchanged\n'); + runGit(repository, ['add', 'added.txt', 'README.md']); + runGit(repository, ['commit', '-m', 'changes']); + + const files = await getRangeFiles(repository, { base: 'react', head: 'next' }); + + expect(files).toEqual(expect.arrayContaining([ + { path: 'added.txt', status: 'A' }, + { path: 'README.md', status: 'M' }, + ])); + }); + + it('reports the destination path for renamed files, including spaces', async () => { + const { repository } = createRepositoryWithRemote(); + // The original file must exist in the base: rename detection pairs a + // deletion against an addition relative to base, not within the branch. + fs.writeFileSync(path.join(repository, 'old name with spaces.md'), '# Test\n'); + runGit(repository, ['add', 'old name with spaces.md']); + runGit(repository, ['commit', '-m', 'add file to rename']); + runGit(repository, ['push', 'origin', 'HEAD:react']); + // Spaces in filenames exercise the -z token split: a newline split would + // mangle these paths long before status letters matter. + fs.renameSync(path.join(repository, 'old name with spaces.md'), path.join(repository, 'new name with spaces.md')); + runGit(repository, ['add', '-A']); + runGit(repository, ['commit', '-m', 'rename']); + + const files = await getRangeFiles(repository, { base: 'react', head: 'next' }); + + const renameEntry = files.find((file) => file.status === 'R'); + expect(renameEntry).toBeDefined(); + expect(renameEntry.path).toBe('new name with spaces.md'); + expect(files.some((file) => file.path === 'old name with spaces.md')).toBe(false); + }); + + it('reports the destination path for copied files', async () => { + const { repository } = createRepositoryWithRemote(); + // The source must exist in the base. Copy detection needs the repository's + // own `diff.renames=copies` setting on top of the service's -C flag; the + // parser must survive whatever C entries git emits. + runGit(repository, ['config', 'diff.renames', 'copies']); + fs.writeFileSync(path.join(repository, 'copied source.md'), '# Copy me\n'); + runGit(repository, ['add', 'copied source.md']); + runGit(repository, ['commit', '-m', 'add source']); + runGit(repository, ['push', 'origin', 'HEAD:react']); + fs.copyFileSync(path.join(repository, 'copied source.md'), path.join(repository, 'copied destination.md')); + runGit(repository, ['add', '-A']); + runGit(repository, ['commit', '-m', 'copy']); + + const files = await getRangeFiles(repository, { base: 'react', head: 'next' }); + + const copyEntry = files.find((file) => file.status === 'C'); + expect(copyEntry).toBeDefined(); + expect(copyEntry.path).toBe('copied destination.md'); + }); +}); diff --git a/packages/web/src/api/git.ts b/packages/web/src/api/git.ts index 80c48a46..d6bc9fd9 100644 --- a/packages/web/src/api/git.ts +++ b/packages/web/src/api/git.ts @@ -11,6 +11,8 @@ export const createWebGitAPI = (): GitAPI => ({ getGitDiff: gitApiHttp.getGitDiff, getGitFileDiff: gitApiHttp.getGitFileDiff, getGitRangeDiff: gitApiHttp.getGitRangeDiff, + getGitRangeFiles: gitApiHttp.getGitRangeFiles, + getBranchBase: gitApiHttp.getBranchBase, revertGitFile: gitApiHttp.revertGitFile, stageGitFile: gitApiHttp.stageGitFile, stageGitFiles: gitApiHttp.stageGitFiles, From 0d50253efaa5e62b33c5fa4bb591b41d50d948ce Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sat, 22 Aug 2026 00:05:44 +0300 Subject: [PATCH 008/157] refactor(integrations): retire unavailable options Remove retired Command Code, Discord, and Telegram integration entries, search targets, and documentation. Keep Command Code provider usage and logo support available through normalized provider ID aliases. --- .../docs/content/docs/de/integrations.mdx | 18 +-- .../docs/content/docs/es/integrations.mdx | 18 +-- packages/docs/content/docs/es/providers.mdx | 2 +- .../docs/content/docs/fr/integrations.mdx | 18 +-- packages/docs/content/docs/fr/providers.mdx | 2 +- packages/docs/content/docs/integrations.mdx | 18 +-- .../docs/content/docs/ja/integrations.mdx | 18 +-- packages/docs/content/docs/ja/providers.mdx | 2 +- .../docs/content/docs/ko/integrations.mdx | 18 +-- packages/docs/content/docs/ko/providers.mdx | 2 +- .../docs/content/docs/pl/integrations.mdx | 18 +-- packages/docs/content/docs/pl/providers.mdx | 2 +- packages/docs/content/docs/providers.mdx | 2 +- .../docs/content/docs/pt-br/integrations.mdx | 18 +-- .../docs/content/docs/pt-br/providers.mdx | 2 +- .../docs/content/docs/uk/integrations.mdx | 18 +-- packages/docs/content/docs/uk/providers.mdx | 2 +- .../docs/content/docs/zh-cn/integrations.mdx | 18 +-- .../docs/content/docs/zh-cn/providers.mdx | 2 +- .../ComingSoonMessengersSection.tsx | 74 ------------ .../integrations/IntegrationsPage.tsx | 19 +-- .../ThirdPartyIntegrationsSection.tsx | 7 +- .../integrations/thirdPartyPlugins.test.ts | 5 - .../integrations/thirdPartyPlugins.ts | 10 -- .../ui/providerLogoFallback.test.ts | 6 +- .../src/components/ui/providerLogoFallback.ts | 6 +- .../third-party-integrations.i18n.test.ts | 4 - .../messages/third-party-integrations.i18n.ts | 110 ++---------------- packages/ui/src/lib/settings/search.ts | 7 -- .../lib/quota/providers/command-code.js | 2 +- .../lib/quota/providers/command-code.test.js | 13 +++ .../web/server/lib/quota/providers/index.js | 16 ++- 32 files changed, 98 insertions(+), 379 deletions(-) delete mode 100644 packages/ui/src/components/sections/integrations/ComingSoonMessengersSection.tsx diff --git a/packages/docs/content/docs/de/integrations.mdx b/packages/docs/content/docs/de/integrations.mdx index 58c2acf4..38b24051 100644 --- a/packages/docs/content/docs/de/integrations.mdx +++ b/packages/docs/content/docs/de/integrations.mdx @@ -1,20 +1,17 @@ --- title: Integrationen -description: Nutze dein Claude-, Command-Code- oder Cursor-Abo als Provider. +description: Nutze dein Claude- oder Cursor-Abo als Provider. --- # Integrationen Eine Integration ist ein kleines Plugin, das OpenChamber einen Provider hinzufügt — auf Basis eines Abos, das du bereits hast. Verwalten kannst du sie unter **Settings → Integrations**. -> **Experimentelle Funktion:** Integrationen können sich ändern oder nicht mehr funktionieren. Nutze sie nach eigenem Ermessen. - -Wir haben diese Integrationen so entwickelt, dass sie die vorgesehenen Anmeldewege der Anbieter nutzen und bekannte Verstöße gegen deren Nutzungsbedingungen vermeiden. Wir können nicht garantieren, dass ein Anbieter jede Nutzung oder jedes Konto akzeptiert. Lies die Bedingungen des Anbieters und nutze Integrationen auf eigenes Risiko. OpenChamber kann keine Kontobeschränkungen, Sperrungen oder Streitfälle mit einem Anbieter klären. +> **Experimentelle Funktion.** Wir bemühen uns, die Richtlinien der Anbieter zu respektieren, aber Kontobeschränkungen und Sperrungen liegen bei jedem Anbieter. Nutze Integrationen auf eigenes Risiko. Verfügbare Integrationen: - **Claude Code** — dein Claude Pro- oder Max-Plan, ohne API-Keys -- **Command Code** — dein Command-Code-Plan - **Cursor** — die Modell-Limits deines Cursor-Plans ## Integration installieren @@ -33,19 +30,10 @@ Claude Code nutzt deinen Claude Pro- oder Max-Plan — ohne API-Keys und ohne se 1. Installiere die Integration (siehe oben). 2. Wähle **Set up** und melde dich an. Wenn du die Claude Code CLI noch nicht hast, bietet die Einrichtung an, sie zuerst zu installieren, und meldet dich danach an. -Claude Code ist die einzige Integration hier, die ihre Provider-CLI installiert und angemeldet benötigt. Command Code und Cursor brauchen ihre CLIs nicht. +Claude Code ist die einzige Integration hier, die ihre Provider-CLI installiert und angemeldet benötigt. Cursor braucht seine CLI nicht. **Wie dein Claude-Konto geschützt bleibt:** Diese Integration nutzt das offizielle Claude Agent SDK von Anthropic und deine installierte Claude Code CLI. Sie kapert kein OAuth, extrahiert oder wiederholt keine Browser-Tokens, gibt sich nicht als nicht unterstützter Client aus und umgeht nicht Anthropics Authentifizierung. Sie bleibt auf dem von Anthropic unterstützten Zugriffsweg und trägt daher nicht das mit Token-Hijacking oder unautorisierten Authentifizierungsumgehungen verbundene Sperrrisiko. -## Command Code - -Command Code nutzt deinen Command-Code-Plan. - -1. Installiere die Integration (siehe oben). -2. Wähle **Set up** — es öffnet sich eine Browserseite. Genehmige den Zugriff und kehre zu OpenChamber zurück. - -Auf einem Headless-Server oder in CI setze stattdessen die Umgebungsvariable `COMMAND_CODE_API_KEY`, anstatt dich im Browser anzumelden. - ## Cursor Cursor macht die Modelle deines Cursor-Plans in OpenChamber nutzbar. diff --git a/packages/docs/content/docs/es/integrations.mdx b/packages/docs/content/docs/es/integrations.mdx index d1f23cc7..17582762 100644 --- a/packages/docs/content/docs/es/integrations.mdx +++ b/packages/docs/content/docs/es/integrations.mdx @@ -1,20 +1,17 @@ --- title: Integraciones -description: Usa tu suscripción de Claude, Command Code o Cursor como proveedor. +description: Usa tu suscripción de Claude o Cursor como proveedor. --- # Integraciones Una integración es un pequeño plugin que añade un proveedor a OpenChamber usando una suscripción que ya tienes. Las gestionas en **Settings → Integrations**. -> **Función experimental:** las integraciones pueden cambiar o dejar de funcionar. Úsalas bajo tu propia responsabilidad. - -Diseñamos estas integraciones para seguir los flujos de inicio de sesión previstos por los proveedores y evitar infracciones conocidas de sus Términos de Servicio. No podemos garantizar que un proveedor acepte cada uso o cuenta. Revisa los términos del proveedor y usa las integraciones bajo tu propia responsabilidad. OpenChamber no puede resolver restricciones, suspensiones de cuentas ni disputas con un proveedor. +> **Función experimental.** Buscamos respetar las políticas de los proveedores, pero las restricciones y suspensiones de cuentas son decisión de cada proveedor. Usa las integraciones bajo tu propia responsabilidad. Integraciones disponibles: - **Claude Code** — tu plan Claude Pro o Max, sin claves de API -- **Command Code** — tu plan de Command Code - **Cursor** — los límites de modelos de tu plan de Cursor ## Instalar una integración @@ -33,19 +30,10 @@ Claude Code usa tu plan Claude Pro o Max — sin claves de API y sin una app de 1. Instala la integración (arriba). 2. Elige **Set up** e inicia sesión. Si aún no tienes la CLI de Claude Code, la configuración ofrece instalarla primero y luego iniciar sesión. -Claude Code es la única integración de esta página que requiere tener la CLI de su proveedor instalada y con sesión iniciada. Command Code y Cursor no requieren sus CLIs. +Claude Code es la única integración de esta página que requiere tener la CLI de su proveedor instalada y con sesión iniciada. Cursor no requiere su CLI. **Cómo se protege tu cuenta de Claude:** esta integración usa el Claude Agent SDK oficial de Anthropic y tu CLI de Claude Code instalada. No secuestra OAuth, no extrae ni reutiliza tokens del navegador, no se hace pasar por un cliente no admitido ni omite la autenticación de Anthropic. Se mantiene en la vía de acceso admitida por Anthropic, por lo que no conlleva el riesgo de baneo asociado al secuestro de tokens o a rodeos de autenticación no autorizados. -## Command Code - -Command Code usa tu plan de Command Code. - -1. Instala la integración (arriba). -2. Elige **Set up** — se abre una página en el navegador. Autoriza el acceso y vuelve a OpenChamber. - -En una máquina sin interfaz gráfica o en CI, define la variable de entorno `COMMAND_CODE_API_KEY` en lugar de iniciar sesión en el navegador. - ## Cursor Cursor hace disponibles en OpenChamber los modelos incluidos en tu plan de Cursor. diff --git a/packages/docs/content/docs/es/providers.mdx b/packages/docs/content/docs/es/providers.mdx index 24e5b94f..65526b23 100644 --- a/packages/docs/content/docs/es/providers.mdx +++ b/packages/docs/content/docs/es/providers.mdx @@ -45,6 +45,6 @@ Los inicios de sesión de los proveedores los guarda OpenCode, no OpenChamber, a ## Relacionado -- [Integraciones](/es/integrations/) — usa una suscripción de Claude, Command Code o Cursor como proveedor +- [Integraciones](/es/integrations/) — usa una suscripción de Claude o Cursor como proveedor - [Servidores MCP](/es/mcp/) — añade herramientas extra para los agentes - [Uso y cuotas](/es/usage/) — controla cuánto has consumido diff --git a/packages/docs/content/docs/fr/integrations.mdx b/packages/docs/content/docs/fr/integrations.mdx index c736ba85..2628a870 100644 --- a/packages/docs/content/docs/fr/integrations.mdx +++ b/packages/docs/content/docs/fr/integrations.mdx @@ -1,20 +1,17 @@ --- title: Intégrations -description: Utilise ton abonnement Claude, Command Code ou Cursor comme fournisseur. +description: Utilise ton abonnement Claude ou Cursor comme fournisseur. --- # Intégrations Une intégration est un petit plugin qui ajoute un fournisseur à OpenChamber à partir d'un abonnement que tu possèdes déjà. Tu les gères dans **Settings → Integrations**. -> **Fonctionnalité expérimentale :** les intégrations peuvent changer ou cesser de fonctionner. Utilise-les à ta discrétion. - -Nous avons conçu ces intégrations pour suivre les méthodes de connexion prévues par les fournisseurs et éviter les violations connues de leurs conditions d'utilisation. Nous ne pouvons pas garantir qu'un fournisseur acceptera chaque usage ou chaque compte. Consulte les conditions du fournisseur et utilise les intégrations à tes risques. OpenChamber ne peut pas résoudre les restrictions, suspensions de compte ou litiges avec un fournisseur. +> **Fonctionnalité expérimentale.** Nous cherchons à respecter les règles des fournisseurs, mais les restrictions et suspensions de compte relèvent de leur décision. Utilise les intégrations à tes risques. Intégrations disponibles : - **Claude Code** — ton plan Claude Pro ou Max, sans clés API -- **Command Code** — ton plan Command Code - **Cursor** — les limites de modèles de ton plan Cursor ## Installer une intégration @@ -33,19 +30,10 @@ Claude Code utilise ton plan Claude Pro ou Max — sans clés API et sans applic 1. Installe l'intégration (ci-dessus). 2. Choisis **Set up** et connecte-toi. Si tu n'as pas encore la CLI Claude Code, la configuration propose de l'installer d'abord, puis de te connecter. -Claude Code est la seule intégration ici qui exige que la CLI de son fournisseur soit installée et connectée. Command Code et Cursor n'exigent pas leurs CLIs. +Claude Code est la seule intégration ici qui exige que la CLI de son fournisseur soit installée et connectée. Cursor n'exige pas sa CLI. **Comment ton compte Claude reste protégé :** cette intégration utilise le Claude Agent SDK officiel d'Anthropic et ta CLI Claude Code installée. Elle ne détourne pas l'OAuth, n'extrait ni rejoue de tokens de navigateur, ne se fait pas passer pour un client non pris en charge et ne contourne pas l'authentification d'Anthropic. Elle reste sur la voie d'accès prise en charge par Anthropic et ne porte donc pas le risque de bannissement associé au détournement de tokens ou aux contournements d'authentification non autorisés. -## Command Code - -Command Code utilise ton plan Command Code. - -1. Installe l'intégration (ci-dessus). -2. Choisis **Set up** — une page s'ouvre dans le navigateur. Autorise l'accès, puis reviens dans OpenChamber. - -Sur une machine sans interface graphique ou en CI, définis la variable d'environnement `COMMAND_CODE_API_KEY` au lieu de te connecter via le navigateur. - ## Cursor Cursor rend disponibles dans OpenChamber les modèles inclus dans ton plan Cursor. diff --git a/packages/docs/content/docs/fr/providers.mdx b/packages/docs/content/docs/fr/providers.mdx index d79633d8..a3ce0149 100644 --- a/packages/docs/content/docs/fr/providers.mdx +++ b/packages/docs/content/docs/fr/providers.mdx @@ -45,6 +45,6 @@ Les connexions aux fournisseurs sont stockées par OpenCode, pas OpenChamber ; e ## Pages liées -- [Intégrations](/integrations/) — utiliser un abonnement Claude, Command Code ou Cursor comme fournisseur +- [Intégrations](/integrations/) — utiliser un abonnement Claude ou Cursor comme fournisseur - [Serveurs MCP](/mcp/) — ajouter des outils supplémentaires aux agents - [Utilisation et quotas](/usage/) — suivre votre consommation diff --git a/packages/docs/content/docs/integrations.mdx b/packages/docs/content/docs/integrations.mdx index 599d0720..8aef2d38 100644 --- a/packages/docs/content/docs/integrations.mdx +++ b/packages/docs/content/docs/integrations.mdx @@ -1,20 +1,17 @@ --- title: Integrations -description: Use your Claude, Command Code, or Cursor subscription as a provider. +description: Use your Claude or Cursor subscription as a provider. --- # Integrations An integration is a small plugin that adds a provider to OpenChamber using a subscription you already have. You manage them at **Settings → Integrations**. -> **Experimental feature:** integrations may change or stop working. Use them at your own discretion. - -We designed these integrations to follow providers' intended sign-in flows and avoid known Terms of Service violations. We cannot guarantee that a provider will accept every use or account. Review the provider's terms and use integrations at your own risk. OpenChamber cannot resolve account restrictions, suspensions, or disputes with a provider. +> **Experimental feature.** We aim to respect provider policies, but account restrictions and suspensions remain each provider's decision. Use integrations at your own risk. Available integrations: - **Claude Code** — your Claude Pro or Max plan, no API keys -- **Command Code** — your Command Code plan - **Cursor** — the model limits of your Cursor plan ## Install an integration @@ -33,19 +30,10 @@ Claude Code uses your Claude Pro or Max plan — no API keys and no separate Cla 1. Install the integration (above). 2. Choose **Set up** and sign in. If you don't have the Claude Code CLI yet, setup offers to install it first and then sign you in. -Claude Code is the only integration here that requires its provider CLI to be installed and signed in. Command Code and Cursor do not require their CLIs. +Claude Code is the only integration here that requires its provider CLI to be installed and signed in. Cursor does not require its CLI. **How your Claude account stays safe:** this integration uses Anthropic's official Claude Agent SDK and your installed Claude Code CLI. It does not hijack OAuth, extract or replay browser tokens, impersonate an unsupported client, or bypass Anthropic's authentication flow. It stays on Anthropic's supported access path, so it does not carry the account-ban risk of token hijacking or unauthorized authentication workarounds. -## Command Code - -Command Code uses your Command Code plan. - -1. Install the integration (above). -2. Choose **Set up** — a browser page opens. Approve access, then return to OpenChamber. - -On a headless machine or in CI, set the `COMMAND_CODE_API_KEY` environment variable instead of signing in in the browser. - ## Cursor Cursor makes the models included in your Cursor plan available in OpenChamber. diff --git a/packages/docs/content/docs/ja/integrations.mdx b/packages/docs/content/docs/ja/integrations.mdx index b4f2c24c..b13ac0cd 100644 --- a/packages/docs/content/docs/ja/integrations.mdx +++ b/packages/docs/content/docs/ja/integrations.mdx @@ -1,20 +1,17 @@ --- title: 統合機能 -description: Claude、Command Code、Cursor のサブスクリプションをプロバイダーとして使う。 +description: Claude または Cursor のサブスクリプションをプロバイダーとして使う。 --- # 統合機能 統合機能(インテグレーション)は、すでに持っているサブスクリプションを使って OpenChamber にプロバイダーを追加する小さなプラグインです。**Settings → Integrations** で管理します。 -> **実験的な機能:** 連携は変更されたり、動作しなくなったりする可能性があります。自己責任で使用してください。 - -これらの連携は、プロバイダーが想定するサインインの流れに従い、既知の利用規約違反を避けるよう設計しています。ただし、プロバイダーがすべての利用方法やアカウントを受け入れることは保証できません。プロバイダーの規約を確認し、自己責任で連携を使用してください。OpenChamber は、プロバイダーによるアカウント制限、停止、または紛争を解決できません。 +> **実験的な機能。** プロバイダーの方針を尊重するよう努めていますが、アカウントの制限や停止は各プロバイダーの判断に委ねられます。自己責任で連携を使用してください。 利用できる統合機能: - **Claude Code** — Claude Pro または Max プラン、API キー不要 -- **Command Code** — Command Code のプラン - **Cursor** — Cursor プランのモデル利用枠 ## 統合機能をインストールする @@ -33,19 +30,10 @@ Claude Code は Claude Pro または Max プランを使います — API キー 1. 統合機能をインストールします(上記)。 2. **Set up** を選んでサインインします。Claude Code CLI がまだない場合は、セットアップがまずインストールを提案し、その後サインインします。 -Claude Code は、ここで唯一プロバイダーの CLI のインストールとサインインを必要とする統合機能です。Command Code と Cursor は CLI を必要としません。 +Claude Code は、ここで唯一プロバイダーの CLI のインストールとサインインを必要とする統合機能です。Cursor は CLI を必要としません。 **Claude アカウントが守られる仕組み:** この統合機能は Anthropic の公式 Claude Agent SDK と、インストール済みの Claude Code CLI を使用します。OAuth の乗っ取り、ブラウザートークンの抽出や再生、未対応クライアントへの偽装、Anthropic の認証フローの回避は一切行いません。Anthropic がサポートする正規のアクセス経路を使うため、トークン乗っ取りや不正な認証の回避につきもののアカウント停止リスクはありません。 -## Command Code - -Command Code は Command Code のプランを使います。 - -1. 統合機能をインストールします(上記)。 -2. **Set up** を選ぶとブラウザーでページが開きます。アクセスを許可して OpenChamber に戻ります。 - -画面のないサーバーや CI では、ブラウザーでサインインする代わりに環境変数 `COMMAND_CODE_API_KEY` を設定してください。 - ## Cursor Cursor は Cursor プランに含まれるモデルを OpenChamber で使えるようにします。 diff --git a/packages/docs/content/docs/ja/providers.mdx b/packages/docs/content/docs/ja/providers.mdx index 62f1c663..7e9c0525 100644 --- a/packages/docs/content/docs/ja/providers.mdx +++ b/packages/docs/content/docs/ja/providers.mdx @@ -45,6 +45,6 @@ OpenChamber が何かを行うには、少なくとも 1 つの AI プロバイ ## 関連 -- [統合機能](/integrations/) — Claude、Command Code、Cursor のサブスクリプションをプロバイダーとして使う +- [統合機能](/integrations/) — Claude または Cursor のサブスクリプションをプロバイダーとして使う - [MCP サーバー](/mcp/) — エージェントに追加ツールを加える - [使用量とクォータ](/usage/) — 使った量を追跡する diff --git a/packages/docs/content/docs/ko/integrations.mdx b/packages/docs/content/docs/ko/integrations.mdx index bdc26f2a..0663879e 100644 --- a/packages/docs/content/docs/ko/integrations.mdx +++ b/packages/docs/content/docs/ko/integrations.mdx @@ -1,20 +1,17 @@ --- title: 통합 기능 -description: Claude, Command Code 또는 Cursor 구독을 공급자로 사용하세요. +description: Claude 또는 Cursor 구독을 공급자로 사용하세요. --- # 통합 기능 통합 기능(인테그레이션)은 이미 가지고 있는 구독을 사용해 OpenChamber에 공급자를 추가하는 작은 플러그인입니다. **Settings → Integrations**에서 관리합니다. -> **실험 단계 기능:** 통합 기능은 변경되거나 작동하지 않을 수 있습니다. 본인의 판단에 따라 사용하세요. - -이 통합 기능은 프로바이더가 의도한 로그인 흐름을 따르고 알려진 서비스 약관 위반을 피하도록 설계했습니다. 프로바이더가 모든 사용 방식이나 계정을 허용한다고 보장할 수는 없습니다. 프로바이더의 약관을 검토하고 본인의 책임 아래 통합 기능을 사용하세요. OpenChamber는 프로바이더와의 계정 제한, 정지 또는 분쟁을 해결할 수 없습니다. +> **실험 단계 기능.** 프로바이더 정책을 존중하려 노력하지만, 계정 제한과 정지는 각 프로바이더의 결정입니다. 본인의 책임 아래 통합 기능을 사용하세요. 사용 가능한 통합 기능: - **Claude Code** — Claude Pro 또는 Max 플랜, API 키 불필요 -- **Command Code** — Command Code 플랜 - **Cursor** — Cursor 플랜의 모델 한도 ## 통합 기능 설치 @@ -33,19 +30,10 @@ Claude Code는 Claude Pro 또는 Max 플랜을 사용합니다 — API 키도 1. 통합 기능을 설치합니다(위 참고). 2. **Set up**를 선택하고 로그인합니다. Claude Code CLI가 아직 없으면 설정에서 먼저 설치를 제안한 뒤 로그인을 진행합니다. -Claude Code는 여기에서 유일하게 공급자 CLI 설치와 로그인을 필요로 하는 통합 기능입니다. Command Code와 Cursor는 CLI가 필요 없습니다. +Claude Code는 여기에서 유일하게 공급자 CLI 설치와 로그인을 필요로 하는 통합 기능입니다. Cursor는 CLI가 필요 없습니다. **Claude 계정이 안전하게 유지되는 방식:** 이 통합 기능은 Anthropic의 공식 Claude Agent SDK와 설치된 Claude Code CLI를 사용합니다. OAuth 탈취, 브라우저 토큰 추출·재사용, 지원되지 않는 클라이언트로의 위장, Anthropic 인증 우회를 하지 않습니다. Anthropic이 지원하는 정상 경로를 사용하므로 토큰 탈취나 비인가 인증 우회에 따른 계정 정지 위험이 없습니다. -## Command Code - -Command Code는 Command Code 플랜을 사용합니다. - -1. 통합 기능을 설치합니다(위 참고). -2. **Set up**를 선택하면 브라우저에서 페이지가 열립니다. 접근을 승인한 뒤 OpenChamber로 돌아옵니다. - -화면이 없는 서버나 CI 환경에서는 브라우저 로그인 대신 `COMMAND_CODE_API_KEY` 환경 변수를 설정하세요. - ## Cursor Cursor는 Cursor 플랜에 포함된 모델을 OpenChamber에서 사용할 수 있게 합니다. diff --git a/packages/docs/content/docs/ko/providers.mdx b/packages/docs/content/docs/ko/providers.mdx index 11d9007f..98fb109d 100644 --- a/packages/docs/content/docs/ko/providers.mdx +++ b/packages/docs/content/docs/ko/providers.mdx @@ -45,6 +45,6 @@ OpenChamber가 무언가를 하려면 먼저 최소한 하나의 AI 공급자가 ## 관련 항목 -- [통합 기능](/ko/integrations/) — Claude, Command Code, Cursor 구독을 공급자로 사용 +- [통합 기능](/ko/integrations/) — Claude 또는 Cursor 구독을 공급자로 사용 - [MCP Servers](/ko/mcp/) — 에이전트에 추가 도구를 제공합니다 - [Usage & Quotas](/ko/usage/) — 사용량을 추적합니다 diff --git a/packages/docs/content/docs/pl/integrations.mdx b/packages/docs/content/docs/pl/integrations.mdx index da95fde2..7e3ddf2c 100644 --- a/packages/docs/content/docs/pl/integrations.mdx +++ b/packages/docs/content/docs/pl/integrations.mdx @@ -1,20 +1,17 @@ --- title: Integracje -description: Używaj subskrypcji Claude, Command Code lub Cursor jako dostawcy. +description: Używaj subskrypcji Claude lub Cursor jako dostawcy. --- # Integracje Integracja to mała wtyczka, która dodaje dostawcę do OpenChamber na podstawie subskrypcji, którą już masz. Zarządzasz nimi w **Settings → Integrations**. -> **Funkcja eksperymentalna:** integracje mogą się zmienić lub przestać działać. Korzystasz z nich na własną odpowiedzialność. - -Zaprojektowaliśmy te integracje tak, aby korzystały z zamierzonych przez dostawców sposobów logowania i unikały znanych naruszeń ich warunków korzystania. Nie możemy zagwarantować, że dostawca zaakceptuje każdy sposób użycia lub konto. Sprawdź warunki dostawcy i używaj integracji na własne ryzyko. OpenChamber nie może rozwiązać ograniczeń konta, zawieszeń ani sporów z dostawcą. +> **Funkcja eksperymentalna.** Staramy się przestrzegać zasad dostawców, ale ograniczenia i zawieszenia kont pozostają decyzją każdego dostawcy. Używaj integracji na własne ryzyko. Dostępne integracje: - **Claude Code** — Twój plan Claude Pro lub Max, bez kluczy API -- **Command Code** — Twój plan Command Code - **Cursor** — limity modeli z Twojego planu Cursor ## Instalacja integracji @@ -33,19 +30,10 @@ Claude Code korzysta z Twojego planu Claude Pro lub Max — bez kluczy API i bez 1. Zainstaluj integrację (patrz wyżej). 2. Wybierz **Set up** i zaloguj się. Jeśli nie masz jeszcze Claude Code CLI, konfiguracja zaoferuje najpierw jego instalację, a potem logowanie. -Claude Code jest jedyną integracją tutaj, która wymaga zainstalowanego i zalogowanego CLI swojego dostawcy. Command Code i Cursor nie wymagają swoich CLI. +Claude Code jest jedyną integracją tutaj, która wymaga zainstalowanego i zalogowanego CLI swojego dostawcy. Cursor nie wymaga swojego CLI. **Jak chronione jest Twoje konto Claude:** ta integracja używa oficjalnego Claude Agent SDK od Anthropic i Twojego zainstalowanego Claude Code CLI. Nie przechwytuje OAuth, nie wyodrębnia ani nie odtwarza tokenów przeglądarki, nie podszywa się pod nieobsługiwany klient i nie omija uwierzytelniania Anthropic. Działa na obsługiwanej przez Anthropic ścieżce dostępu, więc nie niesie ryzyka zablokowania konta związanego z przechwytywaniem tokenów lub nieautoryzowanymi obejściami uwierzytelniania. -## Command Code - -Command Code korzysta z Twojego planu Command Code. - -1. Zainstaluj integrację (patrz wyżej). -2. Wybierz **Set up** — w przeglądarce otworzy się strona. Zatwierdź dostęp i wróć do OpenChamber. - -Na maszynie bez interfejsu graficznego lub w CI ustaw zmienną środowiskową `COMMAND_CODE_API_KEY` zamiast logowania w przeglądarce. - ## Cursor Cursor udostępnia w OpenChamber modele zawarte w Twoim planie Cursor. diff --git a/packages/docs/content/docs/pl/providers.mdx b/packages/docs/content/docs/pl/providers.mdx index 79d1ad41..4c777176 100644 --- a/packages/docs/content/docs/pl/providers.mdx +++ b/packages/docs/content/docs/pl/providers.mdx @@ -45,6 +45,6 @@ Logowania dostawców są przechowywane przez OpenCode, a nie OpenChamber, więc ## Powiązane -- [Integracje](/pl/integrations/) — używaj subskrypcji Claude, Command Code lub Cursor jako dostawcy +- [Integracje](/pl/integrations/) — używaj subskrypcji Claude lub Cursor jako dostawcy - [Serwery MCP](/pl/mcp/) — dodaj agentom dodatkowe narzędzia - [Zużycie i limity](/pl/usage/) — śledź, ile już wykorzystałeś diff --git a/packages/docs/content/docs/providers.mdx b/packages/docs/content/docs/providers.mdx index df7a7d1e..2505ced4 100644 --- a/packages/docs/content/docs/providers.mdx +++ b/packages/docs/content/docs/providers.mdx @@ -57,6 +57,6 @@ Provider sign-ins are stored by OpenCode, not OpenChamber, so they're shared wit ## Related -- [Integrations](/integrations/) — use a Claude, Command Code, or Cursor subscription as a provider +- [Integrations](/integrations/) — use a Claude or Cursor subscription as a provider - [MCP Servers](/mcp/) — add extra tools for agents - [Usage & Quotas](/usage/) — track how much you've used diff --git a/packages/docs/content/docs/pt-br/integrations.mdx b/packages/docs/content/docs/pt-br/integrations.mdx index 1fd915d3..82ed2e68 100644 --- a/packages/docs/content/docs/pt-br/integrations.mdx +++ b/packages/docs/content/docs/pt-br/integrations.mdx @@ -1,20 +1,17 @@ --- title: Integrações -description: Use sua assinatura Claude, Command Code ou Cursor como provedor. +description: Use sua assinatura Claude ou Cursor como provedor. --- # Integrações Uma integração é um pequeno plugin que adiciona um provedor ao OpenChamber usando uma assinatura que você já tem. Você as gerencia em **Settings → Integrations**. -> **Recurso experimental:** as integrações podem mudar ou deixar de funcionar. Use-as por sua conta e risco. - -Projetamos estas integrações para seguir os fluxos de login pretendidos pelos provedores e evitar violações conhecidas de seus Termos de Serviço. Não podemos garantir que um provedor aceitará todos os usos ou contas. Consulte os termos do provedor e use as integrações por sua conta e risco. O OpenChamber não pode resolver restrições, suspensões de conta ou disputas com um provedor. +> **Recurso experimental.** Buscamos respeitar as políticas dos provedores, mas restrições e suspensões de conta continuam sendo decisão de cada provedor. Use as integrações por sua conta e risco. Integrações disponíveis: - **Claude Code** — seu plano Claude Pro ou Max, sem chaves de API -- **Command Code** — seu plano Command Code - **Cursor** — os limites de modelos do seu plano Cursor ## Instalar uma integração @@ -33,19 +30,10 @@ O Claude Code usa seu plano Claude Pro ou Max — sem chaves de API e sem um app 1. Instale a integração (acima). 2. Escolha **Set up** e faça login. Se você ainda não tem a CLI do Claude Code, a configuração oferece instalá-la primeiro e depois fazer login. -O Claude Code é a única integração aqui que exige que a CLI do provedor esteja instalada e autenticada. Command Code e Cursor não exigem suas CLIs. +O Claude Code é a única integração aqui que exige que a CLI do provedor esteja instalada e autenticada. Cursor não exige sua CLI. **Como sua conta Claude fica protegida:** esta integração usa o Claude Agent SDK oficial da Anthropic e a CLI do Claude Code instalada em sua máquina. Ela não sequestra OAuth, não extrai nem reproduz tokens do navegador, não se passa por um cliente não suportado e não contorna a autenticação da Anthropic. Ela permanece no caminho de acesso suportado pela Anthropic, portanto não traz o risco de banimento de conta associado a sequestro de tokens ou a contornos de autenticação não autorizados. -## Command Code - -O Command Code usa seu plano Command Code. - -1. Instale a integração (acima). -2. Escolha **Set up** — uma página abre no navegador. Autorize o acesso e volte ao OpenChamber. - -Em uma máquina sem interface gráfica ou em CI, defina a variável de ambiente `COMMAND_CODE_API_KEY` em vez de fazer login pelo navegador. - ## Cursor O Cursor torna disponíveis no OpenChamber os modelos incluídos no seu plano Cursor. diff --git a/packages/docs/content/docs/pt-br/providers.mdx b/packages/docs/content/docs/pt-br/providers.mdx index b05dc222..70ece24f 100644 --- a/packages/docs/content/docs/pt-br/providers.mdx +++ b/packages/docs/content/docs/pt-br/providers.mdx @@ -45,6 +45,6 @@ Os logins de provedores são armazenados pelo OpenCode, não pelo OpenChamber, e ## Relacionado -- [Integrações](/pt-br/integrations/) — use uma assinatura Claude, Command Code ou Cursor como provedor +- [Integrações](/pt-br/integrations/) — use uma assinatura Claude ou Cursor como provedor - [Servidores MCP](/pt-br/mcp/) — adicione ferramentas extras para os agentes - [Uso e Cotas](/pt-br/usage/) — acompanhe quanto você já usou diff --git a/packages/docs/content/docs/uk/integrations.mdx b/packages/docs/content/docs/uk/integrations.mdx index 50c620b9..d6d075f4 100644 --- a/packages/docs/content/docs/uk/integrations.mdx +++ b/packages/docs/content/docs/uk/integrations.mdx @@ -1,20 +1,17 @@ --- title: Інтеграції -description: Використовуйте підписки Claude, Command Code або Cursor як провайдерів. +description: Використовуйте підписки Claude або Cursor як провайдерів. --- # Інтеграції Інтеграція — це невеликий плагін, що додає провайдера до OpenChamber на основі підписки, яка в вас уже є. Керувати ними можна в **Settings → Integrations**. -> **Експериментальна функція:** інтеграції можуть змінюватися або перестати працювати. Використовуйте їх на власний розсуд. - -Ми розробили ці інтеграції так, щоб вони використовували передбачені провайдерами способи входу й не порушували відомі нам умови користування. Ми не можемо гарантувати, що провайдер прийме кожен спосіб використання або кожен обліковий запис. Ознайомтеся з умовами провайдера й використовуйте інтеграції на власний ризик. OpenChamber не може вирішувати обмеження, блокування облікових записів або суперечки з провайдером. +> **Експериментальна функція.** Ми прагнемо дотримуватися політик провайдерів, але обмеження та блокування облікових записів залишаються рішенням кожного провайдера. Використовуйте інтеграції на власний ризик. Доступні інтеграції: - **Claude Code** — ваша підписка Claude Pro або Max, без API-ключів -- **Command Code** — ваша підписка Command Code - **Cursor** — ліміти моделей вашої підписки Cursor ## Встановлення інтеграції @@ -33,19 +30,10 @@ Claude Code використовує вашу підписку Claude Pro або 1. Встановіть інтеграцію (вище). 2. Натисніть **Set up** і увійдіть. Якщо у вас ще немає Claude Code CLI, програма встановлення спершу запропонує його встановити, а потім виконає вхід. -Claude Code — єдина інтеграція тут, яка вимагає встановленого та залогіненого CLI свого провайдера. Для Command Code і Cursor їхні CLI не потрібні. +Claude Code — єдина інтеграція тут, яка вимагає встановленого та залогіненого CLI свого провайдера. Для Cursor CLI не потрібен. **Як захищається ваш обліковий запис Claude:** ця інтеграція використовує офіційний Claude Agent SDK від Anthropic і ваш встановлений Claude Code CLI. Вона не перехоплює OAuth, не витягує й не відтворює браузерні токени, не видає себе за непідтримуваний клієнт і не обходить процес автентифікації Anthropic. Усе працює через підтримуваний Anthropic шлях доступу, тож інтеграція не несе ризику блокування облікового запису, пов'язаного з перехопленням токенів або несанкціонованими способами автентифікації. -## Command Code - -Command Code використовує вашу підписку Command Code. - -1. Встановіть інтеграцію (вище). -2. Натисніть **Set up** — відкриється сторінка в браузері. Підтвердьте доступ і поверніться до OpenChamber. - -На сервері без графічного інтерфейсу або в CI замість входу через браузер задайте змінну середовища `COMMAND_CODE_API_KEY`. - ## Cursor Cursor робить доступними в OpenChamber моделі, що входять у вашу підписку Cursor. diff --git a/packages/docs/content/docs/uk/providers.mdx b/packages/docs/content/docs/uk/providers.mdx index 4fb61d65..9f2777e5 100644 --- a/packages/docs/content/docs/uk/providers.mdx +++ b/packages/docs/content/docs/uk/providers.mdx @@ -45,6 +45,6 @@ description: Підключайте AI-провайдерів, обирайте ## Пов'язане -- [Інтеграції](/uk/integrations/) — використовуйте підписки Claude, Command Code або Cursor як провайдерів +- [Інтеграції](/uk/integrations/) — використовуйте підписки Claude або Cursor як провайдерів - [MCP Servers](/uk/mcp/) — додайте агентам додаткові інструменти - [Використання та квоти](/uk/usage/) — відстежуйте, скільки ви витратили diff --git a/packages/docs/content/docs/zh-cn/integrations.mdx b/packages/docs/content/docs/zh-cn/integrations.mdx index a0a081fe..a5686e3c 100644 --- a/packages/docs/content/docs/zh-cn/integrations.mdx +++ b/packages/docs/content/docs/zh-cn/integrations.mdx @@ -1,20 +1,17 @@ --- title: 集成 -description: 将你的 Claude、Command Code 或 Cursor 订阅用作提供商。 +description: 将你的 Claude 或 Cursor 订阅用作提供商。 --- # 集成 集成是一个小型插件,它使用你已有的订阅为 OpenChamber 添加一个提供商。你可以在 **Settings → Integrations** 中管理它们。 -> **实验性功能:**集成可能会变更或停止工作。请自行酌情使用。 - -我们设计这些集成时,力求遵循提供商预期的登录流程,并避免已知的服务条款违规。我们无法保证提供商会接受每种使用方式或每个帐户。请查看提供商的条款,并自行承担使用集成的风险。OpenChamber 无法处理提供商施加的帐户限制、暂停或争议。 +> **实验性功能。**我们力求遵守提供商的政策,但帐户限制和暂停仍由各提供商决定。请自行承担使用集成的风险。 可用的集成: - **Claude Code** — 你的 Claude Pro 或 Max 套餐,无需 API 密钥 -- **Command Code** — 你的 Command Code 套餐 - **Cursor** — 你的 Cursor 套餐的模型额度 ## 安装集成 @@ -33,19 +30,10 @@ Claude Code 使用你的 Claude Pro 或 Max 套餐 — 无需 API 密钥,也 1. 安装集成(见上文)。 2. 选择 **Set up** 并登录。如果你还没有 Claude Code CLI,安装向导会先提供安装,然后再登录。 -Claude Code 是这里唯一要求安装并登录其提供商 CLI 的集成。Command Code 和 Cursor 不需要它们的 CLI。 +Claude Code 是这里唯一要求安装并登录其提供商 CLI 的集成。Cursor 不需要其 CLI。 **你的 Claude 账户如何受到保护:** 此集成使用 Anthropic 官方的 Claude Agent SDK 和你已安装的 Claude Code CLI。它不会劫持 OAuth,不会提取或重放浏览器令牌,不会冒充不受支持的客户端,也不会绕过 Anthropic 的身份验证。它始终运行在 Anthropic 支持的访问路径上,因此不会带来与令牌劫持或未授权身份验证变通手段相关的封号风险。 -## Command Code - -Command Code 使用你的 Command Code 套餐。 - -1. 安装集成(见上文)。 -2. 选择 **Set up** — 浏览器中会打开一个页面。授权访问,然后返回 OpenChamber。 - -在没有图形界面的服务器或 CI 环境中,请设置环境变量 `COMMAND_CODE_API_KEY` 来代替浏览器登录。 - ## Cursor Cursor 让你的 Cursor 套餐中包含的模型可以在 OpenChamber 中使用。 diff --git a/packages/docs/content/docs/zh-cn/providers.mdx b/packages/docs/content/docs/zh-cn/providers.mdx index 62cf4019..5e81e7d3 100644 --- a/packages/docs/content/docs/zh-cn/providers.mdx +++ b/packages/docs/content/docs/zh-cn/providers.mdx @@ -45,6 +45,6 @@ description: 连接 AI 提供商、选择模型并设置智能体。 ## 相关内容 -- [集成](/zh-cn/integrations/) — 将 Claude、Command Code 或 Cursor 订阅用作提供商 +- [集成](/zh-cn/integrations/) — 将 Claude 或 Cursor 订阅用作提供商 - [MCP Servers](/zh-cn/mcp/) — 为智能体添加额外工具 - [用量与配额](/zh-cn/usage/) — 跟踪你已使用的量 diff --git a/packages/ui/src/components/sections/integrations/ComingSoonMessengersSection.tsx b/packages/ui/src/components/sections/integrations/ComingSoonMessengersSection.tsx deleted file mode 100644 index ab2693ed..00000000 --- a/packages/ui/src/components/sections/integrations/ComingSoonMessengersSection.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import React from 'react'; -import { Icon } from '@/components/icon/Icon'; -import { SettingsSection } from '@/components/sections/shared/SettingsSection'; -import type { IconName } from '@/components/icon/icons'; -import { useI18n, type I18nKey } from '@/lib/i18n'; -import { cn } from '@/lib/utils'; - -type ComingSoonMessenger = { - id: 'discord' | 'telegram'; - icon: IconName; - brandClassName: string; - nameKey: I18nKey; - descriptionKey: I18nKey; -}; - -const COMING_SOON_MESSENGERS: readonly ComingSoonMessenger[] = [ - { - id: 'discord', - icon: 'discord-fill', - brandClassName: 'text-[#5865F2]', - nameKey: 'settings.integrations.messengers.discord.name', - descriptionKey: 'settings.integrations.messengers.discord.description', - }, - { - id: 'telegram', - icon: 'telegram-fill', - brandClassName: 'text-[#2AABEE]', - nameKey: 'settings.integrations.messengers.telegram.name', - descriptionKey: 'settings.integrations.messengers.telegram.description', - }, -] as const; - -/** - * Non-interactive Discord/Telegram placeholders — same card chrome as live - * integrations, greyed out, with a Coming soon badge and no expandable body. - */ -export const ComingSoonMessengersSection: React.FC = () => { - const { t } = useI18n(); - - return ( - - {COMING_SOON_MESSENGERS.map((messenger) => ( -
-
- -
-
-
{t(messenger.nameKey)}
-

- {t(messenger.descriptionKey)} -

-
- - {t('settings.common.state.comingSoon')} - -
- ))} -
- ); -}; diff --git a/packages/ui/src/components/sections/integrations/IntegrationsPage.tsx b/packages/ui/src/components/sections/integrations/IntegrationsPage.tsx index 047755d0..666c0801 100644 --- a/packages/ui/src/components/sections/integrations/IntegrationsPage.tsx +++ b/packages/ui/src/components/sections/integrations/IntegrationsPage.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { Icon } from '@/components/icon/Icon'; import { SettingsPageLayout } from '@/components/sections/shared/SettingsPageLayout'; +import { SETTINGS_DESCRIPTION_CLASS } from '@/components/sections/shared/SettingsSection'; import { useI18n } from '@/lib/i18n'; import { ThirdPartyIntegrationsSection } from './ThirdPartyIntegrationsSection'; @@ -18,15 +19,19 @@ export const IntegrationsPage: React.FC = ({ return ( +

{t('settings.page.integrations.description')}

+
+ +

+ {t('settings.integrations.experimentalWarning')} +

+
+
+ )} showSaveStatus={false} > -
- -

- {t('settings.integrations.experimentalWarning')} -

-
- {plugin.providerId === 'command-code' ? ( - - ) : ( - - )} +
{t(plugin.nameKey)}
diff --git a/packages/ui/src/components/sections/integrations/thirdPartyPlugins.test.ts b/packages/ui/src/components/sections/integrations/thirdPartyPlugins.test.ts index 35dc0846..83a9dd65 100644 --- a/packages/ui/src/components/sections/integrations/thirdPartyPlugins.test.ts +++ b/packages/ui/src/components/sections/integrations/thirdPartyPlugins.test.ts @@ -131,11 +131,6 @@ describe('third-party plugin catalog helpers', () => { packageName: '@openchamber/opencode-claude', homepage: 'https://github.com/openchamber/opencode-claude', }, - { - id: 'opencode-commandcode', - packageName: '@openchamber/opencode-commandcode', - homepage: 'https://github.com/openchamber/opencode-commandcode', - }, { id: 'opencode-cursor-oauth', packageName: '@openchamber/opencode-cursor', diff --git a/packages/ui/src/components/sections/integrations/thirdPartyPlugins.ts b/packages/ui/src/components/sections/integrations/thirdPartyPlugins.ts index ecbf1bba..5b3cb4f5 100644 --- a/packages/ui/src/components/sections/integrations/thirdPartyPlugins.ts +++ b/packages/ui/src/components/sections/integrations/thirdPartyPlugins.ts @@ -25,16 +25,6 @@ export const THIRD_PARTY_PLUGINS: readonly ThirdPartyPluginDefinition[] = [ descriptionKey: 'settings.integrations.thirdParty.opencodeClaude.description', homepage: 'https://github.com/openchamber/opencode-claude', }, - { - id: 'opencode-commandcode', - packageName: '@openchamber/opencode-commandcode', - providerId: 'command-code', - icon: 'command-code', - brandClassName: 'text-foreground', - nameKey: 'settings.integrations.thirdParty.opencodeCommandcode.name', - descriptionKey: 'settings.integrations.thirdParty.opencodeCommandcode.description', - homepage: 'https://github.com/openchamber/opencode-commandcode', - }, { id: 'opencode-cursor-oauth', packageName: '@openchamber/opencode-cursor', diff --git a/packages/ui/src/components/ui/providerLogoFallback.test.ts b/packages/ui/src/components/ui/providerLogoFallback.test.ts index c82b9ad3..a95e41e0 100644 --- a/packages/ui/src/components/ui/providerLogoFallback.test.ts +++ b/packages/ui/src/components/ui/providerLogoFallback.test.ts @@ -2,8 +2,10 @@ import { describe, expect, test } from 'bun:test'; import { getProviderLogoFallbackIcon } from './providerLogoFallback'; describe('provider logo fallbacks', () => { - test('uses a local terminal icon when Command Code has no resolved logo', () => { - expect(getProviderLogoFallbackIcon('command-code')).toBe('terminal-box'); + test('uses a local terminal icon for Command Code provider ID variants', () => { + for (const providerId of ['command-code', 'commandcode', 'command_code', 'command code']) { + expect(getProviderLogoFallbackIcon(providerId)).toBe('terminal-box'); + } }); test('does not replace providers with their own logo assets', () => { diff --git a/packages/ui/src/components/ui/providerLogoFallback.ts b/packages/ui/src/components/ui/providerLogoFallback.ts index 9aa871fd..68a40c28 100644 --- a/packages/ui/src/components/ui/providerLogoFallback.ts +++ b/packages/ui/src/components/ui/providerLogoFallback.ts @@ -1,5 +1,9 @@ import type { IconName } from '@/components/icon/icons'; +const COMMAND_CODE_PROVIDER_IDS = new Set(['command-code', 'commandcode', 'command_code', 'command code']); + export function getProviderLogoFallbackIcon(providerId: string | null | undefined): IconName | null { - return providerId?.trim().toLowerCase() === 'command-code' ? 'terminal-box' : null; + return providerId && COMMAND_CODE_PROVIDER_IDS.has(providerId.trim().toLowerCase()) + ? 'terminal-box' + : null; } diff --git a/packages/ui/src/lib/i18n/messages/third-party-integrations.i18n.test.ts b/packages/ui/src/lib/i18n/messages/third-party-integrations.i18n.test.ts index 01cd2750..d854e2bf 100644 --- a/packages/ui/src/lib/i18n/messages/third-party-integrations.i18n.test.ts +++ b/packages/ui/src/lib/i18n/messages/third-party-integrations.i18n.test.ts @@ -7,9 +7,6 @@ const requiredKeys = [ 'settings.page.integrations.title', 'settings.page.integrations.description', 'settings.integrations.experimentalWarning', - 'settings.integrations.messengers.title', - 'settings.integrations.messengers.discord.name', - 'settings.integrations.messengers.telegram.name', 'settings.integrations.thirdParty.title', 'settings.integrations.thirdParty.actions.install', 'settings.integrations.thirdParty.actions.update', @@ -17,7 +14,6 @@ const requiredKeys = [ 'settings.integrations.thirdParty.actions.remove', 'settings.integrations.thirdParty.status.notInstalled', 'settings.integrations.thirdParty.opencodeClaude.description', - 'settings.integrations.thirdParty.opencodeCommandcode.description', 'settings.integrations.thirdParty.opencodeCursorOauth.description', ] as const; diff --git a/packages/ui/src/lib/i18n/messages/third-party-integrations.i18n.ts b/packages/ui/src/lib/i18n/messages/third-party-integrations.i18n.ts index 38b64549..cebfc0b1 100644 --- a/packages/ui/src/lib/i18n/messages/third-party-integrations.i18n.ts +++ b/packages/ui/src/lib/i18n/messages/third-party-integrations.i18n.ts @@ -3,13 +3,7 @@ export const thirdPartyIntegrationI18n = { en: { 'settings.page.integrations.title': 'Integrations', 'settings.page.integrations.description': 'Add third-party subscriptions to use as OpenChamber providers.', - 'settings.integrations.experimentalWarning': 'This is an experimental feature. Integrations may change or stop working. Use them at your own discretion.', - 'settings.integrations.messengers.title': 'Messengers', - 'settings.integrations.messengers.info': 'Chat with OpenChamber from Discord or Telegram. These bridges are not available yet.', - 'settings.integrations.messengers.discord.name': 'Discord', - 'settings.integrations.messengers.discord.description': 'Connect a Discord bot to chat with OpenChamber.', - 'settings.integrations.messengers.telegram.name': 'Telegram', - 'settings.integrations.messengers.telegram.description': 'Connect a Telegram bot to chat with OpenChamber.', + 'settings.integrations.experimentalWarning': 'Experimental feature. We aim to respect provider policies, but account restrictions and suspensions remain each provider\'s decision. Use integrations at your own risk.', 'settings.integrations.thirdParty.title': 'Third-party integrations', 'settings.integrations.thirdParty.info': 'Install a provider plugin, then set up your subscription so OpenChamber can use it.', 'settings.integrations.thirdParty.actions.install': 'Install', @@ -38,21 +32,13 @@ export const thirdPartyIntegrationI18n = { 'settings.integrations.thirdParty.toast.restartRequired': 'Restart OpenCode for changes to take effect', 'settings.integrations.thirdParty.opencodeClaude.name': 'Claude Code', 'settings.integrations.thirdParty.opencodeClaude.description': 'Use your Claude Pro/Max plan — no API keys, no Claude apps.', - 'settings.integrations.thirdParty.opencodeCommandcode.name': 'Command Code', - 'settings.integrations.thirdParty.opencodeCommandcode.description': '$1 Go Plan: unlimited Laguna S 2.1 + $40 DeepSeek V4 Pro. Sign in, no CLI.', 'settings.integrations.thirdParty.opencodeCursorOauth.name': 'Cursor', 'settings.integrations.thirdParty.opencodeCursorOauth.description': 'Cursor’s generous in-house model limits, now in OpenChamber.', }, de: { 'settings.page.integrations.title': 'Integrationen', 'settings.page.integrations.description': 'Füge Drittanbieter-Abonnements hinzu, um sie als OpenChamber-Provider zu nutzen.', - 'settings.integrations.experimentalWarning': 'Dies ist eine experimentelle Funktion. Integrationen können sich ändern oder nicht mehr funktionieren. Nutze sie nach eigenem Ermessen.', - 'settings.integrations.messengers.title': 'Messenger', - 'settings.integrations.messengers.info': 'Chatte mit OpenChamber über Discord oder Telegram. Diese Bridges sind noch nicht verfügbar.', - 'settings.integrations.messengers.discord.name': 'Discord', - 'settings.integrations.messengers.discord.description': 'Verbinde einen Discord-Bot, um mit OpenChamber zu chatten.', - 'settings.integrations.messengers.telegram.name': 'Telegram', - 'settings.integrations.messengers.telegram.description': 'Verbinde einen Telegram-Bot, um mit OpenChamber zu chatten.', + 'settings.integrations.experimentalWarning': 'Experimentelle Funktion. Wir bemühen uns, die Richtlinien der Anbieter zu respektieren, aber Kontobeschränkungen und Sperrungen liegen bei jedem Anbieter. Nutze Integrationen auf eigenes Risiko.', 'settings.integrations.thirdParty.title': 'Drittanbieter-Integrationen', 'settings.integrations.thirdParty.info': 'Installiere ein Provider-Plugin und richte dein Abonnement ein, damit OpenChamber es nutzen kann.', 'settings.integrations.thirdParty.actions.install': 'Installieren', @@ -81,21 +67,13 @@ export const thirdPartyIntegrationI18n = { 'settings.integrations.thirdParty.toast.restartRequired': 'Starte OpenCode neu, damit die Änderungen wirksam werden', 'settings.integrations.thirdParty.opencodeClaude.name': 'Claude Code', 'settings.integrations.thirdParty.opencodeClaude.description': 'Nutze deinen Claude-Pro/Max-Plan — ohne API-Keys, ohne Claude-Apps.', - 'settings.integrations.thirdParty.opencodeCommandcode.name': 'Command Code', - 'settings.integrations.thirdParty.opencodeCommandcode.description': 'Go-Plan für 1 $: unbegrenztes Laguna S 2.1 + 40 $ DeepSeek V4 Pro. Anmelden, kein CLI.', 'settings.integrations.thirdParty.opencodeCursorOauth.name': 'Cursor', 'settings.integrations.thirdParty.opencodeCursorOauth.description': 'Die großzügigen Limits der Cursor-eigenen Modelle jetzt in OpenChamber.', }, fr: { 'settings.page.integrations.title': 'Intégrations', 'settings.page.integrations.description': 'Ajoutez des abonnements tiers à utiliser comme fournisseurs OpenChamber.', - 'settings.integrations.experimentalWarning': 'Cette fonctionnalité est expérimentale. Les intégrations peuvent changer ou cesser de fonctionner. Utilisez-les à votre discrétion.', - 'settings.integrations.messengers.title': 'Messagers', - 'settings.integrations.messengers.info': 'Discutez avec OpenChamber depuis Discord ou Telegram. Ces ponts ne sont pas encore disponibles.', - 'settings.integrations.messengers.discord.name': 'Discord', - 'settings.integrations.messengers.discord.description': 'Connectez un bot Discord pour discuter avec OpenChamber.', - 'settings.integrations.messengers.telegram.name': 'Telegram', - 'settings.integrations.messengers.telegram.description': 'Connectez un bot Telegram pour discuter avec OpenChamber.', + 'settings.integrations.experimentalWarning': 'Fonctionnalité expérimentale. Nous cherchons à respecter les règles des fournisseurs, mais les restrictions et suspensions de compte relèvent de leur décision. Utilisez les intégrations à vos risques.', 'settings.integrations.thirdParty.title': 'Intégrations tierces', 'settings.integrations.thirdParty.info': 'Installez un plugin de fournisseur, puis configurez votre abonnement pour qu’OpenChamber puisse l’utiliser.', 'settings.integrations.thirdParty.actions.install': 'Installer', @@ -124,21 +102,13 @@ export const thirdPartyIntegrationI18n = { 'settings.integrations.thirdParty.toast.restartRequired': 'Redémarrez OpenCode pour que les modifications prennent effet', 'settings.integrations.thirdParty.opencodeClaude.name': 'Claude Code', 'settings.integrations.thirdParty.opencodeClaude.description': 'Utilisez votre forfait Claude Pro/Max — sans clés API, sans apps Claude.', - 'settings.integrations.thirdParty.opencodeCommandcode.name': 'Command Code', - 'settings.integrations.thirdParty.opencodeCommandcode.description': 'Go Plan à 1 $ : Laguna S 2.1 illimité + 40 $ DeepSeek V4 Pro. Connectez-vous, sans CLI.', 'settings.integrations.thirdParty.opencodeCursorOauth.name': 'Cursor', 'settings.integrations.thirdParty.opencodeCursorOauth.description': 'Les généreuses limites des modèles internes Cursor, désormais dans OpenChamber.', }, es: { 'settings.page.integrations.title': 'Integraciones', 'settings.page.integrations.description': 'Añade suscripciones de terceros para usarlas como proveedores de OpenChamber.', - 'settings.integrations.experimentalWarning': 'Esta función es experimental. Las integraciones pueden cambiar o dejar de funcionar. Úsalas bajo tu propia responsabilidad.', - 'settings.integrations.messengers.title': 'Mensajeros', - 'settings.integrations.messengers.info': 'Chatea con OpenChamber desde Discord o Telegram. Estos puentes aún no están disponibles.', - 'settings.integrations.messengers.discord.name': 'Discord', - 'settings.integrations.messengers.discord.description': 'Conecta un bot de Discord para chatear con OpenChamber.', - 'settings.integrations.messengers.telegram.name': 'Telegram', - 'settings.integrations.messengers.telegram.description': 'Conecta un bot de Telegram para chatear con OpenChamber.', + 'settings.integrations.experimentalWarning': 'Función experimental. Buscamos respetar las políticas de los proveedores, pero las restricciones y suspensiones de cuentas son decisión de cada proveedor. Usa las integraciones bajo tu propia responsabilidad.', 'settings.integrations.thirdParty.title': 'Integraciones de terceros', 'settings.integrations.thirdParty.info': 'Instala un plugin de proveedor y configura tu suscripción para que OpenChamber pueda usarla.', 'settings.integrations.thirdParty.actions.install': 'Instalar', @@ -167,21 +137,13 @@ export const thirdPartyIntegrationI18n = { 'settings.integrations.thirdParty.toast.restartRequired': 'Reinicia OpenCode para que los cambios surtan efecto', 'settings.integrations.thirdParty.opencodeClaude.name': 'Claude Code', 'settings.integrations.thirdParty.opencodeClaude.description': 'Usa tu plan Claude Pro/Max: sin claves API ni apps de Claude.', - 'settings.integrations.thirdParty.opencodeCommandcode.name': 'Command Code', - 'settings.integrations.thirdParty.opencodeCommandcode.description': 'Go Plan por 1 $: Laguna S 2.1 ilimitado + 40 $ de DeepSeek V4 Pro. Entra, sin CLI.', 'settings.integrations.thirdParty.opencodeCursorOauth.name': 'Cursor', 'settings.integrations.thirdParty.opencodeCursorOauth.description': 'Los generosos límites de los modelos internos de Cursor, ahora en OpenChamber.', }, ja: { 'settings.page.integrations.title': '連携', 'settings.page.integrations.description': 'サードパーティのサブスクリプションを追加して、OpenChamber のプロバイダーとして使います。', - 'settings.integrations.experimentalWarning': 'これは実験的な機能です。連携は変更されたり、動作しなくなったりする可能性があります。自己責任で使用してください。', - 'settings.integrations.messengers.title': 'メッセンジャー', - 'settings.integrations.messengers.info': 'Discord または Telegram から OpenChamber とチャットできます。これらの連携はまだ利用できません。', - 'settings.integrations.messengers.discord.name': 'Discord', - 'settings.integrations.messengers.discord.description': 'Discord ボットを接続して OpenChamber とチャットします。', - 'settings.integrations.messengers.telegram.name': 'Telegram', - 'settings.integrations.messengers.telegram.description': 'Telegram ボットを接続して OpenChamber とチャットします。', + 'settings.integrations.experimentalWarning': '実験的な機能です。プロバイダーの方針を尊重するよう努めていますが、アカウントの制限や停止は各プロバイダーの判断に委ねられます。自己責任で連携を使用してください。', 'settings.integrations.thirdParty.title': 'サードパーティー連携', 'settings.integrations.thirdParty.info': 'プロバイダープラグインをインストールし、サブスクリプションを設定して OpenChamber で使えるようにします。', 'settings.integrations.thirdParty.actions.install': 'インストール', @@ -210,21 +172,13 @@ export const thirdPartyIntegrationI18n = { 'settings.integrations.thirdParty.toast.restartRequired': '変更を反映するには OpenCode を再起動してください', 'settings.integrations.thirdParty.opencodeClaude.name': 'Claude Code', 'settings.integrations.thirdParty.opencodeClaude.description': 'Claude Pro/Max プランを利用 — API キーも Claude アプリも不要。', - 'settings.integrations.thirdParty.opencodeCommandcode.name': 'Command Code', - 'settings.integrations.thirdParty.opencodeCommandcode.description': '1ドルの Go Plan:Laguna S 2.1 無制限 + DeepSeek V4 Pro 40ドル分。ログインするだけで CLI 不要。', 'settings.integrations.thirdParty.opencodeCursorOauth.name': 'Cursor', 'settings.integrations.thirdParty.opencodeCursorOauth.description': 'Cursor 内蔵モデルの余裕ある制限が、OpenChamber で使えます。', }, ko: { 'settings.page.integrations.title': '통합', 'settings.page.integrations.description': '타사 구독을 추가해 OpenChamber 프로바이더로 사용하세요.', - 'settings.integrations.experimentalWarning': '이 기능은 실험 단계입니다. 통합 기능은 변경되거나 작동하지 않을 수 있습니다. 본인의 판단에 따라 사용하세요.', - 'settings.integrations.messengers.title': '메신저', - 'settings.integrations.messengers.info': 'Discord 또는 Telegram에서 OpenChamber와 채팅하세요. 이 브리지는 아직 사용할 수 없습니다.', - 'settings.integrations.messengers.discord.name': 'Discord', - 'settings.integrations.messengers.discord.description': 'Discord 봇을 연결해 OpenChamber와 채팅하세요.', - 'settings.integrations.messengers.telegram.name': 'Telegram', - 'settings.integrations.messengers.telegram.description': 'Telegram 봇을 연결해 OpenChamber와 채팅하세요.', + 'settings.integrations.experimentalWarning': '실험 단계 기능입니다. 프로바이더 정책을 존중하려 노력하지만, 계정 제한과 정지는 각 프로바이더의 결정입니다. 본인의 책임 아래 통합 기능을 사용하세요.', 'settings.integrations.thirdParty.title': '서드파티 통합', 'settings.integrations.thirdParty.info': '프로바이더 플러그인을 설치한 뒤 구독을 설정하면 OpenChamber에서 사용할 수 있습니다.', 'settings.integrations.thirdParty.actions.install': '설치', @@ -253,21 +207,13 @@ export const thirdPartyIntegrationI18n = { 'settings.integrations.thirdParty.toast.restartRequired': '변경 사항을 적용하려면 OpenCode를 다시 시작하세요', 'settings.integrations.thirdParty.opencodeClaude.name': 'Claude Code', 'settings.integrations.thirdParty.opencodeClaude.description': 'Claude Pro/Max 요금제를 사용하세요. API 키와 Claude 앱은 필요 없습니다.', - 'settings.integrations.thirdParty.opencodeCommandcode.name': 'Command Code', - 'settings.integrations.thirdParty.opencodeCommandcode.description': '1달러 Go Plan: Laguna S 2.1 무제한 + DeepSeek V4 Pro 40달러. 로그인만 하면 되고 CLI는 필요 없습니다.', 'settings.integrations.thirdParty.opencodeCursorOauth.name': 'Cursor', 'settings.integrations.thirdParty.opencodeCursorOauth.description': 'Cursor 자체 모델의 넉넉한 한도를 이제 OpenChamber에서.', }, pl: { 'settings.page.integrations.title': 'Integracje', 'settings.page.integrations.description': 'Dodaj subskrypcje zewnętrzne, aby używać ich jako dostawców OpenChamber.', - 'settings.integrations.experimentalWarning': 'To funkcja eksperymentalna. Integracje mogą się zmienić lub przestać działać. Korzystasz z nich na własną odpowiedzialność.', - 'settings.integrations.messengers.title': 'Komunikatory', - 'settings.integrations.messengers.info': 'Czatuj z OpenChamber przez Discord lub Telegram. Te mosty nie są jeszcze dostępne.', - 'settings.integrations.messengers.discord.name': 'Discord', - 'settings.integrations.messengers.discord.description': 'Połącz bota Discord, aby czatować z OpenChamber.', - 'settings.integrations.messengers.telegram.name': 'Telegram', - 'settings.integrations.messengers.telegram.description': 'Połącz bota Telegram, aby czatować z OpenChamber.', + 'settings.integrations.experimentalWarning': 'Funkcja eksperymentalna. Staramy się przestrzegać zasad dostawców, ale ograniczenia i zawieszenia kont pozostają decyzją każdego dostawcy. Używaj integracji na własne ryzyko.', 'settings.integrations.thirdParty.title': 'Integracje zewnętrzne', 'settings.integrations.thirdParty.info': 'Zainstaluj wtyczkę dostawcy, a następnie skonfiguruj subskrypcję, aby OpenChamber mógł z niej korzystać.', 'settings.integrations.thirdParty.actions.install': 'Zainstaluj', @@ -296,21 +242,13 @@ export const thirdPartyIntegrationI18n = { 'settings.integrations.thirdParty.toast.restartRequired': 'Uruchom ponownie OpenCode, aby zastosować zmiany', 'settings.integrations.thirdParty.opencodeClaude.name': 'Claude Code', 'settings.integrations.thirdParty.opencodeClaude.description': 'Korzystaj z planu Claude Pro/Max — bez kluczy API i aplikacji Claude.', - 'settings.integrations.thirdParty.opencodeCommandcode.name': 'Command Code', - 'settings.integrations.thirdParty.opencodeCommandcode.description': 'Go Plan za 1 $: nielimitowane Laguna S 2.1 + 40 $ DeepSeek V4 Pro. Zaloguj się, bez CLI.', 'settings.integrations.thirdParty.opencodeCursorOauth.name': 'Cursor', 'settings.integrations.thirdParty.opencodeCursorOauth.description': 'Hojne limity wewnętrznych modeli Cursor teraz w OpenChamber.', }, 'pt-BR': { 'settings.page.integrations.title': 'Integrações', 'settings.page.integrations.description': 'Adicione assinaturas de terceiros para usar como provedores do OpenChamber.', - 'settings.integrations.experimentalWarning': 'Este recurso é experimental. As integrações podem mudar ou deixar de funcionar. Use-as por sua conta e risco.', - 'settings.integrations.messengers.title': 'Mensageiros', - 'settings.integrations.messengers.info': 'Converse com o OpenChamber pelo Discord ou Telegram. Essas pontes ainda não estão disponíveis.', - 'settings.integrations.messengers.discord.name': 'Discord', - 'settings.integrations.messengers.discord.description': 'Conecte um bot do Discord para conversar com o OpenChamber.', - 'settings.integrations.messengers.telegram.name': 'Telegram', - 'settings.integrations.messengers.telegram.description': 'Conecte um bot do Telegram para conversar com o OpenChamber.', + 'settings.integrations.experimentalWarning': 'Recurso experimental. Buscamos respeitar as políticas dos provedores, mas restrições e suspensões de conta continuam sendo decisão de cada provedor. Use as integrações por sua conta e risco.', 'settings.integrations.thirdParty.title': 'Integrações de terceiros', 'settings.integrations.thirdParty.info': 'Instale um plugin de provedor e configure sua assinatura para o OpenChamber poder usá-la.', 'settings.integrations.thirdParty.actions.install': 'Instalar', @@ -339,21 +277,13 @@ export const thirdPartyIntegrationI18n = { 'settings.integrations.thirdParty.toast.restartRequired': 'Reinicie o OpenCode para que as alterações entrem em vigor', 'settings.integrations.thirdParty.opencodeClaude.name': 'Claude Code', 'settings.integrations.thirdParty.opencodeClaude.description': 'Use seu plano Claude Pro/Max — sem chaves de API nem apps da Claude.', - 'settings.integrations.thirdParty.opencodeCommandcode.name': 'Command Code', - 'settings.integrations.thirdParty.opencodeCommandcode.description': 'Go Plan por US$ 1: Laguna S 2.1 ilimitado + US$ 40 de DeepSeek V4 Pro. Entre, sem CLI.', 'settings.integrations.thirdParty.opencodeCursorOauth.name': 'Cursor', 'settings.integrations.thirdParty.opencodeCursorOauth.description': 'Os limites generosos dos modelos internos do Cursor, agora no OpenChamber.', }, uk: { 'settings.page.integrations.title': 'Інтеграції', 'settings.page.integrations.description': 'Додайте сторонні підписки, щоб використовувати їх як провайдери OpenChamber.', - 'settings.integrations.experimentalWarning': 'Це експериментальна функція. Інтеграції можуть змінюватися або перестати працювати. Використовуйте їх на власний розсуд.', - 'settings.integrations.messengers.title': 'Месенджери', - 'settings.integrations.messengers.info': 'Спілкуйтеся з OpenChamber у Discord або Telegram. Ці мости ще недоступні.', - 'settings.integrations.messengers.discord.name': 'Discord', - 'settings.integrations.messengers.discord.description': 'Підключіть бота Discord, щоб спілкуватися з OpenChamber.', - 'settings.integrations.messengers.telegram.name': 'Telegram', - 'settings.integrations.messengers.telegram.description': 'Підключіть бота Telegram, щоб спілкуватися з OpenChamber.', + 'settings.integrations.experimentalWarning': 'Експериментальна функція. Ми прагнемо дотримуватися політик провайдерів, але обмеження та блокування облікових записів залишаються рішенням кожного провайдера. Використовуйте інтеграції на власний ризик.', 'settings.integrations.thirdParty.title': 'Сторонні інтеграції', 'settings.integrations.thirdParty.info': 'Установіть плагін провайдера, а потім налаштуйте підписку, щоб OpenChamber міг її використовувати.', 'settings.integrations.thirdParty.actions.install': 'Встановити', @@ -382,21 +312,13 @@ export const thirdPartyIntegrationI18n = { 'settings.integrations.thirdParty.toast.restartRequired': 'Перезапустіть OpenCode, щоб застосувати зміни', 'settings.integrations.thirdParty.opencodeClaude.name': 'Claude Code', 'settings.integrations.thirdParty.opencodeClaude.description': 'Claude Pro/Max за підпискою — без API-ключів і без додатків Claude.', - 'settings.integrations.thirdParty.opencodeCommandcode.name': 'Command Code', - 'settings.integrations.thirdParty.opencodeCommandcode.description': 'Go Plan за $1: безліміт Laguna S 2.1 і $40 на DeepSeek V4 Pro. Вхід без CLI.', 'settings.integrations.thirdParty.opencodeCursorOauth.name': 'Cursor', 'settings.integrations.thirdParty.opencodeCursorOauth.description': 'Щедрі ліміти внутрішніх моделей Cursor — тепер в OpenChamber.', }, 'zh-CN': { 'settings.page.integrations.title': '集成', 'settings.page.integrations.description': '添加第三方订阅,将其用作 OpenChamber 提供商。', - 'settings.integrations.experimentalWarning': '这是实验性功能。集成可能会变更或停止工作。请自行酌情使用。', - 'settings.integrations.messengers.title': '即时通讯', - 'settings.integrations.messengers.info': '通过 Discord 或 Telegram 与 OpenChamber 聊天。这些桥接尚不可用。', - 'settings.integrations.messengers.discord.name': 'Discord', - 'settings.integrations.messengers.discord.description': '连接 Discord 机器人以与 OpenChamber 聊天。', - 'settings.integrations.messengers.telegram.name': 'Telegram', - 'settings.integrations.messengers.telegram.description': '连接 Telegram 机器人以与 OpenChamber 聊天。', + 'settings.integrations.experimentalWarning': '实验性功能。我们力求遵守提供商的政策,但帐户限制和暂停仍由各提供商决定。请自行承担使用集成的风险。', 'settings.integrations.thirdParty.title': '第三方集成', 'settings.integrations.thirdParty.info': '安装提供商插件并设置订阅,以便 OpenChamber 可以使用它。', 'settings.integrations.thirdParty.actions.install': '安装', @@ -425,21 +347,13 @@ export const thirdPartyIntegrationI18n = { 'settings.integrations.thirdParty.toast.restartRequired': '请重启 OpenCode 以使更改生效', 'settings.integrations.thirdParty.opencodeClaude.name': 'Claude Code', 'settings.integrations.thirdParty.opencodeClaude.description': '使用 Claude Pro/Max 套餐——无需 API 密钥,也无需 Claude 应用。', - 'settings.integrations.thirdParty.opencodeCommandcode.name': 'Command Code', - 'settings.integrations.thirdParty.opencodeCommandcode.description': '1 美元 Go Plan:无限 Laguna S 2.1,另含 40 美元 DeepSeek V4 Pro。登录即可,无需 CLI。', 'settings.integrations.thirdParty.opencodeCursorOauth.name': 'Cursor', 'settings.integrations.thirdParty.opencodeCursorOauth.description': 'Cursor 内部模型的充足额度,现已可用于 OpenChamber。', }, 'zh-TW': { 'settings.page.integrations.title': '整合', 'settings.page.integrations.description': '新增第三方訂閱,將其用作 OpenChamber 供應商。', - 'settings.integrations.experimentalWarning': '這是實驗性功能。整合可能會變更或停止運作。請自行斟酌使用。', - 'settings.integrations.messengers.title': '即時通訊', - 'settings.integrations.messengers.info': '透過 Discord 或 Telegram 與 OpenChamber 聊天。這些橋接尚不可用。', - 'settings.integrations.messengers.discord.name': 'Discord', - 'settings.integrations.messengers.discord.description': '連接 Discord 機器人以與 OpenChamber 聊天。', - 'settings.integrations.messengers.telegram.name': 'Telegram', - 'settings.integrations.messengers.telegram.description': '連接 Telegram 機器人以與 OpenChamber 聊天。', + 'settings.integrations.experimentalWarning': '實驗性功能。我們致力遵守供應商的政策,但帳戶限制和停用仍由各供應商決定。請自行承擔使用整合的風險。', 'settings.integrations.thirdParty.title': '第三方整合', 'settings.integrations.thirdParty.info': '安裝供應商外掛並設定訂閱,以便 OpenChamber 可以使用它。', 'settings.integrations.thirdParty.actions.install': '安裝', @@ -468,8 +382,6 @@ export const thirdPartyIntegrationI18n = { 'settings.integrations.thirdParty.toast.restartRequired': '請重新啟動 OpenCode 以使變更生效', 'settings.integrations.thirdParty.opencodeClaude.name': 'Claude Code', 'settings.integrations.thirdParty.opencodeClaude.description': '使用 Claude Pro/Max 方案——無需 API 金鑰,也無需 Claude 應用程式。', - 'settings.integrations.thirdParty.opencodeCommandcode.name': 'Command Code', - 'settings.integrations.thirdParty.opencodeCommandcode.description': '1 美元 Go Plan:無限 Laguna S 2.1,另含 40 美元 DeepSeek V4 Pro。登入即可,無需 CLI。', 'settings.integrations.thirdParty.opencodeCursorOauth.name': 'Cursor', 'settings.integrations.thirdParty.opencodeCursorOauth.description': 'Cursor 內部模型的充足額度,現已可用於 OpenChamber。', }, diff --git a/packages/ui/src/lib/settings/search.ts b/packages/ui/src/lib/settings/search.ts index a9a7b264..c7fb00d4 100644 --- a/packages/ui/src/lib/settings/search.ts +++ b/packages/ui/src/lib/settings/search.ts @@ -958,13 +958,6 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [ descriptionKey: 'settings.integrations.thirdParty.opencodeClaude.description', keywords: ['claude', 'anthropic', 'claude code', 'pro', 'max', 'agent sdk', '@openchamber/opencode-claude'], }, - { - id: 'integrations.third-party.opencode-commandcode', - page: 'integrations', - titleKey: 'settings.integrations.thirdParty.opencodeCommandcode.name', - descriptionKey: 'settings.integrations.thirdParty.opencodeCommandcode.description', - keywords: ['command code', 'commandcode', 'laguna', 'poolside', 'gateway', '@openchamber/opencode-commandcode'], - }, { id: 'integrations.third-party.opencode-cursor-oauth', page: 'integrations', diff --git a/packages/web/server/lib/quota/providers/command-code.js b/packages/web/server/lib/quota/providers/command-code.js index c8881435..4c848245 100644 --- a/packages/web/server/lib/quota/providers/command-code.js +++ b/packages/web/server/lib/quota/providers/command-code.js @@ -3,7 +3,7 @@ import { asObject, buildResult, getAuthEntry, normalizeAuthEntry, toNumber, toUs export const providerId = 'command-code'; export const providerName = 'Command Code'; -export const aliases = ['command-code']; +export const aliases = ['command-code', 'commandcode', 'command_code', 'command code']; const API_BASE_URL = 'https://api.commandcode.ai'; diff --git a/packages/web/server/lib/quota/providers/command-code.test.js b/packages/web/server/lib/quota/providers/command-code.test.js index 4a6285c8..e2dc1dde 100644 --- a/packages/web/server/lib/quota/providers/command-code.test.js +++ b/packages/web/server/lib/quota/providers/command-code.test.js @@ -69,4 +69,17 @@ describe('Command Code quota provider', () => { expect(fetchMock.mock.calls[0][1].headers.Authorization).toBe('Bearer test-token'); vi.unstubAllGlobals(); }); + + it('recognizes Command Code auth entries under supported provider ID variants', async () => { + for (const providerId of ['commandcode', 'command_code', 'command code']) { + const fetchMock = vi.fn() + .mockResolvedValueOnce(new Response(JSON.stringify({ org: { id: 'org-1' } }))) + .mockResolvedValueOnce(new Response(JSON.stringify(creditsPayload))); + vi.stubGlobal('fetch', fetchMock); + + const result = await fetchQuota({ [providerId]: { type: 'oauth', access: 'test-token' } }); + expect(result).toMatchObject({ providerId: 'command-code', ok: true, configured: true }); + vi.unstubAllGlobals(); + } + }); }); diff --git a/packages/web/server/lib/quota/providers/index.js b/packages/web/server/lib/quota/providers/index.js index 1ce58558..1f97d159 100644 --- a/packages/web/server/lib/quota/providers/index.js +++ b/packages/web/server/lib/quota/providers/index.js @@ -160,6 +160,13 @@ const registry = { const pendingFetches = new Map(); +const normalizeQuotaProviderId = (providerId) => { + if (typeof providerId !== 'string') return providerId; + return ['command-code', 'commandcode', 'command_code', 'command code'].includes(providerId.trim().toLowerCase()) + ? 'command-code' + : providerId; +}; + export const listConfiguredQuotaProviders = () => { const configured = []; @@ -203,13 +210,14 @@ const fetchQuotaForProviderUncoalesced = async (providerId) => { }; export const fetchQuotaForProvider = (providerId) => { - const existing = pendingFetches.get(providerId); + const normalizedProviderId = normalizeQuotaProviderId(providerId); + const existing = pendingFetches.get(normalizedProviderId); if (existing) return existing; - const pending = fetchQuotaForProviderUncoalesced(providerId).finally(() => { - if (pendingFetches.get(providerId) === pending) pendingFetches.delete(providerId); + const pending = fetchQuotaForProviderUncoalesced(normalizedProviderId).finally(() => { + if (pendingFetches.get(normalizedProviderId) === pending) pendingFetches.delete(normalizedProviderId); }); - pendingFetches.set(providerId, pending); + pendingFetches.set(normalizedProviderId, pending); return pending; }; From 35998f9f4dc72e9a0634577f042db702103ad6af Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sat, 22 Aug 2026 00:30:53 +0300 Subject: [PATCH 009/157] fix(sidebar): indent sessions inside folders --- packages/ui/src/components/session/SessionFolderItem.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/ui/src/components/session/SessionFolderItem.tsx b/packages/ui/src/components/session/SessionFolderItem.tsx index 58992963..21f5078c 100644 --- a/packages/ui/src/components/session/SessionFolderItem.tsx +++ b/packages/ui/src/components/session/SessionFolderItem.tsx @@ -346,9 +346,11 @@ const SessionFolderItemBase = ({ {subFolderItems} {/* Then sessions */} {sessions.length > 0 ? ( - sessions.map((node) => - renderSessionNode(node, 0, groupDirectory ?? null, projectId ?? null, archivedBucket, undefined, 'project', getRenderExtras?.(node)), - ) +
+ {sessions.map((node) => + renderSessionNode(node, 0, groupDirectory ?? null, projectId ?? null, archivedBucket, undefined, 'project', getRenderExtras?.(node)), + )} +
) : !subFolderItems ? (
{t('sessions.sidebar.folderItem.emptyFolder')} From 23928d342ce14c3e25b6b65c5109b4a1556a124a Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sat, 22 Aug 2026 00:36:25 +0300 Subject: [PATCH 010/157] feat(dictation): transcribe after recording instead of live Parakeet is an offline model trained on whole utterances, so re-decoding the growing buffer to animate a live transcript cost O(n^2) work for a result the final decode replaced. Sessions now decode once per committed segment, and the composer shows a scrolling waveform of the mic level instead of running text. Long dictations split at a pause once past 60s (hard cap 90s) instead of on a blind 15s timer, so cuts no longer land mid-word. Committed segments decode while the user is still speaking: a 185s dictation returns 4.1s after stop instead of 11.0s, with identical text (816 vs 817 words). Also fixes two ways the stream manager could silently drop transcribed audio. It now counts the commits it issued instead of trusting the session's echoed events, so a commit still in flight when the client finishes can no longer be left out of the final text. And segment byte/peak accounting is reset where the commit is issued rather than when the event arrives, which could mistake the tail of a dictation for silence and clear it. --- CHANGELOG.md | 1 + .../dictation/ComposerDictation.tsx | 34 ++--- .../dictation/DictationWaveform.tsx | 130 ++++++++++++++++++ packages/ui/src/hooks/useDictation.ts | 23 ++-- .../dictation/use-dictation-audio-source.ts | 38 +++-- .../web/server/lib/dictation/DOCUMENTATION.md | 40 ++++-- .../lib/dictation/local/sherpa-recognizer.js | 113 +++++---------- .../lib/dictation/local/worker-process.js | 4 +- .../dictation/openai-compatible-session.js | 5 +- .../server/lib/dictation/stream-manager.js | 111 ++++++++++----- .../lib/dictation/stream-manager.test.js | 62 ++++++++- 11 files changed, 393 insertions(+), 168 deletions(-) create mode 100644 packages/ui/src/components/dictation/DictationWaveform.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 65659752..03cf8954 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +- **Dictation:** speech is now transcribed after you stop talking, instead of being re-guessed word by word while you speak. The offline models OpenChamber runs are built to read a whole utterance at once, so the running transcript was consistently worse than the final one. While recording, the composer shows a live waveform of your voice and a timer, then Transcribing while the text is produced. Long recordings are split at pauses in your speech rather than on a timer, so a three-minute dictation still returns a few seconds after you stop, and words are no longer cut in half at the split. - Chat: if OpenCode restarts while a response is still running, the chat now stops with an interrupted state and a notification to continue instead of hanging silently (thanks to @sum117). ## [1.19.0] - 2026-08-19 diff --git a/packages/ui/src/components/dictation/ComposerDictation.tsx b/packages/ui/src/components/dictation/ComposerDictation.tsx index c458e5d1..c66bc804 100644 --- a/packages/ui/src/components/dictation/ComposerDictation.tsx +++ b/packages/ui/src/components/dictation/ComposerDictation.tsx @@ -5,6 +5,10 @@ * area uses the same paddings/typography as the textarea and the action row * reuses the footer icon-button styling — so toggling dictation causes no * vertical shift. + * + * No text appears while recording. The server transcribes the audio once the + * user stops, so the overlay shows the recording state and then Transcribing. + * The only transcript rendered here is the salvage text of a failed dictation. */ import React from 'react'; @@ -15,6 +19,7 @@ import { useThemeSystem } from '@/contexts/useThemeSystem'; import { cn } from '@/lib/utils'; import { runtimeFetch } from '@/lib/runtime-fetch'; import { useDictation } from '@/hooks/useDictation'; +import { DictationWaveform } from '@/components/dictation/DictationWaveform'; import { isDictationCaptureSupported } from '@/lib/dictation/use-dictation-audio-source'; import { isVSCodeRuntime } from '@/lib/desktop'; import { useConfigStore } from '@/stores/useConfigStore'; @@ -50,25 +55,6 @@ const formatDuration = (seconds: number): string => { return `${mins}:${String(secs).padStart(2, '0')}`; }; -const VolumeMeter: React.FC<{ volume: number }> = ({ volume }) => { - const { currentTheme } = useThemeSystem(); - return ( - diff --git a/packages/ui/src/components/chat/work-status/DOCUMENTATION.md b/packages/ui/src/components/chat/work-status/DOCUMENTATION.md index 0d9b9712..d2a872c6 100644 --- a/packages/ui/src/components/chat/work-status/DOCUMENTATION.md +++ b/packages/ui/src/components/chat/work-status/DOCUMENTATION.md @@ -54,6 +54,11 @@ mode. It remains available on a new-session draft: when the draft targets a project or pending worktree, the panel uses that directory for project, MCP, and usage readouts before a session exists. +Managed Chats never render or warm the Project repository section. A Chat draft +also passes no fallback directory to the panel, so an active project's branch +cannot leak into the draft while directory-independent sections remain +available. + `rowRef` is a **callback ref, not an object ref**. An object ref gives no signal when the node attaches, so the measuring effect read `.current`, found nothing whenever the row mounted after the effect first ran, and only recovered on the diff --git a/packages/ui/src/components/chat/work-status/WorkStatusPanel.tsx b/packages/ui/src/components/chat/work-status/WorkStatusPanel.tsx index 3f769d58..af097a23 100644 --- a/packages/ui/src/components/chat/work-status/WorkStatusPanel.tsx +++ b/packages/ui/src/components/chat/work-status/WorkStatusPanel.tsx @@ -26,6 +26,8 @@ type Props = { /** Null on a new-session draft: repository readouts still apply. */ sessionId: string | null; directory: string | null; + /** Managed Chats have no project repository, even if another project remains active. */ + repositoryEnabled?: boolean; /** Whether the panel should currently occupy space. */ visible: boolean; /** @@ -63,7 +65,7 @@ const PANEL_TRANSITION_EASING = 'cubic-bezier(0.22, 1, 0.36, 1)'; * eat a visible slice of every row's trailing value, and the shadows already * say there is more to see. */ -export const WorkStatusPanel: React.FC = ({ sessionId, directory, visible, overlay = false }) => { +export const WorkStatusPanel: React.FC = ({ sessionId, directory, visible, repositoryEnabled = true, overlay = false }) => { const { t } = useI18n(); const setScrollTop = useUIStore((state) => state.setWorkStatusScrollTop); const setOverlayOpen = useUIStore((state) => state.setWorkStatusOverlayOpen); @@ -248,7 +250,7 @@ export const WorkStatusPanel: React.FC = ({ sessionId, directory, visible sessionId={sessionId} directory={directory} showSession={sectionVisible('session')} - showRepository={sectionVisible('repository')} + showRepository={repositoryEnabled && sectionVisible('repository')} goalRow={} /> {sectionVisible('usage') ? : null} From 4078deb90a2573452342e4efea497420e1c288a8 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sat, 22 Aug 2026 01:36:12 +0300 Subject: [PATCH 014/157] fix(desktop): align Windows close button chrome --- packages/ui/src/components/desktop/WindowsWindowControls.tsx | 5 ++++- packages/ui/src/components/layout/Header.tsx | 3 ++- packages/ui/src/components/mini-chat/MiniChatLayout.tsx | 3 ++- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/ui/src/components/desktop/WindowsWindowControls.tsx b/packages/ui/src/components/desktop/WindowsWindowControls.tsx index 8c8e88c5..4d257e30 100644 --- a/packages/ui/src/components/desktop/WindowsWindowControls.tsx +++ b/packages/ui/src/components/desktop/WindowsWindowControls.tsx @@ -205,7 +205,10 @@ export const WindowsWindowControls = React.memo(function WindowsWindowControls({ @@ -1782,50 +1948,71 @@ export const RemoteInstancesPage: React.FC = () => { contentClassName="space-y-2.5" > {isLoading ? ( -

{t('settings.remoteInstances.page.import.loading')}

+

{t('settings.remoteInstances.page.state.loadingInstances')}

) : instances.length === 0 ? ( -

{t('settings.remoteInstances.page.import.noneFound')}

+

+ {importCandidates.length === 1 + ? t('settings.remoteInstances.page.empty.noInstancesWithOneImport') + : importCandidates.length > 1 + ? t('settings.remoteInstances.page.empty.noInstancesWithImports', { count: importCandidates.length }) + : t('settings.remoteInstances.page.empty.noInstances')} +

) : instances.map((instance) => { const instanceStatus = statusesById[instance.id]; const title = instance.nickname?.trim() || instance.sshParsed?.destination || instance.id; const phase = instanceStatus?.phase; const ready = phase === 'ready'; + const state = instanceState(phase); + const failureDetail = state === 'error' ? instanceStatus?.detail : undefined; return ( -
-
-
- -

{title}

+
+
+
+
+ +

{title}

+
+

+ {t(instanceStateLabelKey(state))} + {state === 'connecting' ? ` · ${t(phaseLabelKey(phase))}` : ''} + {ready && instanceStatus?.localUrl ? ` · ${instanceStatus.localUrl}` : ''} +

+
+
+ {ready ? ( + + ) : null} + + +
-

- {t(phaseLabelKey(phase))}{instanceStatus?.localUrl ? ` · ${instanceStatus.localUrl}` : ''} -

-
-
- - -
+ {failureDetail ? ( +

{failureDetail}

+ ) : null}
); })} @@ -1835,52 +2022,70 @@ export const RemoteInstancesPage: React.FC = () => { {t('settings.remoteInstances.sidebar.actions.addSshInstance')} - {t('settings.remoteInstances.page.section.instanceDescription')} + {t('settings.remoteInstances.page.addDialog.description')} -
{ event.preventDefault(); void createSshInstanceFromDialog(); }}> - setSshNameDraft(event.target.value)} placeholder={t('settings.remoteInstances.page.field.nicknamePlaceholder')} disabled={isSaving} /> - setSshCommandDraft(event.target.value)} placeholder={t('settings.remoteInstances.page.field.sshCommandPlaceholder')} disabled={isSaving} autoFocus /> -
- - + + {sshAddMode === 'saved' ? ( +
+ setSshHostSearch(event.target.value)} + placeholder={t('settings.remoteInstances.page.addDialog.searchPlaceholder')} + autoFocus + /> + {isImportsLoading ? ( +

{t('settings.remoteInstances.page.import.loading')}

+ ) : importCandidates.length === 0 ? ( +

{t('settings.remoteInstances.page.addDialog.emptySaved')}

+ ) : filteredImportCandidates.length === 0 ? ( +

{t('settings.remoteInstances.page.addDialog.searchEmpty')}

+ ) : ( +
+ {filteredImportCandidates.map((candidate) => ( +
+
+
+ {candidate.host} + {candidate.pattern ? ` ${t('settings.remoteInstances.page.import.patternSuffix')}` : ''} +
+
{candidate.sshCommand}
+
+ +
+ ))} +
+ )}
- + ) : ( +
{ event.preventDefault(); void createSshInstanceFromDialog(); }}> + setSshNameDraft(event.target.value)} placeholder={t('settings.remoteInstances.page.field.nicknamePlaceholder')} disabled={isSaving} /> + setSshCommandDraft(event.target.value)} placeholder={t('settings.remoteInstances.page.field.sshCommandPlaceholder')} disabled={isSaving} autoFocus /> +
+ + +
+
+ )} : null} - {showInstanceManagement ? - {isImportsLoading ? ( -

{t('settings.remoteInstances.page.import.loading')}

- ) : importCandidates.length === 0 ? ( -

{t('settings.remoteInstances.page.import.noneFound')}

- ) : ( -
- {importCandidates.map((candidate) => ( -
-
-
- {candidate.host} - {candidate.pattern ? ` ${t('settings.remoteInstances.page.import.patternSuffix')}` : ''} -
-
{candidate.sshCommand}
-
- -
- ))} -
- )} -
: null} - { @@ -1925,6 +2130,10 @@ export const RemoteInstancesPage: React.FC = () => { } const isManagedMode = draft.remoteOpenchamber.mode === 'managed'; + // Publishing the remote server to its network turns the UI password from an + // option into the only thing standing in front of it. + const remoteLanExposed = isManagedMode && draft.remoteOpenchamber.bindHost === '0.0.0.0'; + const uiPasswordMissing = remoteLanExposed && !draft.auth.openchamberPassword?.value?.trim(); const instanceTitle = draft.nickname?.trim() || draft.sshParsed?.destination || draft.id; return ( @@ -1934,7 +2143,8 @@ export const RemoteInstancesPage: React.FC = () => {

{instanceTitle}

- {t(phaseLabelKey(statusPhase))} + {t(instanceStateLabelKey(currentState))} + {currentState === 'connecting' ? {t(phaseLabelKey(statusPhase))} : null} {status?.localUrl ? {status.localUrl} : null} {reconnectAppearsStuck ? {t('settings.remoteInstances.page.status.reconnectStale')} : null}
@@ -2005,6 +2215,29 @@ export const RemoteInstancesPage: React.FC = () => { {t('settings.remoteInstances.sidebar.actions.remove')}
+ {currentState === 'error' && status?.detail ? ( +
+

{status.detail}

+ {currentRemedyHintKey ? ( +

{t(currentRemedyHintKey)}

+ ) : null} + {currentRemedy && !currentRemedyHintKey ? ( + + ) : null} +
+ ) : null} {status?.localUrl ? (
{t('settings.remoteInstances.page.status.currentLocalUrl')} @@ -2046,30 +2279,6 @@ export const RemoteInstancesPage: React.FC = () => { placeholder={t('settings.remoteInstances.page.field.nicknamePlaceholder')} />
-
- {t('settings.remoteInstances.page.field.connectionTimeoutSeconds')} - { - updateDraft((current) => ({ - ...current, - connectionTimeoutSec: Number.isFinite(next) ? next : current.connectionTimeoutSec, - })); - }} - /> -
- - -
{
+ + + + + {t('settings.remoteInstances.page.section.advanced')} + + + +

{t('settings.remoteInstances.page.section.advancedHint')}

+
+ {t('settings.remoteInstances.page.field.connectionTimeoutSeconds')} + { + updateDraft((current) => ({ + ...current, + connectionTimeoutSec: Number.isFinite(next) ? next : current.connectionTimeoutSec, + })); + }} + /> +
+ +
-
+
{ ...current, remoteOpenchamber: { ...current.remoteOpenchamber, - installMethod: - value === 'npm' || value === 'download_release' || value === 'upload_bundle' - ? value - : 'bun', + installMethod: value === 'npm' || value === 'bun' ? value : 'auto', }, })) } @@ -2162,15 +2400,45 @@ export const RemoteInstancesPage: React.FC = () => { + {t('settings.remoteInstances.page.field.installMethodAuto')} bun npm - {t('settings.remoteInstances.page.field.installMethodDownloadRelease')} - {t('settings.remoteInstances.page.field.installMethodUploadBundle')}
) : null} + {isManagedMode ? ( +
+
+
+ +
+ + updateDraft((current) => ({ + ...current, + remoteOpenchamber: { + ...current.remoteOpenchamber, + bindHost: checked ? '0.0.0.0' : '127.0.0.1', + }, + })) + } + aria-label={t('settings.remoteInstances.page.field.remoteLanAccess')} + /> +
+ {remoteLanExposed ? ( +

+ {t('settings.remoteInstances.page.field.remoteLanAccessWarning')} +

+ ) : null} +
+ ) : null} + {isManagedMode ? (
@@ -2227,13 +2495,13 @@ export const RemoteInstancesPage: React.FC = () => { })); }} > - + - 127.0.0.1 - localhost - 0.0.0.0 + {t('settings.remoteInstances.page.field.bindHostOption.loopback')} + {t('settings.remoteInstances.page.field.bindHostOption.localhost')} + {t('settings.remoteInstances.page.field.bindHostOption.lan')}
@@ -2293,6 +2561,13 @@ export const RemoteInstancesPage: React.FC = () => {
+ +
+

{t('settings.remoteInstances.page.tunnelPreview.caption')}

+

+ {`${draft.localForward.bindHost}:${draft.localForward.preferredLocalPort || 'auto'} → ${draft.sshParsed?.destination || draft.nickname || 'remote'}:${draft.remoteOpenchamber.preferredPort || 'auto'}`} +

+
{ contentClassName="space-y-3" >
- {t('settings.remoteInstances.page.field.sshPasswordOptional')} +
+ +
{
- {t('settings.remoteInstances.page.field.uiPasswordOptional')} +
+ +
updateDraft((current) => ({ @@ -2345,6 +2636,11 @@ export const RemoteInstancesPage: React.FC = () => { placeholder={t('settings.remoteInstances.page.field.uiPasswordPlaceholder')} />
+ {uiPasswordMissing ? ( +

+ {t('settings.remoteInstances.page.field.uiPasswordMissingForLan')} +

+ ) : null}
{ + + +
+ ) : ( + + +

+ {title} +

+
+ )} +
+ {actions} +
+ {children ? ( + <> + {children} +
+ + ) : null} +
+
+); + +const BtwSheet: React.FC<{ + sessionRef: BtwSessionRef; + title: string; + boundaryMessageID: string | null; + collapsed: boolean; +}> = ({ sessionRef, title, boundaryMessageID, collapsed }) => { + const { t } = useI18n(); + const handleDestroy = useBtwDestroy(sessionRef); + const setCollapsed = React.useCallback((next: boolean) => { + useBtwStore.getState().setPanelState(sessionRef.parentSessionId, { collapsed: next }); + }, [sessionRef.parentSessionId]); + const handleToggleCollapsed = React.useCallback(() => setCollapsed(!collapsed), [collapsed, setCollapsed]); + const handleCollapse = React.useCallback(() => setCollapsed(true), [setCollapsed]); + const handlePromote = React.useCallback(() => { + void promoteBtwSession(sessionRef).catch(() => { + toast.error(t('chat.btw.toast.promoteFailed')); + }); + }, [sessionRef, t]); + useEscapeToCollapse(handleCollapse); + + const toggleLabel = collapsed ? t('chat.btw.expandAria') : t('chat.btw.collapseAria'); + const headerButtonClass = 'size-7 rounded-lg text-muted-foreground transition-colors hover:text-foreground hover:!bg-transparent active:!bg-transparent'; + const actions = ( +
+ + +
+ ); + + if (collapsed) { + return ( + + ); + } + + return ( + + ); +}; + +/** + * Collapsed mode: only the header strip stays docked above the composer. The + * fork keeps running in the background; a spinner replaces the header icon + * while it is busy so activity stays visible without the message list. + */ +const BtwCollapsedStrip: React.FC<{ + sessionRef: BtwSessionRef; + title: string; + actions: React.ReactNode; + onExpand: () => void; + expandLabel: string; +}> = ({ sessionRef, title, actions, onExpand, expandLabel }) => { + const status = useSessionStatus(sessionRef.btwSessionId, sessionRef.directory) ?? IDLE_SESSION_STATUS; + const isBusy = status.type === 'busy' || status.type === 'retry'; + return ( + + ); +}; + +const BtwExpandedSheet: React.FC<{ + sessionRef: BtwSessionRef; + title: string; + boundaryMessageID: string | null; + actions: React.ReactNode; + onTitleClick: () => void; + titleClickLabel: string; +}> = ({ sessionRef, title, boundaryMessageID, actions, onTitleClick, titleClickLabel }) => { + const data = useBtwSessionData(sessionRef.btwSessionId, sessionRef.directory, boundaryMessageID); + const bodyRef = React.useRef(null); + const contentRef = React.useRef(null); + const handleBodyScroll = useAutoScroll(bodyRef, contentRef, !data.isEmpty); + // With the on-screen keyboard open the composer (this panel's anchor) + // rises, and a vh-based cap would push the panel under the app header. + // Same protection as the composer autocomplete popups: clamp the scroll + // body to the space actually available above the anchor. The hook measures + // room for the scroll body itself, but the panel header and bottom spacer + // sit inside the same frame above/below it — reserve their height too. + const BTW_FRAME_CHROME_PX = 48; + const availableMaxHeight = useMobileAutocompleteMaxHeight(bodyRef, true, 520 + BTW_FRAME_CHROME_PX); + const mobileMaxHeight = availableMaxHeight !== undefined + ? Math.max(120, availableMaxHeight - BTW_FRAME_CHROME_PX) + : undefined; + + return ( + + + + + + ); +}; + +const BtwMessages: React.FC<{ + data: BtwSessionData; + bodyRef: React.RefObject; + contentRef: React.RefObject; + onBodyScroll: (event: React.UIEvent) => void; + maxHeight?: number; +}> = ({ data, bodyRef, contentRef, onBodyScroll, maxHeight }) => { + const { t } = useI18n(); + + if (data.isEmpty) { + return ( +
+ + {t('chat.btw.loading')} +
+ ); + } + + return ( + +
+ {data.messageRecords.map((record, index) => ( + + ))} + {data.sessionQuestions.length > 0 || data.sessionPermissions.length > 0 ? ( +
+ {data.sessionQuestions.map((question) => ( + + ))} + {data.sessionPermissions.map((permission) => ( + + ))} +
+ ) : null} + {/* Always reserve this row so the content does not shift down + by a line when the indicator disappears. */} +
+ + {t('chat.btw.working')} +
+
+
+ ); +}; diff --git a/packages/ui/src/components/chat/btw/useBtwPanelState.ts b/packages/ui/src/components/chat/btw/useBtwPanelState.ts new file mode 100644 index 00000000..6d36f060 --- /dev/null +++ b/packages/ui/src/components/chat/btw/useBtwPanelState.ts @@ -0,0 +1,54 @@ +import React from 'react'; +import type { Session } from '@opencode-ai/sdk/v2'; +import { useSession } from '@/sync/sync-context'; +import { getBtwBoundaryMessageID, getBtwSessionID } from '@/lib/sessionBtwMetadata'; +import { useBtwStore } from '@/stores/useBtwStore'; + +export type BtwPanelState = { + /** The active fork for this parent, or null when no panel should exist. */ + btwSessionId: string | null; + btwSession: Session | null; + /** The fork's directory identity (may be canonicalized by the server). */ + btwDirectory: string | null; + /** Last message id inherited from the parent; the panel shows what's after it. */ + boundaryMessageID: string | null; + collapsed: boolean; + creating: boolean; +}; + +/** + * Derive the `/btw` panel identity for one parent session from authoritative + * session metadata (`openchamber.btwSessionID`), plus the transient UI state + * kept in `useBtwStore`. The panel exists only while the parent's link AND the + * fork itself are present in the live stores, so a fork deleted anywhere + * (sidebar, another client) makes the panel disappear without extra tracking. + */ +export function useBtwPanelState( + parentSessionId: string | null | undefined, + directory: string | undefined, +): BtwPanelState { + const parentSession = useSession(parentSessionId, directory); + const linkedBtwSessionId = getBtwSessionID(parentSession); + const btwSession = useSession(linkedBtwSessionId, directory) ?? null; + const uiState = useBtwStore( + React.useCallback( + (s) => (parentSessionId ? s.byParent[parentSessionId] : undefined), + [parentSessionId], + ), + ); + + const destroying = Boolean(uiState?.destroying); + const btwSessionId = btwSession && !destroying ? linkedBtwSessionId : null; + return { + btwSessionId, + btwSession: btwSessionId ? btwSession : null, + // SAFETY: the SDK Session type omits the server's `directory` field; this + // widening only reads it, with the parent's directory as the fallback. + btwDirectory: btwSessionId + ? ((btwSession as (Session & { directory?: string | null }) | null)?.directory ?? directory ?? null) + : null, + boundaryMessageID: btwSessionId ? getBtwBoundaryMessageID(btwSession) : null, + collapsed: Boolean(uiState?.collapsed), + creating: Boolean(uiState?.creating), + }; +} diff --git a/packages/ui/src/components/chat/chatSurfaceContextValue.ts b/packages/ui/src/components/chat/chatSurfaceContextValue.ts index 30065ad0..74470c17 100644 --- a/packages/ui/src/components/chat/chatSurfaceContextValue.ts +++ b/packages/ui/src/components/chat/chatSurfaceContextValue.ts @@ -1,5 +1,11 @@ import React from 'react'; -export type ChatSurfaceMode = 'default' | 'mini-chat'; +/** + * 'mini-chat' is the browser-panel side chat (compact, no fork/plan actions). + * 'peek' is a read-only glance surface (the /btw panel): messages render with + * no per-message controls at all — no user action row, no assistant action + * buttons, no turn footer. + */ +export type ChatSurfaceMode = 'default' | 'mini-chat' | 'peek'; export const ChatSurfaceContext = React.createContext('default'); diff --git a/packages/ui/src/components/chat/message/MessageBody.tsx b/packages/ui/src/components/chat/message/MessageBody.tsx index c6c09f46..dc870847 100644 --- a/packages/ui/src/components/chat/message/MessageBody.tsx +++ b/packages/ui/src/components/chat/message/MessageBody.tsx @@ -567,7 +567,7 @@ const UserMessageBody = React.memo(({ messageId, parts, messageCreatedAt, isMobi const formatted = formatTimestampForDisplay(messageCreatedAt, timeFormatPreference); return formatted.length > 0 ? formatted : null; }, [locale, messageCreatedAt, timeFormatPreference]); - const actionsBlock = ((canCopyMessage && hasCopyableText) || onRevert || effectiveOnFork || onToggleContextPin) && showUserActions ? ( + const actionsBlock = chatSurfaceMode !== 'peek' && ((canCopyMessage && hasCopyableText) || onRevert || effectiveOnFork || onToggleContextPin) && showUserActions ? (
= ({ const merged = mergeSidebarSessionSources(globalActiveSessions, liveFallbackSessions); return merged.filter((session) => ( - (!isVSCode && isChatDirectoryPath(session.directory)) - || isKnownActiveSessionDirectory(session, knownSessionDirectories, { - allowUnknownDirectory: !isVSCode, - allowEmptyDirectorySet: !isVSCode, - }) + // btw forks stay hidden until promoted to a full session + !isBtwSession(session) + && ( + (!isVSCode && isChatDirectoryPath(session.directory)) + || isKnownActiveSessionDirectory(session, knownSessionDirectories, { + allowUnknownDirectory: !isVSCode, + allowEmptyDirectorySet: !isVSCode, + }) + ) )); }, [globalActiveSessions, isVSCode, knownSessionDirectories, liveFallbackSessions]); diff --git a/packages/ui/src/components/session/sidebar/hooks/useSwitcherItems.ts b/packages/ui/src/components/session/sidebar/hooks/useSwitcherItems.ts index 42e3e05b..8e0f5e77 100644 --- a/packages/ui/src/components/session/sidebar/hooks/useSwitcherItems.ts +++ b/packages/ui/src/components/session/sidebar/hooks/useSwitcherItems.ts @@ -2,6 +2,7 @@ import React from 'react'; import type { Session } from '@opencode-ai/sdk/v2'; import { useGlobalSessionsStore, resolveGlobalSessionDirectory } from '@/stores/useGlobalSessionsStore'; +import { isBtwSession } from '@/lib/sessionBtwMetadata'; import { useProjectsStore } from '@/stores/useProjectsStore'; import { useSessionPinnedStore } from '@/stores/useSessionPinnedStore'; import { useGitAllBranches } from '@/stores/useGitStore'; @@ -117,6 +118,8 @@ export const useSwitcherItems = (enabled: boolean, options: SwitcherItemsOptions const parents = activeSessions .filter((session) => !session.time?.archived) + // btw forks stay hidden until promoted to a full session + .filter((session) => !isBtwSession(session)) .filter((session) => !isVSCode || !isChatDirectoryPath(resolveGlobalSessionDirectory(session))) .filter((session) => !(session as Session & { parentID?: string | null }).parentID) .filter((session) => { diff --git a/packages/ui/src/components/ui/CommandPalette.tsx b/packages/ui/src/components/ui/CommandPalette.tsx index d54861be..2fe8a942 100644 --- a/packages/ui/src/components/ui/CommandPalette.tsx +++ b/packages/ui/src/components/ui/CommandPalette.tsx @@ -18,6 +18,7 @@ import { import { useUIStore } from '@/stores/useUIStore'; import { useSessionUIStore } from '@/sync/session-ui-store'; import { useGlobalSessionsStore, resolveGlobalSessionDirectory } from '@/stores/useGlobalSessionsStore'; +import { isBtwSession } from '@/lib/sessionBtwMetadata'; import { useSessionPinnedStore } from '@/stores/useSessionPinnedStore'; import { EMPTY_SESSION_ORDER_RANKS, @@ -308,7 +309,9 @@ export const CommandPalette: React.FC = () => { // Sessions // --------------------------------------------------------------------------- const orderedActiveSessions = React.useMemo(() => { - return orderSessionsByLifecycleScopes(activeSessions, pinnedSessionIds, sessionOrderRanks); + // btw forks stay hidden until promoted to a full session + const visibleSessions = activeSessions.filter((session) => !isBtwSession(session)); + return orderSessionsByLifecycleScopes(visibleSessions, pinnedSessionIds, sessionOrderRanks); }, [activeSessions, pinnedSessionIds, sessionOrderRanks]); const allBranches = useGitAllBranches(); diff --git a/packages/ui/src/hooks/useSessionActivity.ts b/packages/ui/src/hooks/useSessionActivity.ts index a19eda62..0d7163fe 100644 --- a/packages/ui/src/hooks/useSessionActivity.ts +++ b/packages/ui/src/hooks/useSessionActivity.ts @@ -27,7 +27,7 @@ const IDLE_RESULT: SessionActivityResult = { * question indicator takes priority, and the send button must stay available so * the user can supersede the prompt with a new message). */ -function useSessionActivity(sessionId: string | null | undefined, directory?: string): SessionActivityResult { +export function useSessionActivity(sessionId: string | null | undefined, directory?: string): SessionActivityResult { const status = useSessionStatus(sessionId ?? '', directory); const messages = useSessionMessages(sessionId ?? '', directory); const permissions = useSessionPermissions(sessionId ?? '', directory); diff --git a/packages/ui/src/lib/btw.test.ts b/packages/ui/src/lib/btw.test.ts new file mode 100644 index 00000000..ce541989 --- /dev/null +++ b/packages/ui/src/lib/btw.test.ts @@ -0,0 +1,242 @@ +import { beforeEach, describe, expect, mock, test } from 'bun:test'; +import type { Message, Part, Session } from '@opencode-ai/sdk/v2'; + +let forkSessionImpl: (sessionId: string, messageId?: string, directory?: string | null) => Promise; +let getSessionMessagesImpl: (id: string, limit?: number, directory?: string | null) => Promise>; +let sendMessageImpl: (...args: unknown[]) => Promise; +let deleteSessionImpl: (sessionId: string) => Promise; +let updateSessionTitleImpl: (sessionId: string, title: string) => Promise; +let patchSessionMetadataImpl: ( + sessionId: string, + directory: string | null | undefined, + updater: (metadata: Record) => Record, +) => Promise; +const registeredDirectories: string[] = []; +const upsertedSessions: unknown[] = []; +const childStoreSessions: Session[] = []; +const currentSessionSwitches: string[] = []; +const metadataPatches: Array<{ sessionId: string; result: Record }> = []; + +mock.module('@/lib/opencode/client', () => ({ + opencodeClient: { + forkSession: (sessionId: string, messageId?: string, directory?: string | null) => + forkSessionImpl(sessionId, messageId, directory), + getSessionMessages: (id: string, limit?: number, directory?: string | null) => + getSessionMessagesImpl(id, limit, directory), + }, +})); +mock.module('@/sync/session-actions', () => ({ + waitForConnectionOrThrow: () => Promise.resolve(), + deleteSession: (sessionId: string) => deleteSessionImpl(sessionId), + updateSessionTitle: (sessionId: string, title: string) => updateSessionTitleImpl(sessionId, title), + patchSessionMetadata: ( + sessionId: string, + directory: string | null | undefined, + updater: (metadata: Record) => Record, + ) => patchSessionMetadataImpl(sessionId, directory, updater), +})); +mock.module('@/sync/session-ui-store', () => ({ + useSessionUIStore: { + getState: () => ({ + sendMessage: (...args: unknown[]) => sendMessageImpl(...args), + setCurrentSession: (sessionId: string) => { currentSessionSwitches.push(sessionId); }, + }), + }, +})); +mock.module('@/stores/useGlobalSessionsStore', () => ({ + useGlobalSessionsStore: { getState: () => ({ upsertSession: (session: unknown) => { upsertedSessions.push(session); } }) }, +})); +mock.module('@/sync/sync-refs', () => ({ + registerSessionDirectory: (sessionId: string, directory: string) => { registeredDirectories.push(`${sessionId}:${directory}`); }, + getSyncChildStores: () => ({ + children: new Map([['/project', { + getState: () => ({ session: childStoreSessions }), + setState: (patch: { session: Session[] }) => { childStoreSessions.length = 0; childStoreSessions.push(...patch.session); }, + }]]), + }), +})); + +const { btwSessionTitle, startBtwSession, destroyBtwSession, promoteBtwSession, filterBtwTailMessages } = + await import('@/lib/btw'); +const { useBtwStore } = await import('@/stores/useBtwStore'); + +const makeSession = (id: string, directory?: string): Session => ({ + id, + directory, + title: 'btw: q', + time: { created: Date.now(), updated: Date.now() }, + parentID: undefined, + version: 1, +}) as unknown as Session; + +const record = (id: string): { info: Message; parts: Part[] } => ({ + info: { id, role: 'user', time: { created: 1 } } as unknown as Message, + parts: [], +}); + +const startInput = { + parentSessionId: 'parent-1', + question: 'wtf is kafka', + directory: '/project', + providerID: 'provider', + modelID: 'model', + agent: 'build', + variant: 'v', +}; + +beforeEach(() => { + registeredDirectories.length = 0; + upsertedSessions.length = 0; + childStoreSessions.length = 0; + currentSessionSwitches.length = 0; + metadataPatches.length = 0; + useBtwStore.setState({ byParent: {} }); + forkSessionImpl = () => Promise.reject(new Error('no forkSession stub')); + getSessionMessagesImpl = () => Promise.resolve([record('msg-boundary')]); + sendMessageImpl = () => Promise.resolve(); + deleteSessionImpl = () => Promise.resolve(true); + updateSessionTitleImpl = () => Promise.resolve(); + patchSessionMetadataImpl = (sessionId, _directory, updater) => { + const result = updater({}); + metadataPatches.push({ sessionId, result }); + return Promise.resolve(makeSession(sessionId)); + }; +}); + +describe('btwSessionTitle', () => { + test('prefixes the question', () => { + expect(btwSessionTitle('wtf is kafka')).toBe('btw: wtf is kafka'); + }); +}); + +describe('filterBtwTailMessages', () => { + test('keeps only messages after the boundary id', () => { + const records = [record('msg-1'), record('msg-2'), record('msg-3')]; + expect(filterBtwTailMessages(records, 'msg-2').map((r) => r.info.id)).toEqual(['msg-3']); + }); + + test('a null boundary keeps everything (fork of an empty parent)', () => { + const records = [record('msg-1'), record('msg-2')]; + expect(filterBtwTailMessages(records, null)).toBe(records); + }); +}); + +describe('startBtwSession', () => { + test('forks, marks the fork, links the parent, and routes the question to the fork', async () => { + forkSessionImpl = (sessionId, messageId, directory) => { + expect(sessionId).toBe('parent-1'); + expect(messageId).toBe(undefined); + return Promise.resolve(makeSession('fork-1', directory ?? '/project')); + }; + let sentText: unknown = null; + let sentOptions: unknown = null; + sendMessageImpl = (...args) => { + sentText = args[0]; + sentOptions = args[9]; + return Promise.resolve(); + }; + + const session = await startBtwSession(startInput); + + expect(session.id).toBe('fork-1'); + expect(registeredDirectories).toEqual(['fork-1:/project']); + expect(childStoreSessions.map((s) => s.id)).toEqual(['fork-1']); + expect(sentText).toBe('wtf is kafka'); + expect(sentOptions).toEqual({ sessionId: 'fork-1', directory: '/project' }); + expect(metadataPatches).toEqual([ + { sessionId: 'fork-1', result: { openchamber: { kind: 'btw', originalSessionID: 'parent-1', btwBoundaryMessageID: 'msg-boundary' } } }, + { sessionId: 'parent-1', result: { openchamber: { btwSessionID: 'fork-1' } } }, + ]); + // Transient creating flag is cleared once the flow settles. + expect(useBtwStore.getState().byParent).toEqual({}); + }); + + test('an empty parent produces a marker without a boundary', async () => { + forkSessionImpl = () => Promise.resolve(makeSession('fork-1', '/project')); + getSessionMessagesImpl = () => Promise.resolve([]); + await startBtwSession(startInput); + expect(metadataPatches[0]?.result).toEqual({ openchamber: { kind: 'btw', originalSessionID: 'parent-1' } }); + }); + + test('a failed first send unlinks the parent and deletes the fork', async () => { + forkSessionImpl = () => Promise.resolve(makeSession('fork-1', '/project')); + sendMessageImpl = () => Promise.reject(new Error('send failed')); + const deleted: string[] = []; + deleteSessionImpl = (sessionId) => { deleted.push(sessionId); return Promise.resolve(true); }; + + await expect(startBtwSession(startInput)).rejects.toThrow('send failed'); + + expect(deleted).toEqual(['fork-1']); + // marker, link, then unlink rollback + expect(metadataPatches.map((p) => p.sessionId)).toEqual(['fork-1', 'parent-1', 'parent-1']); + expect(metadataPatches[2]?.result).toEqual({}); + expect(useBtwStore.getState().byParent).toEqual({}); + }); + + test('a failed boundary fetch deletes the fork', async () => { + forkSessionImpl = () => Promise.resolve(makeSession('fork-1', '/project')); + getSessionMessagesImpl = () => Promise.reject(new Error('messages failed')); + const deleted: string[] = []; + deleteSessionImpl = (sessionId) => { deleted.push(sessionId); return Promise.resolve(true); }; + + await expect(startBtwSession(startInput)).rejects.toThrow('messages failed'); + expect(deleted).toEqual(['fork-1']); + expect(metadataPatches).toEqual([]); + }); +}); + +describe('destroyBtwSession', () => { + const ref = { parentSessionId: 'parent-1', btwSessionId: 'fork-1', directory: '/project' }; + + test('unlinks the parent and deletes the fork', async () => { + const deleted: string[] = []; + deleteSessionImpl = (sessionId) => { deleted.push(sessionId); return Promise.resolve(true); }; + expect(await destroyBtwSession(ref)).toBe(true); + expect(metadataPatches).toEqual([{ sessionId: 'parent-1', result: {} }]); + expect(deleted).toEqual(['fork-1']); + expect(useBtwStore.getState().byParent).toEqual({}); + }); + + test('reports an unconfirmed delete and still cleans UI state', async () => { + deleteSessionImpl = () => Promise.resolve(false); + expect(await destroyBtwSession(ref)).toBe(false); + expect(useBtwStore.getState().byParent).toEqual({}); + }); + + test('a failed unlink still attempts the delete', async () => { + patchSessionMetadataImpl = () => Promise.reject(new Error('patch failed')); + const deleted: string[] = []; + deleteSessionImpl = (sessionId) => { deleted.push(sessionId); return Promise.resolve(true); }; + expect(await destroyBtwSession(ref)).toBe(true); + expect(deleted).toEqual(['fork-1']); + }); +}); + +describe('promoteBtwSession', () => { + const ref = { parentSessionId: 'parent-1', btwSessionId: 'fork-1', directory: '/project' }; + + test('unlinks the parent, strips the marker, and navigates to the fork', async () => { + patchSessionMetadataImpl = (sessionId, _directory, updater) => { + const base = sessionId === 'fork-1' + ? { openchamber: { kind: 'btw', originalSessionID: 'parent-1', btwBoundaryMessageID: 'msg-1' } } + : { openchamber: { btwSessionID: 'fork-1' } }; + const result = updater(base); + metadataPatches.push({ sessionId, result }); + return Promise.resolve(makeSession(sessionId)); + }; + + await promoteBtwSession(ref); + + expect(metadataPatches).toEqual([ + { sessionId: 'parent-1', result: {} }, + { sessionId: 'fork-1', result: {} }, + ]); + expect(currentSessionSwitches).toEqual(['fork-1']); + }); + + test('a failed unlink aborts the promote without navigating', async () => { + patchSessionMetadataImpl = () => Promise.reject(new Error('patch failed')); + await expect(promoteBtwSession(ref)).rejects.toThrow('patch failed'); + expect(currentSessionSwitches).toEqual([]); + }); +}); diff --git a/packages/ui/src/lib/btw.ts b/packages/ui/src/lib/btw.ts new file mode 100644 index 00000000..9ba9068e --- /dev/null +++ b/packages/ui/src/lib/btw.ts @@ -0,0 +1,170 @@ +import type { Message, Part, Session } from '@opencode-ai/sdk/v2'; +import { opencodeClient } from '@/lib/opencode/client'; +import * as sessionActions from '@/sync/session-actions'; +import { withBtwSessionLink, withBtwSessionMarker, withoutBtwSessionLink, withoutBtwSessionMarker } from '@/lib/sessionBtwMetadata'; +import { useBtwStore } from '@/stores/useBtwStore'; +import { useSessionUIStore } from '@/sync/session-ui-store'; +import { getSyncChildStores, registerSessionDirectory } from '@/sync/sync-refs'; +import { Binary } from '@/sync/binary'; + +/** + * `/btw `: fork the main session into a temporary session and send + * the question there. + * + * A fork (not an empty child) gives the agent the full inherited conversation + * as its window context. The fork is created through the SDK directly (like + * reviewFlow) so the main chat's `currentSessionId` is never switched; the + * prompt is routed to the fork with `SendMessageOptions.sessionId`. + * + * The parent session's metadata carries `openchamber.btwSessionID` (see + * `sessionBtwMetadata`), so the panel belongs to the parent session alone, + * follows the user as they navigate between sessions, and survives reloads. + */ +export type StartBtwInput = { + parentSessionId: string; + question: string; + directory: string; + providerID: string; + modelID: string; + agent?: string; + variant?: string; +}; + +export const btwSessionTitle = (question: string): string => `btw: ${question}`; + +/** + * Insert the fork into its directory child store so the sidebar picks it up + * immediately, mirroring `forkFromMessage` in session-actions. + */ +function insertForkIntoDirectoryStore(session: Session, directory: string): void { + const store = getSyncChildStores().children.get(directory); + if (!store) return; + const current = store.getState(); + const sessions = [...current.session]; + const searchResult = Binary.search(sessions, session.id, (s) => s.id); + if (!searchResult.found) { + sessions.splice(searchResult.index, 0, session); + store.setState({ session: sessions }); + } +} + +export async function startBtwSession(input: StartBtwInput): Promise { + const { setPanelState, clearPanelState } = useBtwStore.getState(); + setPanelState(input.parentSessionId, { creating: true }); + try { + await sessionActions.waitForConnectionOrThrow(); + const forked = await opencodeClient.forkSession(input.parentSessionId, undefined, input.directory); + + // The server may canonicalize the worktree path; the prompt must use the + // same directory identity as the forked session. + // SAFETY: the SDK Session type omits the server's `directory` field; this + // widening only reads it, with the requested directory as the fallback. + const sessionDirectory = (forked as Session & { directory?: string | null }).directory ?? input.directory; + registerSessionDirectory(forked.id, sessionDirectory); + + try { + // The boundary between inherited history and the fork's own tail is the + // id of the newest cloned message. Message ids are server-generated and + // ascending, so everything the fork produces sorts after it. + const newestCloned = await opencodeClient.getSessionMessages(forked.id, 1, sessionDirectory); + const boundaryMessageID = newestCloned[newestCloned.length - 1]?.info.id ?? null; + + // The fork inherits the parent's metadata and title wholesale: replace + // the metadata with the btw marker, and rename it (rename is + // best-effort — a failed rename must not fail the btw flow). + // The marker lands BEFORE the fork is inserted into local stores: btw + // forks are hidden from session lists by this marker, so inserting an + // unmarked fork first would flash it in the sidebar. + const marked = await sessionActions.patchSessionMetadata(forked.id, sessionDirectory, (metadata) => + withBtwSessionMarker(metadata, input.parentSessionId, boundaryMessageID)); + // patchSessionMetadata already upserted the marked fork into the global + // store; the directory child store still needs the explicit insert. + insertForkIntoDirectoryStore(marked, sessionDirectory); + void sessionActions.updateSessionTitle(forked.id, btwSessionTitle(input.question)).catch(() => undefined); + + // Link the parent before sending so the panel opens as soon as the + // metadata lands; the question streams into it. + await sessionActions.patchSessionMetadata(input.parentSessionId, input.directory, (metadata) => + withBtwSessionLink(metadata, forked.id)); + + try { + await useSessionUIStore.getState().sendMessage( + input.question, + input.providerID, + input.modelID, + input.agent, + [], + undefined, + undefined, + input.variant, + 'normal', + { sessionId: forked.id, directory: sessionDirectory }, + ); + } catch (error) { + // A fork without its first question is not a usable btw session: + // unlink the parent again before deleting the fork. + await sessionActions.patchSessionMetadata(input.parentSessionId, input.directory, (metadata) => + withoutBtwSessionLink(metadata, forked.id)).catch(() => undefined); + throw error; + } + } catch (error) { + await sessionActions.deleteSession(forked.id).catch(() => undefined); + throw error; + } + return forked; + } finally { + clearPanelState(input.parentSessionId); + } +} + +/** + * Keep only the fork's own tail: messages after the last message cloned from + * the parent. A `null` boundary means the fork inherited nothing. + */ +export function filterBtwTailMessages( + records: Array<{ info: Message; parts: Part[] }>, + boundaryMessageID: string | null, +): Array<{ info: Message; parts: Part[] }> { + if (!boundaryMessageID) return records; + return records.filter((record) => record.info.id > boundaryMessageID); +} + +export type BtwSessionRef = { + parentSessionId: string; + btwSessionId: string; + directory: string; +}; + +/** + * Destroy the temporary fork. The panel disappears immediately (optimistic + * `destroying` flag); the parent is unlinked and the fork deleted in the + * background. Resolves `false` when the server could not confirm deletion — + * the fork then remains in the sidebar and the caller should surface that. + */ +export async function destroyBtwSession(ref: BtwSessionRef): Promise { + const { setPanelState, clearPanelState } = useBtwStore.getState(); + setPanelState(ref.parentSessionId, { destroying: true }); + try { + // deleteSession's metadata cleanup also unlinks the parent; doing it first + // makes the panel close authoritative even if the delete then fails. + await sessionActions.patchSessionMetadata(ref.parentSessionId, ref.directory, (metadata) => + withoutBtwSessionLink(metadata, ref.btwSessionId)).catch(() => undefined); + return await sessionActions.deleteSession(ref.btwSessionId); + } finally { + clearPanelState(ref.parentSessionId); + } +} + +/** + * Keep the fork as a normal session: unlink it from the parent, drop its btw + * marker, and navigate to it. The conversation continues there as a regular + * session. + */ +export async function promoteBtwSession(ref: BtwSessionRef): Promise { + await sessionActions.patchSessionMetadata(ref.parentSessionId, ref.directory, (metadata) => + withoutBtwSessionLink(metadata, ref.btwSessionId)); + await sessionActions.patchSessionMetadata(ref.btwSessionId, ref.directory, withoutBtwSessionMarker) + .catch(() => undefined); + useBtwStore.getState().clearPanelState(ref.parentSessionId); + useSessionUIStore.getState().setCurrentSession(ref.btwSessionId); +} diff --git a/packages/ui/src/lib/i18n/messages/de.ts b/packages/ui/src/lib/i18n/messages/de.ts index 673ec2fe..6874fc8c 100644 --- a/packages/ui/src/lib/i18n/messages/de.ts +++ b/packages/ui/src/lib/i18n/messages/de.ts @@ -1938,6 +1938,7 @@ export const dict = { 'chat.commandAutocomplete.command.catchUpDescription': 'Kontext wiederherstellen: Was du getan hast und wo du weitermachen sollst.', 'chat.commandAutocomplete.command.debugDescription': 'Geführte Ursachenforschung für einen Fehler, bevor eine Lösung vorgeschlagen wird.', 'chat.commandAutocomplete.command.weighDescription': 'Zwei bis drei Ansätze mit Kompromissen und einer Empfehlung bewerten, bevor du dich entscheidest.', + 'chat.commandAutocomplete.command.btwDescription': 'Stelle eine Neben-Frage in einer temporären Kind-Sitzung, ohne diesen Chat zu unterbrechen.', 'chat.commandAutocomplete.command.exploreDescription': 'Vertraut machen mit diesem Codebase: Eine Übersicht über die Architektur und Hauptbestandteile.', 'chat.commandAutocomplete.badge.skill': 'Fähigkeit', 'chat.commandAutocomplete.badge.command': 'Befehl', @@ -1958,6 +1959,18 @@ export const dict = { 'chat.container.returnToParent.titleNamed': 'Zurück zu: {title}', 'chat.container.returnToParent.title': 'Zurück zur übergeordneten Sitzung', 'chat.container.returnToParent.label': 'Übergeordnet', + 'chat.btw.destroyAria': 'Diese btw-Sitzung löschen', + 'chat.btw.titleFallback': 'btw-Sitzung', + 'chat.btw.mainComposerPlaceholder': 'In dieser btw-Sitzung fragen…', + 'chat.btw.loading': 'btw-Sitzung wird gestartet…', + 'chat.btw.toast.emptyArgument': 'Gib eine Frage nach /btw ein', + 'chat.btw.toast.createFailed': 'Die btw-Sitzung konnte nicht gestartet werden', + 'chat.btw.toast.destroyFailed': 'Die btw-Sitzung konnte nicht gelöscht werden. Sie bleibt in der Seitenleiste.', + 'chat.btw.working': 'Arbeitet…', + 'chat.btw.collapseAria': 'btw-Panel einklappen', + 'chat.btw.expandAria': 'btw-Panel ausklappen', + 'chat.btw.promoteAria': 'Als eigene Sitzung behalten', + 'chat.btw.toast.promoteFailed': 'Die btw-Sitzung konnte nicht behalten werden', 'chat.container.readOnlySubagentPromptBanner': 'Subagent-Sitzungen können nicht abgefragt werden.', 'chat.unifiedControls.title': 'Steuerung', 'chat.unifiedControls.model.title': 'Modell', diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts index d10d992a..e48145b3 100644 --- a/packages/ui/src/lib/i18n/messages/en.ts +++ b/packages/ui/src/lib/i18n/messages/en.ts @@ -2104,6 +2104,7 @@ export const dict = { 'chat.commandAutocomplete.command.debugDescription': 'Guided root-cause investigation for a bug before proposing a fix.', 'chat.commandAutocomplete.command.weighDescription': 'Weigh 2-3 approaches with trade-offs and a recommendation before you commit.', 'chat.commandAutocomplete.command.exploreDescription': 'Get oriented in this codebase: a high-level tour of the architecture and main parts.', + 'chat.commandAutocomplete.command.btwDescription': 'Ask a side question in a temporary child session without derailing this chat.', 'chat.commandAutocomplete.badge.skill': 'skill', 'chat.commandAutocomplete.badge.command': 'command', 'chat.commandAutocomplete.badge.system': 'system', @@ -2124,6 +2125,18 @@ export const dict = { 'chat.container.returnToParent.title': 'Return to parent session', 'chat.container.returnToParent.label': 'Parent', 'chat.container.readOnlySubagentPromptBanner': 'Subagent sessions cannot be prompted.', + 'chat.btw.destroyAria': 'Destroy this btw session', + 'chat.btw.titleFallback': 'btw session', + 'chat.btw.mainComposerPlaceholder': 'Ask in this btw session…', + 'chat.btw.loading': 'Starting btw session…', + 'chat.btw.toast.emptyArgument': 'Type a question after /btw', + 'chat.btw.toast.createFailed': 'Failed to start the btw session', + 'chat.btw.toast.destroyFailed': 'Failed to destroy the btw session. It will remain in the sidebar.', + 'chat.btw.working': 'Working…', + 'chat.btw.collapseAria': 'Collapse the btw panel', + 'chat.btw.expandAria': 'Expand the btw panel', + 'chat.btw.promoteAria': 'Keep as a separate session', + 'chat.btw.toast.promoteFailed': 'Failed to keep the btw session', 'chat.container.sessionLoadError.title': 'Session could not be loaded', 'chat.container.sessionLoadError.description': 'Check the connection and try loading this session again.', 'chat.container.sessionLoadError.retry': 'Try again', diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts index 344c4eed..79625f08 100644 --- a/packages/ui/src/lib/i18n/messages/es.ts +++ b/packages/ui/src/lib/i18n/messages/es.ts @@ -2081,6 +2081,7 @@ export const dict: Record = { "chat.commandAutocomplete.command.catchUpDescription": "Recupera el contexto: qué estabas haciendo y por dónde continuar.", "chat.commandAutocomplete.command.debugDescription": "Investigación guiada de la causa raíz de un error antes de proponer una solución.", "chat.commandAutocomplete.command.weighDescription": "Compara 2-3 enfoques con sus ventajas y desventajas y una recomendación antes de decidir.", + 'chat.commandAutocomplete.command.btwDescription': 'Haz una pregunta paralela en una sesión hija temporal sin desviar este chat.', "chat.commandAutocomplete.command.exploreDescription": "Oriéntate en este código: un recorrido general de la arquitectura y las partes principales.", "chat.commandAutocomplete.badge.skill": "habilidad", "chat.commandAutocomplete.badge.command": "comando", @@ -2101,6 +2102,18 @@ export const dict: Record = { "chat.container.returnToParent.titleNamed": "Volver a: {title}", "chat.container.returnToParent.title": "Volver a la sesión principal", "chat.container.returnToParent.label": "Principal", + 'chat.btw.destroyAria': 'Destruir esta sesión btw', + 'chat.btw.titleFallback': 'sesión btw', + 'chat.btw.mainComposerPlaceholder': 'Pregunta en esta sesión btw…', + 'chat.btw.loading': 'Iniciando sesión btw…', + 'chat.btw.toast.emptyArgument': 'Escribe una pregunta después de /btw', + 'chat.btw.toast.createFailed': 'No se pudo iniciar la sesión btw', + 'chat.btw.toast.destroyFailed': 'No se pudo destruir la sesión btw. Permanecerá en la barra lateral.', + 'chat.btw.working': 'Trabajando…', + 'chat.btw.collapseAria': 'Contraer el panel btw', + 'chat.btw.expandAria': 'Expandir el panel btw', + 'chat.btw.promoteAria': 'Conservar como sesión aparte', + 'chat.btw.toast.promoteFailed': 'No se pudo conservar la sesión btw', "chat.container.readOnlySubagentPromptBanner": "Las sesiones de subagentes no pueden recibir prompts.", "chat.container.sessionLoadError.title": "No se pudo cargar la sesión", "chat.container.sessionLoadError.description": "Comprueba la conexión e intenta cargar esta sesión de nuevo.", diff --git a/packages/ui/src/lib/i18n/messages/fr.ts b/packages/ui/src/lib/i18n/messages/fr.ts index 14973f40..fd989b8f 100644 --- a/packages/ui/src/lib/i18n/messages/fr.ts +++ b/packages/ui/src/lib/i18n/messages/fr.ts @@ -1855,6 +1855,18 @@ export const dict = { 'chat.container.returnToParent.titleNamed': 'Retourner à : {title}', 'chat.container.returnToParent.title': 'Retour à la session parents', 'chat.container.returnToParent.label': 'Mère', + 'chat.btw.destroyAria': 'Détruire cette session btw', + 'chat.btw.titleFallback': 'session btw', + 'chat.btw.mainComposerPlaceholder': 'Poser une question dans cette session btw…', + 'chat.btw.loading': 'Démarrage de la session btw…', + 'chat.btw.toast.emptyArgument': 'Saisissez une question après /btw', + 'chat.btw.toast.createFailed': 'Échec du démarrage de la session btw', + 'chat.btw.toast.destroyFailed': 'Échec de la suppression de la session btw. Elle restera dans la barre latérale.', + 'chat.btw.working': 'En cours…', + 'chat.btw.collapseAria': 'Réduire le panneau btw', + 'chat.btw.expandAria': 'Développer le panneau btw', + 'chat.btw.promoteAria': 'Conserver comme session à part', + 'chat.btw.toast.promoteFailed': 'Échec de la conservation de la session btw', 'chat.container.readOnlySubagentPromptBanner': 'Les sessions de sous-agent ne peuvent pas être invitées.', 'chat.container.sessionLoadError.title': 'Impossible de charger la session', 'chat.container.sessionLoadError.description': 'Vérifiez la connexion et essayez de charger à nouveau cette session.', @@ -3021,6 +3033,7 @@ export const dict = { 'chat.commandAutocomplete.command.catchUpDescription': 'Rétablir le contexte : ce que vous faisiez et où reprendre.', 'chat.commandAutocomplete.command.debugDescription': 'Investigation guidée de la cause racine d’un bug avant de proposer une correction.', 'chat.commandAutocomplete.command.weighDescription': 'Comparer 2 à 3 approches avec compromis et recommandation avant de vous engager.', + 'chat.commandAutocomplete.command.btwDescription': 'Posez une question annexe dans une session enfant temporaire sans interrompre cette conversation.', 'chat.commandAutocomplete.command.exploreDescription': 'Vous orienter dans ce codebase : tour d’ensemble de l’architecture et des parties principales.', 'chat.questionCard.submitFailed': 'Impossible d’envoyer la réponse', 'chat.questionCard.dismissFailed': 'Impossible d’ignorer la question', diff --git a/packages/ui/src/lib/i18n/messages/ja.ts b/packages/ui/src/lib/i18n/messages/ja.ts index bef4e313..f233371b 100644 --- a/packages/ui/src/lib/i18n/messages/ja.ts +++ b/packages/ui/src/lib/i18n/messages/ja.ts @@ -2099,6 +2099,7 @@ export const dict: Record = { 'chat.commandAutocomplete.command.catchUpDescription': 'コンテキストを再確立: 何をしていたか、どこから再開するか。', 'chat.commandAutocomplete.command.debugDescription': '修正を提案する前に、バグのガイド付き根本原因調査。', 'chat.commandAutocomplete.command.weighDescription': 'トレードオフと推奨事項を含む2~3のアプローチを比較検討してからコミット。', + 'chat.commandAutocomplete.command.btwDescription': 'このチャットを乱さず、一時的な子セッションで脇の質問をする', 'chat.commandAutocomplete.command.exploreDescription': 'このコードベースに慣れる: アーキテクチャと主要部分の概要ツアー。', 'chat.commandAutocomplete.badge.skill': 'スキル', 'chat.commandAutocomplete.badge.command': 'コマンド', @@ -2119,6 +2120,18 @@ export const dict: Record = { 'chat.container.returnToParent.titleNamed': '戻る: {title}', 'chat.container.returnToParent.title': '親セッションに戻る', 'chat.container.returnToParent.label': '親', + 'chat.btw.destroyAria': 'このbtwセッションを破棄', + 'chat.btw.titleFallback': 'btwセッション', + 'chat.btw.mainComposerPlaceholder': 'このbtwセッションで質問する…', + 'chat.btw.loading': 'btwセッションを開始中…', + 'chat.btw.toast.emptyArgument': '/btwの後に質問を入力してください', + 'chat.btw.toast.createFailed': 'btwセッションを開始できませんでした', + 'chat.btw.toast.destroyFailed': 'btwセッションを破棄できませんでした。サイドバーに残ります。', + 'chat.btw.working': '処理中…', + 'chat.btw.collapseAria': 'btwパネルを折りたたむ', + 'chat.btw.expandAria': 'btwパネルを展開する', + 'chat.btw.promoteAria': '独立したセッションとして保持', + 'chat.btw.toast.promoteFailed': 'btwセッションを保持できませんでした', 'chat.container.readOnlySubagentPromptBanner': 'サブエージェントセッションはプロンプトを受け付けません。', 'chat.container.sessionLoadError.title': 'セッションを読み込めませんでした', 'chat.container.sessionLoadError.description': '接続を確認して、このセッションをもう一度読み込んでください。', diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index 5e2ca75a..bfe88f96 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -2105,6 +2105,7 @@ export const dict: Record = { 'chat.commandAutocomplete.command.catchUpDescription': '맥락을 다시 파악합니다: 무엇을 하고 있었고 어디서 이어서 할지.', 'chat.commandAutocomplete.command.debugDescription': '수정안을 제시하기 전에 버그의 근본 원인을 단계적으로 조사합니다.', 'chat.commandAutocomplete.command.weighDescription': '결정하기 전에 2~3가지 접근 방식을 장단점과 함께 비교하고 추천을 제시합니다.', + 'chat.commandAutocomplete.command.btwDescription': '이 채팅을 방해하지 않고 임시 하위 세션에서 별도 질문하기', 'chat.commandAutocomplete.command.exploreDescription': '코드베이스에 대한 방향을 잡습니다: 아키텍처와 주요 부분을 한눈에 살펴봅니다.', 'chat.commandAutocomplete.badge.skill': '스킬', 'chat.commandAutocomplete.badge.command': '명령', @@ -2125,6 +2126,18 @@ export const dict: Record = { 'chat.container.returnToParent.titleNamed': '돌아가기: {title}', 'chat.container.returnToParent.title': '상위 세션으로 돌아가기', 'chat.container.returnToParent.label': '상위', + 'chat.btw.destroyAria': '이 btw 세션 삭제', + 'chat.btw.titleFallback': 'btw 세션', + 'chat.btw.mainComposerPlaceholder': '이 btw 세션에서 질문하세요…', + 'chat.btw.loading': 'btw 세션 시작 중…', + 'chat.btw.toast.emptyArgument': '/btw 뒤에 질문을 입력하세요', + 'chat.btw.toast.createFailed': 'btw 세션을 시작하지 못했습니다', + 'chat.btw.toast.destroyFailed': 'btw 세션을 삭제하지 못했습니다. 사이드바에 남아 있습니다.', + 'chat.btw.working': '작업 중…', + 'chat.btw.collapseAria': 'btw 패널 접기', + 'chat.btw.expandAria': 'btw 패널 펼치기', + 'chat.btw.promoteAria': '별도 세션으로 유지', + 'chat.btw.toast.promoteFailed': 'btw 세션을 유지하지 못했습니다', 'chat.container.readOnlySubagentPromptBanner': '하위 에이전트 세션에는 프롬프트를 보낼 수 없습니다.', 'chat.container.sessionLoadError.title': '세션을 불러올 수 없습니다', 'chat.container.sessionLoadError.description': '연결을 확인한 후 이 세션을 다시 불러오세요.', diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts index 73d8a952..c8ded4ba 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -801,6 +801,7 @@ export const dict: Record = { 'chat.commandAutocomplete.command.catchUpDescription': 'Przywróć kontekst: nad czym pracowałeś i od czego kontynuować.', 'chat.commandAutocomplete.command.debugDescription': 'Prowadzone badanie pierwotnej przyczyny błędu przed zaproponowaniem poprawki.', 'chat.commandAutocomplete.command.weighDescription': 'Rozważ 2-3 podejścia z kompromisami i rekomendacją, zanim się zdecydujesz.', + 'chat.commandAutocomplete.command.btwDescription': 'Zadaj pytanie poboczne w tymczasowej sesji potomnej, nie przerywając tego czatu.', 'chat.commandAutocomplete.command.exploreDescription': 'Zorientuj się w bazie kodu: ogólny przegląd architektury i głównych części.', 'chat.commandAutocomplete.badge.skill': 'skill', 'chat.commandAutocomplete.badge.command': 'polecenie', @@ -821,6 +822,18 @@ export const dict: Record = { 'chat.container.returnToParent.titleNamed': 'Powrót do: {title}', 'chat.container.returnToParent.title': 'Powrót do sesji nadrzędnej', 'chat.container.returnToParent.label': 'Nadrzędna', + 'chat.btw.destroyAria': 'Zniszcz tę sesję btw', + 'chat.btw.titleFallback': 'sesja btw', + 'chat.btw.mainComposerPlaceholder': 'Zadaj pytanie w tej sesji btw…', + 'chat.btw.loading': 'Uruchamianie sesji btw…', + 'chat.btw.toast.emptyArgument': 'Wpisz pytanie po /btw', + 'chat.btw.toast.createFailed': 'Nie udało się uruchomić sesji btw', + 'chat.btw.toast.destroyFailed': 'Nie udało się zniszczyć sesji btw. Pozostanie na pasku bocznym.', + 'chat.btw.working': 'Pracuje…', + 'chat.btw.collapseAria': 'Zwiń panel btw', + 'chat.btw.expandAria': 'Rozwiń panel btw', + 'chat.btw.promoteAria': 'Zachowaj jako osobną sesję', + 'chat.btw.toast.promoteFailed': 'Nie udało się zachować sesji btw', 'chat.container.readOnlySubagentPromptBanner': 'Sesje podagentów nie mogą otrzymywać promptów.', 'chat.container.sessionLoadError.title': 'Nie udało się wczytać sesji', 'chat.container.sessionLoadError.description': 'Sprawdź połączenie i spróbuj ponownie wczytać tę sesję.', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index 2fe14a2c..48235bf6 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -2081,6 +2081,7 @@ export const dict: Record = { "chat.commandAutocomplete.command.catchUpDescription": "Retome o contexto: o que você estava fazendo e por onde continuar.", "chat.commandAutocomplete.command.debugDescription": "Investigação guiada da causa raiz de um bug antes de propor uma correção.", "chat.commandAutocomplete.command.weighDescription": "Compare 2-3 abordagens com seus prós e contras e uma recomendação antes de decidir.", + 'chat.commandAutocomplete.command.btwDescription': 'Faça uma pergunta paralela em uma sessão filha temporária sem desviar este chat.', "chat.commandAutocomplete.command.exploreDescription": "Oriente-se neste código: um tour geral pela arquitetura e pelas partes principais.", "chat.commandAutocomplete.badge.skill": "habilidade", "chat.commandAutocomplete.badge.command": "comando", @@ -2101,6 +2102,18 @@ export const dict: Record = { "chat.container.returnToParent.titleNamed": "Voltar para: {title}", "chat.container.returnToParent.title": "Voltar para a sessão principal", "chat.container.returnToParent.label": "Principal", + 'chat.btw.destroyAria': 'Destruir esta sessão btw', + 'chat.btw.titleFallback': 'sessão btw', + 'chat.btw.mainComposerPlaceholder': 'Pergunte nesta sessão btw…', + 'chat.btw.loading': 'Iniciando sessão btw…', + 'chat.btw.toast.emptyArgument': 'Digite uma pergunta depois de /btw', + 'chat.btw.toast.createFailed': 'Falha ao iniciar a sessão btw', + 'chat.btw.toast.destroyFailed': 'Falha ao destruir a sessão btw. Ela permanecerá na barra lateral.', + 'chat.btw.working': 'Trabalhando…', + 'chat.btw.collapseAria': 'Recolher o painel btw', + 'chat.btw.expandAria': 'Expandir o painel btw', + 'chat.btw.promoteAria': 'Manter como sessão separada', + 'chat.btw.toast.promoteFailed': 'Falha ao manter a sessão btw', "chat.container.readOnlySubagentPromptBanner": "Sessões de subagente não podem receber prompts.", "chat.container.sessionLoadError.title": "Não foi possível carregar a sessão", "chat.container.sessionLoadError.description": "Verifique a conexão e tente carregar esta sessão novamente.", diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index d7675954..618fafa4 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -2081,6 +2081,7 @@ export const dict: Record = { "chat.commandAutocomplete.command.catchUpDescription": "Повернутись у контекст: над чим працювали і звідки продовжити.", "chat.commandAutocomplete.command.debugDescription": "Кероване дослідження першопричини бага перед тим, як пропонувати фікс.", "chat.commandAutocomplete.command.weighDescription": "Зважити 2-3 підходи з trade-offs і рекомендацією перш ніж братися до роботи.", + 'chat.commandAutocomplete.command.btwDescription': 'Поставте побічне питання в тимчасовій дочірній сесії, не відволікаючи цей чат.', "chat.commandAutocomplete.command.exploreDescription": "Зорієнтуватись у кодовій базі: високорівневий тур архітектурою й основними частинами.", "chat.commandAutocomplete.badge.skill": "навичка", "chat.commandAutocomplete.badge.command": "команда", @@ -2101,6 +2102,18 @@ export const dict: Record = { "chat.container.returnToParent.titleNamed": "Повернутися до: {title}", "chat.container.returnToParent.title": "Повернутися до батьківської сесії", "chat.container.returnToParent.label": "Батьківська", + 'chat.btw.destroyAria': 'Знищити цю сесію btw', + 'chat.btw.titleFallback': 'сесія btw', + 'chat.btw.mainComposerPlaceholder': 'Поставте питання в цій сесії btw…', + 'chat.btw.loading': 'Запуск сесії btw…', + 'chat.btw.toast.emptyArgument': 'Введіть питання після /btw', + 'chat.btw.toast.createFailed': 'Не вдалося запустити сесію btw', + 'chat.btw.toast.destroyFailed': 'Не вдалося знищити сесію btw. Вона залишиться в бічній панелі.', + 'chat.btw.working': 'Працює…', + 'chat.btw.collapseAria': 'Згорнути панель btw', + 'chat.btw.expandAria': 'Розгорнути панель btw', + 'chat.btw.promoteAria': 'Залишити як окрему сесію', + 'chat.btw.toast.promoteFailed': 'Не вдалося залишити сесію btw', "chat.container.readOnlySubagentPromptBanner": "Сесії субагентів не можна запитувати.", "chat.container.sessionLoadError.title": "Не вдалося завантажити сесію", "chat.container.sessionLoadError.description": "Перевірте з’єднання та спробуйте завантажити цю сесію ще раз.", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index 50b5164d..df7f48c5 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -2069,6 +2069,7 @@ export const dict: Record = { 'chat.commandAutocomplete.command.catchUpDescription': '重新进入上下文:你之前在做什么、从哪里继续。', 'chat.commandAutocomplete.command.debugDescription': '在提出修复方案前,引导式地排查 bug 的根本原因。', 'chat.commandAutocomplete.command.weighDescription': '在动手前,权衡 2-3 种方案的利弊并给出推荐。', + 'chat.commandAutocomplete.command.btwDescription': '在临时子会话中提问,不打断当前对话', 'chat.commandAutocomplete.command.exploreDescription': '快速熟悉这个代码库:对架构和主要部分的概览。', 'chat.commandAutocomplete.badge.skill': '技能', 'chat.commandAutocomplete.badge.command': '命令', @@ -2089,6 +2090,18 @@ export const dict: Record = { 'chat.container.returnToParent.titleNamed': '返回到:{title}', 'chat.container.returnToParent.title': '返回父会话', 'chat.container.returnToParent.label': '父级', + 'chat.btw.destroyAria': '销毁此 btw 会话', + 'chat.btw.titleFallback': 'btw 会话', + 'chat.btw.mainComposerPlaceholder': '在此 btw 会话中提问…', + 'chat.btw.loading': '正在启动 btw 会话…', + 'chat.btw.toast.emptyArgument': '在 /btw 后输入问题', + 'chat.btw.toast.createFailed': '启动 btw 会话失败', + 'chat.btw.toast.destroyFailed': '销毁 btw 会话失败。它将保留在侧边栏中。', + 'chat.btw.working': '处理中…', + 'chat.btw.collapseAria': '收起 btw 面板', + 'chat.btw.expandAria': '展开 btw 面板', + 'chat.btw.promoteAria': '保留为独立会话', + 'chat.btw.toast.promoteFailed': '保留 btw 会话失败', 'chat.container.readOnlySubagentPromptBanner': '无法向子智能体会话发送提示。', 'chat.container.sessionLoadError.title': '无法加载会话', 'chat.container.sessionLoadError.description': '请检查连接,然后重新加载此会话。', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.ts b/packages/ui/src/lib/i18n/messages/zh-TW.ts index 644b95c1..673ae58e 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts @@ -2073,6 +2073,7 @@ export const dict: Record = { 'chat.commandAutocomplete.command.catchUpDescription': '重新進入上下文:你之前在做什麼、從哪裡繼續。', 'chat.commandAutocomplete.command.debugDescription': '在提出修復方案前,引導式地排查 bug 的根本原因。', 'chat.commandAutocomplete.command.weighDescription': '在動手前,權衡 2-3 種方案的利弊並給出推薦。', + 'chat.commandAutocomplete.command.btwDescription': '在臨時子工作階段中提問,不打斷目前對話', 'chat.commandAutocomplete.command.exploreDescription': '快速熟悉這個程式碼庫:對架構和主要部分的概覽。', 'chat.commandAutocomplete.badge.skill': 'Skills', 'chat.commandAutocomplete.badge.command': '命令', @@ -2093,6 +2094,18 @@ export const dict: Record = { 'chat.container.returnToParent.titleNamed': '返回到:{title}', 'chat.container.returnToParent.title': '返回父會話', 'chat.container.returnToParent.label': '父級', + 'chat.btw.destroyAria': '銷毀此 btw 工作階段', + 'chat.btw.titleFallback': 'btw 工作階段', + 'chat.btw.mainComposerPlaceholder': '在此 btw 工作階段中提問…', + 'chat.btw.loading': '正在啟動 btw 工作階段…', + 'chat.btw.toast.emptyArgument': '在 /btw 後輸入問題', + 'chat.btw.toast.createFailed': '啟動 btw 工作階段失敗', + 'chat.btw.toast.destroyFailed': '銷毀 btw 工作階段失敗。它將保留在側邊欄中。', + 'chat.btw.working': '處理中…', + 'chat.btw.collapseAria': '收合 btw 面板', + 'chat.btw.expandAria': '展開 btw 面板', + 'chat.btw.promoteAria': '保留為獨立工作階段', + 'chat.btw.toast.promoteFailed': '保留 btw 工作階段失敗', 'chat.container.readOnlySubagentPromptBanner': '無法向子 Agent 會話傳送提示。', 'chat.container.sessionLoadError.title': '無法載入工作階段', 'chat.container.sessionLoadError.description': '請檢查連線,然後重新載入此工作階段。', diff --git a/packages/ui/src/lib/opencode/client.ts b/packages/ui/src/lib/opencode/client.ts index 37e12620..42f155c1 100644 --- a/packages/ui/src/lib/opencode/client.ts +++ b/packages/ui/src/lib/opencode/client.ts @@ -602,10 +602,11 @@ class OpencodeService { return unwrapSdkData(response, 'session.update'); } - async getSessionMessages(id: string, limit?: number): Promise<{ info: Message; parts: Part[] }[]> { + async getSessionMessages(id: string, limit?: number, directory?: string | null): Promise<{ info: Message; parts: Part[] }[]> { + const requestDirectory = this.normalizeCandidatePath(directory) ?? this.currentDirectory; const response = await this.client.session.messages({ sessionID: id, - ...(this.currentDirectory ? { directory: this.currentDirectory } : {}), + ...(requestDirectory ? { directory: requestDirectory } : {}), ...(typeof limit === 'number' ? { limit } : {}), }); return unwrapSdkData(response, 'session.messages'); diff --git a/packages/ui/src/lib/sessionBtwMetadata.test.ts b/packages/ui/src/lib/sessionBtwMetadata.test.ts new file mode 100644 index 00000000..56ca52b9 --- /dev/null +++ b/packages/ui/src/lib/sessionBtwMetadata.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, test } from 'bun:test'; +import type { Session } from '@opencode-ai/sdk/v2'; +import { + getBtwBoundaryMessageID, + getBtwOriginalSessionID, + getBtwSessionID, + isBtwSession, + withBtwSessionLink, + withBtwSessionMarker, + withoutBtwSessionLink, + withoutBtwSessionMarker, +} from './sessionBtwMetadata'; + +const sessionWith = (metadata: unknown): Session => ({ id: 's', metadata }) as unknown as Session; + +describe('parent link', () => { + test('withBtwSessionLink preserves unrelated openchamber metadata', () => { + const next = withBtwSessionLink({ openchamber: { reviewSessionID: 'r-1' }, other: 1 }, 'fork-1'); + expect(next).toEqual({ openchamber: { reviewSessionID: 'r-1', btwSessionID: 'fork-1' }, other: 1 }); + }); + + test('getBtwSessionID reads the link and rejects blank values', () => { + expect(getBtwSessionID(sessionWith({ openchamber: { btwSessionID: 'fork-1' } }))).toBe('fork-1'); + expect(getBtwSessionID(sessionWith({ openchamber: { btwSessionID: ' ' } }))).toBeNull(); + expect(getBtwSessionID(sessionWith(undefined))).toBeNull(); + expect(getBtwSessionID(null)).toBeNull(); + }); + + test('withoutBtwSessionLink removes only a matching link', () => { + const linked = { openchamber: { btwSessionID: 'fork-1', reviewSessionID: 'r-1' } }; + expect(withoutBtwSessionLink(linked, 'fork-2')).toBe(linked); + expect(withoutBtwSessionLink(linked, 'fork-1')).toEqual({ openchamber: { reviewSessionID: 'r-1' } }); + }); + + test('withoutBtwSessionLink drops an emptied openchamber object', () => { + expect(withoutBtwSessionLink({ openchamber: { btwSessionID: 'fork-1' } }, 'fork-1')).toEqual({}); + }); +}); + +describe('fork marker', () => { + test('withBtwSessionMarker replaces inherited openchamber metadata', () => { + const inherited = { openchamber: { btwSessionID: 'stale', reviewSessionID: 'r-1' }, other: 1 }; + expect(withBtwSessionMarker(inherited, 'parent-1', 'msg-9')).toEqual({ + openchamber: { kind: 'btw', originalSessionID: 'parent-1', btwBoundaryMessageID: 'msg-9' }, + other: 1, + }); + }); + + test('withBtwSessionMarker omits a null boundary (empty parent)', () => { + expect(withBtwSessionMarker({}, 'parent-1', null)).toEqual({ + openchamber: { kind: 'btw', originalSessionID: 'parent-1' }, + }); + }); + + test('marker readers only apply to btw-kind sessions', () => { + const fork = sessionWith({ openchamber: { kind: 'btw', originalSessionID: 'parent-1', btwBoundaryMessageID: 'msg-9' } }); + expect(isBtwSession(fork)).toBe(true); + expect(getBtwOriginalSessionID(fork)).toBe('parent-1'); + expect(getBtwBoundaryMessageID(fork)).toBe('msg-9'); + + const review = sessionWith({ openchamber: { kind: 'review', originalSessionID: 'parent-1', btwBoundaryMessageID: 'msg-9' } }); + expect(isBtwSession(review)).toBe(false); + expect(getBtwOriginalSessionID(review)).toBeNull(); + expect(getBtwBoundaryMessageID(review)).toBeNull(); + }); + + test('withoutBtwSessionMarker strips the marker and keeps other keys', () => { + const marked = { openchamber: { kind: 'btw', originalSessionID: 'parent-1', btwBoundaryMessageID: 'msg-9', btwSessionID: 'nested' } }; + expect(withoutBtwSessionMarker(marked)).toEqual({ openchamber: { btwSessionID: 'nested' } }); + expect(withoutBtwSessionMarker({ openchamber: { kind: 'btw', originalSessionID: 'parent-1' } })).toEqual({}); + const plain = { openchamber: { kind: 'review' } }; + expect(withoutBtwSessionMarker(plain)).toBe(plain); + }); +}); diff --git a/packages/ui/src/lib/sessionBtwMetadata.ts b/packages/ui/src/lib/sessionBtwMetadata.ts new file mode 100644 index 00000000..1aace74f --- /dev/null +++ b/packages/ui/src/lib/sessionBtwMetadata.ts @@ -0,0 +1,120 @@ +import type { Session } from '@opencode-ai/sdk/v2'; +import { getSessionMetadata, type SessionMetadataRecord } from '@/lib/sessionReviewMetadata'; + +/** + * Session-metadata contract for the `/btw` flow, mirroring the review-session + * link in `sessionReviewMetadata`: + * + * - The parent (the session `/btw` was typed into) carries + * `openchamber.btwSessionID` pointing at its active btw fork. The panel is + * derived from this link, so it appears only in the parent session and + * survives reloads. + * - The fork itself is marked `openchamber.kind = 'btw'` with + * `originalSessionID` (its parent) and `btwBoundaryMessageID` — the id of + * the last message cloned from the parent. Messages with a greater id are + * the fork's own tail and are what the panel renders. Message ids are + * server-generated ascending identifiers, so the boundary is a plain string + * comparison and immune to client clock skew. + */ +type BtwMetadata = { + kind?: string; + originalSessionID?: string; + btwSessionID?: string; + btwBoundaryMessageID?: string; +}; + +const getOpenChamberMetadata = (metadata: SessionMetadataRecord): BtwMetadata => { + const value = metadata.openchamber; + if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; + // SAFETY: session metadata is persisted, externally writable data; this is + // its parsing boundary. `BtwMetadata` only declares optional fields and + // every reader re-validates the field it consumes in `nonEmpty`. + return value as BtwMetadata; +}; + +const nonEmpty = (value: string | undefined): string | null => + typeof value === 'string' && value.trim().length > 0 ? value : null; + +/** The parent's link to its active btw fork, or null. */ +export const getBtwSessionID = (session: Session | null | undefined): string | null => + nonEmpty(getOpenChamberMetadata(getSessionMetadata(session)).btwSessionID); + +export const isBtwSession = (session: Session | null | undefined): boolean => + getOpenChamberMetadata(getSessionMetadata(session)).kind === 'btw' + && Boolean(getBtwOriginalSessionID(session)); + +/** The fork's back-pointer to the session `/btw` was typed into. */ +export const getBtwOriginalSessionID = (session: Session | null | undefined): string | null => { + const openchamber = getOpenChamberMetadata(getSessionMetadata(session)); + return openchamber.kind === 'btw' ? nonEmpty(openchamber.originalSessionID) : null; +}; + +/** + * The id of the last message the fork inherited from the parent. `null` means + * the fork inherited nothing (empty parent) and every message is its own. + */ +export const getBtwBoundaryMessageID = (session: Session | null | undefined): string | null => { + const openchamber = getOpenChamberMetadata(getSessionMetadata(session)); + return openchamber.kind === 'btw' ? nonEmpty(openchamber.btwBoundaryMessageID) : null; +}; + +export const withBtwSessionLink = ( + metadata: SessionMetadataRecord, + btwSessionID: string, +): SessionMetadataRecord => ({ + ...metadata, + openchamber: { + ...getOpenChamberMetadata(metadata), + btwSessionID, + }, +}); + +/** + * Mark the fork as a btw session. The fork clones the parent's metadata + * wholesale (including review links or a stale `btwSessionID`), so the + * inherited `openchamber` object is replaced, not merged. + */ +export const withBtwSessionMarker = ( + metadata: SessionMetadataRecord, + originalSessionID: string, + boundaryMessageID: string | null, +): SessionMetadataRecord => { + const openchamber: BtwMetadata = { kind: 'btw', originalSessionID }; + if (boundaryMessageID) openchamber.btwBoundaryMessageID = boundaryMessageID; + return { ...metadata, openchamber }; +}; + +/** Remove the btw marker so a promoted fork becomes a plain session. */ +export const withoutBtwSessionMarker = (metadata: SessionMetadataRecord): SessionMetadataRecord => { + const openchamber = getOpenChamberMetadata(metadata); + if (openchamber.kind !== 'btw') return metadata; + const rest: BtwMetadata = { ...openchamber }; + delete rest.kind; + delete rest.originalSessionID; + delete rest.btwBoundaryMessageID; + const next: SessionMetadataRecord = { ...metadata }; + if (Object.keys(rest).length > 0) { + next.openchamber = rest; + } else { + delete next.openchamber; + } + return next; +}; + +/** Unlink the parent, but only if it still points at this fork. */ +export const withoutBtwSessionLink = ( + metadata: SessionMetadataRecord, + btwSessionID: string, +): SessionMetadataRecord => { + const openchamber = getOpenChamberMetadata(metadata); + if (openchamber.btwSessionID !== btwSessionID) return metadata; + const rest: BtwMetadata = { ...openchamber }; + delete rest.btwSessionID; + const next: SessionMetadataRecord = { ...metadata }; + if (Object.keys(rest).length > 0) { + next.openchamber = rest; + } else { + delete next.openchamber; + } + return next; +}; diff --git a/packages/ui/src/stores/useBtwStore.test.ts b/packages/ui/src/stores/useBtwStore.test.ts new file mode 100644 index 00000000..9711d182 --- /dev/null +++ b/packages/ui/src/stores/useBtwStore.test.ts @@ -0,0 +1,38 @@ +import { beforeEach, describe, expect, test } from 'bun:test'; +import { useBtwStore } from './useBtwStore'; + +describe('useBtwStore', () => { + beforeEach(() => { + useBtwStore.setState({ byParent: {} }); + }); + + test('starts empty', () => { + expect(useBtwStore.getState().byParent).toEqual({}); + }); + + test('setPanelState merges patches per parent', () => { + useBtwStore.getState().setPanelState('parent-1', { creating: true }); + useBtwStore.getState().setPanelState('parent-1', { collapsed: true }); + expect(useBtwStore.getState().byParent['parent-1']).toEqual({ creating: true, collapsed: true }); + }); + + test('parents are independent', () => { + useBtwStore.getState().setPanelState('parent-1', { collapsed: true }); + useBtwStore.getState().setPanelState('parent-2', { destroying: true }); + expect(useBtwStore.getState().byParent['parent-1']).toEqual({ collapsed: true }); + expect(useBtwStore.getState().byParent['parent-2']).toEqual({ destroying: true }); + }); + + test('clearPanelState removes only its parent entry', () => { + useBtwStore.getState().setPanelState('parent-1', { collapsed: true }); + useBtwStore.getState().setPanelState('parent-2', { collapsed: true }); + useBtwStore.getState().clearPanelState('parent-1'); + expect(useBtwStore.getState().byParent).toEqual({ 'parent-2': { collapsed: true } }); + }); + + test('clearPanelState on an unknown parent is a no-op', () => { + const before = useBtwStore.getState().byParent; + useBtwStore.getState().clearPanelState('missing'); + expect(useBtwStore.getState().byParent).toBe(before); + }); +}); diff --git a/packages/ui/src/stores/useBtwStore.ts b/packages/ui/src/stores/useBtwStore.ts new file mode 100644 index 00000000..c885f3bc --- /dev/null +++ b/packages/ui/src/stores/useBtwStore.ts @@ -0,0 +1,47 @@ +import { create } from 'zustand'; + +/** + * UI-only state for the `/btw` peek panel. + * + * The panel's identity is NOT stored here: it is derived from session + * metadata (`openchamber.btwSessionID` on the parent — see + * `sessionBtwMetadata`), so the panel appears only in the session `/btw` was + * typed into and survives reloads. This store keeps only transient + * per-parent presentation state that has no authoritative home: + * + * - `collapsed`: the panel is minimized to the composer chip; the composer + * talks to the main session again until it is expanded. + * - `creating`: `/btw` is between submit and the parent-metadata link + * landing, so the panel can show its starting state immediately. + * - `destroying`: close was clicked; hides the panel optimistically while the + * unlink/delete round-trip completes. + */ +type BtwPanelUIState = { + collapsed?: boolean; + creating?: boolean; + destroying?: boolean; +}; + +type BtwStore = { + byParent: Record; + setPanelState: (parentSessionId: string, patch: BtwPanelUIState) => void; + clearPanelState: (parentSessionId: string) => void; +}; + +export const useBtwStore = create()((set) => ({ + byParent: {}, + setPanelState: (parentSessionId, patch) => + set((state) => ({ + byParent: { + ...state.byParent, + [parentSessionId]: { ...state.byParent[parentSessionId], ...patch }, + }, + })), + clearPanelState: (parentSessionId) => + set((state) => { + if (!(parentSessionId in state.byParent)) return state; + const byParent = { ...state.byParent }; + delete byParent[parentSessionId]; + return { byParent }; + }), +})); diff --git a/packages/ui/src/sync/session-actions.ts b/packages/ui/src/sync/session-actions.ts index 027e8f27..fa56cc25 100644 --- a/packages/ui/src/sync/session-actions.ts +++ b/packages/ui/src/sync/session-actions.ts @@ -26,6 +26,7 @@ import { type SessionMetadataRecord, } from "@/lib/sessionReviewMetadata" import { withContextObligatoryMessage, type ContextObligatoryMessage } from "@/lib/contextObligatoryMessages" +import { getBtwOriginalSessionID, getBtwSessionID, isBtwSession, withoutBtwSessionLink } from "@/lib/sessionBtwMetadata" import { withLinkedIssue, type LinkedIssue } from "@/lib/linkedIssues" import { getImperativeSessionMessageLoader } from "./session-message-loader" import { cleanupPersistedSessionState } from "./session-deletion-cleanup" @@ -794,6 +795,7 @@ export async function patchSessionMetadata( useGlobalSessionsStore.getState().upsertSession(updated) const sessionDirectory = (updated as { directory?: string | null }).directory ?? targetDirectory if (sessionDirectory) registerSessionDirectory(updated.id, sessionDirectory) + mirrorSessionIntoLiveStores(updated, sessionDirectory ?? undefined) return updated } @@ -803,11 +805,8 @@ export async function setLinkedIssue( issue: LinkedIssue, linked: boolean, ): Promise { - const updated = await patchSessionMetadata(sessionId, directory, (metadata) => + return patchSessionMetadata(sessionId, directory, (metadata) => withLinkedIssue(metadata, issue, linked)) - const sessionDirectory = (updated as Session & { directory?: string | null }).directory ?? directory ?? undefined - mirrorSessionIntoLiveStores(updated, sessionDirectory ?? undefined) - return updated } export async function setContextObligatoryMessage( @@ -816,11 +815,8 @@ export async function setContextObligatoryMessage( message: ContextObligatoryMessage, pinned: boolean, ): Promise { - const updated = await patchSessionMetadata(sessionId, directory, (metadata) => + return patchSessionMetadata(sessionId, directory, (metadata) => withContextObligatoryMessage(metadata, message, pinned)) - const sessionDirectory = (updated as Session & { directory?: string | null }).directory ?? directory ?? undefined - mirrorSessionIntoLiveStores(updated, sessionDirectory ?? undefined) - return updated } async function cleanupReviewMetadataBeforeDelete( @@ -836,18 +832,41 @@ async function cleanupReviewMetadataBeforeDelete( return } if (isStaleRuntime(expectedRuntimeKey)) return - if (!isReviewSession(session)) return - const originalSessionID = getOriginalSessionID(session) - if (!originalSessionID) return - try { - await patchSessionMetadata(originalSessionID, directory ?? getSessionDirectory(originalSessionID), (metadata) => - withoutReviewSessionLink(metadata, sessionId), - expectedRuntimeKey, - ) - } catch (error) { - const message = error instanceof Error ? error.message : String(error) - if (/not found/i.test(message)) return - console.warn("[session-actions] review metadata cleanup failed before delete", error) + + const unlinkParent = async (originalSessionID: string, unlink: (metadata: SessionMetadataRecord) => SessionMetadataRecord) => { + try { + await patchSessionMetadata(originalSessionID, directory ?? getSessionDirectory(originalSessionID), unlink, expectedRuntimeKey) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + if (/not found/i.test(message)) return + console.warn("[session-actions] linked-session metadata cleanup failed before delete", error) + } + } + + if (isReviewSession(session)) { + const originalSessionID = getOriginalSessionID(session) + if (originalSessionID) await unlinkParent(originalSessionID, (metadata) => withoutReviewSessionLink(metadata, sessionId)) + return + } + + if (isBtwSession(session)) { + const originalSessionID = getBtwOriginalSessionID(session) + if (originalSessionID) await unlinkParent(originalSessionID, (metadata) => withoutBtwSessionLink(metadata, sessionId)) + return + } + + // Deleting or archiving a session that has an active btw fork also removes + // the fork: it is a temporary session that only exists for its parent's + // panel. Best-effort — a failed fork delete must not block the parent's + // operation; the orphaned fork stays visible in the sidebar. + const btwSessionID = getBtwSessionID(session) + if (btwSessionID) { + try { + if (isStaleRuntime(expectedRuntimeKey)) return + await deleteSession(btwSessionID, { expectedRuntimeKey }) + } catch (error) { + console.warn("[session-actions] failed to delete btw fork before parent delete", error) + } } } From 3195fb119097c2f9694f86ef2403af50a5301ee5 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sun, 23 Aug 2026 00:24:33 +0300 Subject: [PATCH 041/157] fix(chat): make the working status row swap seamlessly into the turn footer The streaming status line and the finished turn footer are the same visual line, but the swap used to jump: different font/color, a 2px left inset, a 20px row vs the footer's 32px (its h-8 action buttons set the height), and the bottom-anchored chat pulling the line up because the finished message carries more structure below its footer than the status row had. Match the footer exactly: text-sm at muted-foreground/60, no left inset, h-8 row, and mb-6 reserving the missing space below. Verified against the live DOM: the footer appears at the exact pixel position the status row occupied. --- packages/ui/src/components/chat/StatusRow.tsx | 16 +++++++++++----- .../chat/message/parts/WorkingPlaceholder.tsx | 8 ++++++-- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/packages/ui/src/components/chat/StatusRow.tsx b/packages/ui/src/components/chat/StatusRow.tsx index 66ecde5f..1159e822 100644 --- a/packages/ui/src/components/chat/StatusRow.tsx +++ b/packages/ui/src/components/chat/StatusRow.tsx @@ -306,13 +306,19 @@ export const StatusRow: React.FC = ({ return (
is running…" row sits flush against - // the message above. - className={cn("mb-1", isMobile && "mt-2", !hasLeftAccessory && "chat-column")} + // This row must land exactly where the assistant turn footer (mt-2 + // inside the message) appears when the turn completes. Measured against + // the live DOM: the gap ABOVE already matches (message pb-2 = footer + // mt-2 = 8px), but the chat is bottom-anchored and the finished message + // carries ~12px more structure BELOW its footer than this row has — so + // the swap used to lift the line up. mb-6 (24px) reserves that space + // under this row instead (verified: row top 636 == footer top 636). + className={cn("mb-6", !hasLeftAccessory && "chat-column")} style={STATUS_ROW_CONTAINER_STYLE} > -
+ {/* h-8 matches the turn footer's real row height: its h-8 action + buttons define the footer line, with the meta text centered in it. */} +
{/* Left: Abort status | Working placeholder | leftAccessory */}
{showAssistantStatus && showAbortStatus ? ( diff --git a/packages/ui/src/components/chat/message/parts/WorkingPlaceholder.tsx b/packages/ui/src/components/chat/message/parts/WorkingPlaceholder.tsx index 2a980448..9260cc51 100644 --- a/packages/ui/src/components/chat/message/parts/WorkingPlaceholder.tsx +++ b/packages/ui/src/components/chat/message/parts/WorkingPlaceholder.tsx @@ -229,15 +229,19 @@ export function WorkingPlaceholder({ return (
- + {hasProviderLogo && providerLogoSrc ? ( Date: Sun, 23 Aug 2026 00:42:23 +0300 Subject: [PATCH 042/157] docs(changelog): note /btw side questions and the status-line handoff --- CHANGELOG.md | 2 ++ packages/vscode/CHANGELOG.md | 2 ++ 2 files changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d6cb91b..4bc876de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ All notable changes to this project will be documented in this file. - Settings/Projects: a project can now pin a thinking level next to its model, for models that offer levels. Both sit in one Defaults for new chats group, laid out like the Sessions defaults. - **Settings:** the project selector on Providers, Agents, MCP, Commands and Skills now only changes what those pages show. It used to switch the whole app, so opening another project's configuration moved your chat, session list and file tree with it. - Settings/Providers: the provider you select no longer jumps to a different one on its own. Changing the chat's model or agent, and background provider refreshes, used to move the settings selection with them. +- **Chat: /btw side questions.** Type `/btw ` to ask something off-topic in a temporary session forked from the current conversation, so it inherits the full context but leaves the chat itself untouched. The answer streams into a panel above the composer, which talks to that session while the panel is open; you can collapse it to a slim header bar, keep it as a full session, or discard it. The temporary session stays out of the sidebar and session lists until you keep it (thanks to @jaygupta17). - **Chat sessions:** start chats without choosing a project. They live in their own Chats section, rather than inheriting a project's repository and worktree context. - **Skills catalog:** browse curated GitHub skill collections in a card-based catalog with cross-source search, skill counts, stars, recent updates, and links back to each skill's repository. - **Diff:** the context-panel diff can now show every change on the current branch against its base branch. OpenChamber detects the base when Git knows it, or lets you choose one once when it does not. @@ -26,6 +27,7 @@ All notable changes to this project will be documented in this file. - Git: generating a pull request description now picks up the repository's own PR template when it has one, so the draft comes back in your project's sections and checklists instead of the built-in Summary/Why/Testing layout. - Sidebar: switch between the full project list and a focused view of one project. Sessions created outside OpenChamber now also appear in the sidebar and Recent list without a page refresh (thanks to @tomzx). - Chat: if OpenCode restarts while a response is still running, the chat now stops with an interrupted state and a notification to continue instead of hanging silently (thanks to @sum117). +- Chat: while a reply streams, the model status line under the last message now turns into the finished message's info row in place, instead of jumping when the reply completes. - Chat: newly sent messages and syntax-highlighted code blocks no longer briefly flicker. Bash output can also grow with its content instead of being cut off. - Usage: Z.ai credit limits now appear alongside its other quota windows. - Git: pull-request checks in Work status stay current as their status changes. diff --git a/packages/vscode/CHANGELOG.md b/packages/vscode/CHANGELOG.md index 58dec215..bdd03883 100644 --- a/packages/vscode/CHANGELOG.md +++ b/packages/vscode/CHANGELOG.md @@ -1,11 +1,13 @@ ## [Unreleased] +- **/btw side questions:** type `/btw ` to ask something off-topic in a temporary session forked from the current conversation. The answer streams into a panel above the composer; collapse it, keep it as a full session, or discard it without touching the chat (thanks to @jaygupta17). - **Skills catalog:** browse curated GitHub skill collections in a card-based catalog with cross-source search and direct links to each skill's repository. - Providers: expanded support for custom providers. - Sessions created outside OpenChamber now appear in the sidebar and Recent list without a page refresh (thanks to @tomzx). - If OpenCode restarts while a response is still running, the chat now stops with an interrupted state and a notification to continue instead of hanging silently (thanks to @sum117). - Usage: Z.ai credit limits now appear alongside its other quota windows. - Chat: file paths in messages now open from the session's workspace, even if you last browsed files in another workspace (thanks to @tomzx). +- While a reply streams, the model status line under the last message now turns into the finished message's info row in place, instead of jumping when the reply completes. - Chat: newly sent messages and syntax-highlighted code blocks no longer briefly flicker. Bash output can also grow with its content instead of being cut off. - UI: the default dialog close button is easier to click or tap (thanks to @rockinrimmer). From 8f2c1ecc8b6e7a399e105d1b471d9435d1fec871 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sun, 23 Aug 2026 01:05:09 +0300 Subject: [PATCH 043/157] fix(chat): hide autocomplete tooltips on mobile --- packages/ui/src/components/chat/CommandAutocomplete.tsx | 2 +- packages/ui/src/components/chat/FileMentionAutocomplete.tsx | 2 +- packages/ui/src/components/chat/SkillAutocomplete.tsx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/ui/src/components/chat/CommandAutocomplete.tsx b/packages/ui/src/components/chat/CommandAutocomplete.tsx index aa9e5d0c..c1cebccc 100644 --- a/packages/ui/src/components/chat/CommandAutocomplete.tsx +++ b/packages/ui/src/components/chat/CommandAutocomplete.tsx @@ -385,7 +385,7 @@ export const CommandAutocomplete = React.forwardRef +
{ itemRefs.current[index] = el; }} diff --git a/packages/ui/src/components/chat/FileMentionAutocomplete.tsx b/packages/ui/src/components/chat/FileMentionAutocomplete.tsx index 89bef76a..9192fbc7 100644 --- a/packages/ui/src/components/chat/FileMentionAutocomplete.tsx +++ b/packages/ui/src/components/chat/FileMentionAutocomplete.tsx @@ -459,7 +459,7 @@ export const FileMentionAutocomplete = React.forwardRef { const isSelected = selectedIndex === index; return ( - +
{ itemRefs.current[index] = el; }} diff --git a/packages/ui/src/components/chat/SkillAutocomplete.tsx b/packages/ui/src/components/chat/SkillAutocomplete.tsx index 42bfe583..a5d05e4a 100644 --- a/packages/ui/src/components/chat/SkillAutocomplete.tsx +++ b/packages/ui/src/components/chat/SkillAutocomplete.tsx @@ -127,7 +127,7 @@ export const SkillAutocomplete = React.forwardRef +
{ From a5b0272f01b424557cbc6299ff0b6c33432fbc79 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sun, 23 Aug 2026 01:05:14 +0300 Subject: [PATCH 044/157] chore: update unreleased changelog --- CHANGELOG.md | 19 +++++++++++-------- packages/vscode/CHANGELOG.md | 8 +++++++- 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4bc876de..96723c6f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,21 +4,22 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +- **Chat: /btw side questions.** Type `/btw` followed by your question to ask something off-topic in a temporary session forked from the current conversation, so it inherits the full context but leaves the chat itself untouched. The answer streams into a panel above the composer, which talks to that session while the panel is open; you can collapse it to a slim header bar, keep it as a full session, or discard it. The temporary session stays out of the sidebar and session lists until you keep it (thanks to @jaygupta17). +- **Chat sessions:** start chats without choosing a project. They live in their own Chats section, rather than inheriting a project's repository and worktree context. - **Desktop/Remote instances:** adding an SSH connection now starts from the hosts in your SSH config instead of a blank command field. Ports, install method and passwords moved behind Advanced settings, and each connection shows Connected, Connecting, or Needs attention with the failure text and a button that resolves it. - Desktop/Remote instances: connecting to a remote machine now works when bun, OpenChamber or the opencode CLI live in your home directory rather than on the system path. Installing no longer fails with a permission error, and a missing opencode CLI is now reported before the connection starts instead of as a stack trace. - Desktop/Remote instances: a managed remote server can now also be published to the remote machine's own network, so other devices there reach it without the SSH tunnel. It requires a UI password, and stays private to the tunnel otherwise. - Desktop/Remote instances: disconnecting from a connection set to not keep the server running now actually stops that remote server. -- Chat: in a chat without a project, the work status card again steps aside when the context panel is open, instead of sitting next to it. -- Settings/General: changing the default model, variant or agent no longer repoints an open chat that already carries a model you picked for it. Chats following the default still switch immediately. +- Skills catalog: browse curated GitHub skill collections in a card-based catalog with cross-source search, skill counts, stars, recent updates, and links back to each skill's repository. +- Diff: the context-panel diff can now show every change on the current branch against its base branch. OpenChamber detects the base when Git knows it, or lets you choose one once when it does not. +- Dictation: speech is now transcribed after you stop recording. The composer shows a live waveform and timer, and long recordings split at pauses instead of cutting words. +- Settings: the project selector on Providers, Agents, MCP, Commands and Skills now only changes what those pages show. It used to switch the whole app, so opening another project's configuration moved your chat, session list and file tree with it. - Settings/Projects: a project can now pin a thinking level next to its model, for models that offer levels. Both sit in one Defaults for new chats group, laid out like the Sessions defaults. -- **Settings:** the project selector on Providers, Agents, MCP, Commands and Skills now only changes what those pages show. It used to switch the whole app, so opening another project's configuration moved your chat, session list and file tree with it. +- Settings/General: changing the default model, variant or agent no longer repoints an open chat that already carries a model you picked for it. Chats following the default still switch immediately. - Settings/Providers: the provider you select no longer jumps to a different one on its own. Changing the chat's model or agent, and background provider refreshes, used to move the settings selection with them. -- **Chat: /btw side questions.** Type `/btw ` to ask something off-topic in a temporary session forked from the current conversation, so it inherits the full context but leaves the chat itself untouched. The answer streams into a panel above the composer, which talks to that session while the panel is open; you can collapse it to a slim header bar, keep it as a full session, or discard it. The temporary session stays out of the sidebar and session lists until you keep it (thanks to @jaygupta17). -- **Chat sessions:** start chats without choosing a project. They live in their own Chats section, rather than inheriting a project's repository and worktree context. -- **Skills catalog:** browse curated GitHub skill collections in a card-based catalog with cross-source search, skill counts, stars, recent updates, and links back to each skill's repository. -- **Diff:** the context-panel diff can now show every change on the current branch against its base branch. OpenChamber detects the base when Git knows it, or lets you choose one once when it does not. -- **Dictation:** speech is now transcribed after you stop recording. The composer shows a live waveform and timer, and long recordings split at pauses instead of cutting words. +- Settings/Integrations: the experimental page now only lists integrations that can be installed; unavailable and Coming soon entries were removed. - Chat: file paths in messages now open from the session's project, even if you last browsed files in another project (thanks to @tomzx). +- Files/Desktop: files opened from outside the workspace remain readable after their temporary access expires instead of failing until you reopen them (thanks to @pascalandr). - Diff: creating an inline comment now opens the chat and focuses the composer for your follow-up. - Chat: in the expanded composer, Enter now starts a new line and Cmd/Ctrl+Enter sends, so a long prompt is harder to send by accident. - Providers: expanded support for custom providers. @@ -29,6 +30,8 @@ All notable changes to this project will be documented in this file. - Chat: if OpenCode restarts while a response is still running, the chat now stops with an interrupted state and a notification to continue instead of hanging silently (thanks to @sum117). - Chat: while a reply streams, the model status line under the last message now turns into the finished message's info row in place, instead of jumping when the reply completes. - Chat: newly sent messages and syntax-highlighted code blocks no longer briefly flicker. Bash output can also grow with its content instead of being cut off. +- Chat: long user messages can be expanded even when their final layout finishes after they first appear. +- Chat: in a chat without a project, the work status card again steps aside when the context panel is open, instead of sitting next to it. - Usage: Z.ai credit limits now appear alongside its other quota windows. - Git: pull-request checks in Work status stay current as their status changes. - UI: the default dialog close button is easier to click or tap (thanks to @rockinrimmer). diff --git a/packages/vscode/CHANGELOG.md b/packages/vscode/CHANGELOG.md index bdd03883..47e08755 100644 --- a/packages/vscode/CHANGELOG.md +++ b/packages/vscode/CHANGELOG.md @@ -1,7 +1,12 @@ ## [Unreleased] -- **/btw side questions:** type `/btw ` to ask something off-topic in a temporary session forked from the current conversation. The answer streams into a panel above the composer; collapse it, keep it as a full session, or discard it without touching the chat (thanks to @jaygupta17). +- **/btw side questions:** type `/btw` followed by your question to ask something off-topic in a temporary session forked from the current conversation. The answer streams into a panel above the composer; collapse it, keep it as a full session, or discard it without touching the chat (thanks to @jaygupta17). - **Skills catalog:** browse curated GitHub skill collections in a card-based catalog with cross-source search and direct links to each skill's repository. +- Settings: the workspace selector on Providers, Agents, MCP, Commands and Skills now only changes what those pages show instead of moving the chat, session list and file tree to another workspace. +- Settings/Projects: a project can now pin a thinking level next to its model, for models that offer levels. +- Settings/General: changing the default model, variant or agent no longer repoints an open chat that already carries a model you picked for it. Chats following the default still switch immediately. +- Settings/Providers: the provider you select no longer jumps to a different one when the chat selection or provider data changes. +- Settings/Integrations: the experimental page now only lists integrations that can be installed; unavailable and Coming soon entries were removed. - Providers: expanded support for custom providers. - Sessions created outside OpenChamber now appear in the sidebar and Recent list without a page refresh (thanks to @tomzx). - If OpenCode restarts while a response is still running, the chat now stops with an interrupted state and a notification to continue instead of hanging silently (thanks to @sum117). @@ -9,6 +14,7 @@ - Chat: file paths in messages now open from the session's workspace, even if you last browsed files in another workspace (thanks to @tomzx). - While a reply streams, the model status line under the last message now turns into the finished message's info row in place, instead of jumping when the reply completes. - Chat: newly sent messages and syntax-highlighted code blocks no longer briefly flicker. Bash output can also grow with its content instead of being cut off. +- Chat: long user messages can be expanded even when their final layout finishes after they first appear. - UI: the default dialog close button is easier to click or tap (thanks to @rockinrimmer). ## [1.19.0] - 2026-08-19 From 3a78d862488824e0a492b7212dfcd956fa96388a Mon Sep 17 00:00:00 2001 From: ChangeHow <23733347+ChangeHow@users.noreply.github.com> Date: Sun, 23 Aug 2026 06:53:21 +0800 Subject: [PATCH 045/157] fix(ui): open app deep links from chat after confirmation (#2932) * fix(ui): open app deep links from chat after confirmation DOMPurify's default URI policy stripped href from anchors with custom application schemes (obsidian://, vscode://, ...), so every app link rendered in chat was dead across web, desktop, VS Code, and mobile. - Classify safe app-link schemes in lib/url.ts (browser-handled, scriptable, webview-internal, network, and self-deep-link schemes stay excluded) and let openExternalUrl accept them - Keep app-link hrefs through the markdown sanitize hook - Intercept app-link clicks in the markdown renderer and route them through a confirmation dialog (Trust and open / Open once, dismiss to cancel) mounted in the desktop/web app root and the mobile shell - Persist per-device trusted schemes in a zustand store; trusted schemes open without asking again * feat(settings): manage trusted app link schemes in General Add an App links section to Settings > General listing the application schemes trusted on this device with a delete action; removing a scheme restores the confirmation dialog for it. Register the section in settings search. * fix(ui): enforce app link confirmation * fix(ui): handle app links by runtime * fix(vscode): keep app links unsupported * fix(settings): clarify trusted app links --------- Co-authored-by: Bohdan Triapitsyn --- CHANGELOG.md | 1 + packages/ui/src/App.tsx | 3 + packages/ui/src/apps/ElectronMiniChatApp.tsx | 2 + packages/ui/src/apps/MobileApp.tsx | 2 + packages/ui/src/apps/VSCodeApp.tsx | 3 + .../chat/AppLinkConfirmDialog.test.tsx | 46 +++++++++ .../components/chat/AppLinkConfirmDialog.tsx | 77 +++++++++++++++ .../components/chat/MarkdownRendererImpl.tsx | 53 +++-------- .../chat/appLinkConfirmation.test.ts | 67 +++++++++++++ .../components/chat/appLinkConfirmation.ts | 71 ++++++++++++++ .../chat/appLinkInteractions.test.ts | 93 +++++++++++++++++++ .../components/chat/appLinkInteractions.ts | 75 +++++++++++++++ .../chat/markdown/markdownCore.test.ts | 52 ++++++++++- .../components/chat/markdown/markdownCore.ts | 16 +++- .../chat/message/parts/DOCUMENTATION.md | 3 +- .../openchamber/AppLinkSecuritySettings.tsx | 48 ++++++++++ .../sections/openchamber/OpenChamberPage.tsx | 3 + .../ui/src/lib/i18n/messages/de.settings.ts | 4 + packages/ui/src/lib/i18n/messages/de.ts | 6 ++ .../ui/src/lib/i18n/messages/en.settings.ts | 4 + packages/ui/src/lib/i18n/messages/en.ts | 6 ++ .../ui/src/lib/i18n/messages/es.settings.ts | 4 + packages/ui/src/lib/i18n/messages/es.ts | 6 ++ .../ui/src/lib/i18n/messages/fr.settings.ts | 4 + packages/ui/src/lib/i18n/messages/fr.ts | 6 ++ .../ui/src/lib/i18n/messages/ja.settings.ts | 4 + packages/ui/src/lib/i18n/messages/ja.ts | 6 ++ .../ui/src/lib/i18n/messages/ko.settings.ts | 4 + packages/ui/src/lib/i18n/messages/ko.ts | 6 ++ .../ui/src/lib/i18n/messages/pl.settings.ts | 4 + packages/ui/src/lib/i18n/messages/pl.ts | 6 ++ .../src/lib/i18n/messages/pt-BR.settings.ts | 4 + packages/ui/src/lib/i18n/messages/pt-BR.ts | 6 ++ .../ui/src/lib/i18n/messages/uk.settings.ts | 4 + packages/ui/src/lib/i18n/messages/uk.ts | 6 ++ .../src/lib/i18n/messages/zh-CN.settings.ts | 4 + packages/ui/src/lib/i18n/messages/zh-CN.ts | 6 ++ .../src/lib/i18n/messages/zh-TW.settings.ts | 4 + packages/ui/src/lib/i18n/messages/zh-TW.ts | 6 ++ packages/ui/src/lib/settings/search.ts | 8 +- packages/ui/src/lib/url.test.ts | 62 +++++++++++++ packages/ui/src/lib/url.ts | 68 +++++++++++++- .../ui/src/stores/appLinkTrustStore.test.ts | 50 ++++++++++ packages/ui/src/stores/appLinkTrustStore.ts | 48 ++++++++++ packages/vscode/CHANGELOG.md | 1 + 45 files changed, 909 insertions(+), 53 deletions(-) create mode 100644 packages/ui/src/components/chat/AppLinkConfirmDialog.test.tsx create mode 100644 packages/ui/src/components/chat/AppLinkConfirmDialog.tsx create mode 100644 packages/ui/src/components/chat/appLinkConfirmation.test.ts create mode 100644 packages/ui/src/components/chat/appLinkConfirmation.ts create mode 100644 packages/ui/src/components/chat/appLinkInteractions.test.ts create mode 100644 packages/ui/src/components/chat/appLinkInteractions.ts create mode 100644 packages/ui/src/components/sections/openchamber/AppLinkSecuritySettings.tsx create mode 100644 packages/ui/src/lib/url.test.ts create mode 100644 packages/ui/src/stores/appLinkTrustStore.test.ts create mode 100644 packages/ui/src/stores/appLinkTrustStore.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 96723c6f..a0923699 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ All notable changes to this project will be documented in this file. - Settings/Providers: the provider you select no longer jumps to a different one on its own. Changing the chat's model or agent, and background provider refreshes, used to move the settings selection with them. - Settings/Integrations: the experimental page now only lists integrations that can be installed; unavailable and Coming soon entries were removed. - Chat: file paths in messages now open from the session's project, even if you last browsed files in another project (thanks to @tomzx). +- Chat: app links such as `spotify://` now ask for confirmation before opening another app. You can trust an app link type on one device and manage trusted links in Settings. - Files/Desktop: files opened from outside the workspace remain readable after their temporary access expires instead of failing until you reopen them (thanks to @pascalandr). - Diff: creating an inline comment now opens the chat and focuses the composer for your follow-up. - Chat: in the expanded composer, Enter now starts a new line and Cmd/Ctrl+Enter sends, so a long prompt is harder to send by accident. diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index 3470f692..5c9e8806 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { MainLayout } from '@/components/layout/MainLayout'; import { ChatView } from '@/components/views/ChatView'; +import { AppLinkConfirmDialog } from '@/components/chat/AppLinkConfirmDialog'; import { FireworksProvider } from '@/contexts/FireworksContext'; import { Toaster } from '@/components/ui/sonner'; import { Button } from '@/components/ui/button'; @@ -908,6 +909,7 @@ function App({ apis }: AppProps) { isVSCodeRuntime={isVSCodeRuntime} embeddedBackgroundWorkEnabled={embeddedBackgroundWorkEnabled} /> +
@@ -951,6 +953,7 @@ function App({ apis }: AppProps) { + {!isBootShell && ( <> diff --git a/packages/ui/src/apps/ElectronMiniChatApp.tsx b/packages/ui/src/apps/ElectronMiniChatApp.tsx index d1d53b50..7aed5ba0 100644 --- a/packages/ui/src/apps/ElectronMiniChatApp.tsx +++ b/packages/ui/src/apps/ElectronMiniChatApp.tsx @@ -5,6 +5,7 @@ import { registerRuntimeAPIs } from '@/contexts/runtimeAPIRegistry'; import { TooltipProvider } from '@/components/ui/tooltip'; import { Toaster } from '@/components/ui/sonner'; import { MiniChatLayout } from '@/components/mini-chat/MiniChatLayout'; +import { AppLinkConfirmDialog } from '@/components/chat/AppLinkConfirmDialog'; import { usePushVisibilityBeacon } from '@/hooks/usePushVisibilityBeacon'; import { useWindowTitle } from '@/hooks/useWindowTitle'; import { opencodeClient } from '@/lib/opencode/client'; @@ -325,6 +326,7 @@ export function ElectronMiniChatApp({ apis }: ElectronMiniChatAppProps) {
+
diff --git a/packages/ui/src/apps/MobileApp.tsx b/packages/ui/src/apps/MobileApp.tsx index b790270c..4d30dc00 100644 --- a/packages/ui/src/apps/MobileApp.tsx +++ b/packages/ui/src/apps/MobileApp.tsx @@ -9,6 +9,7 @@ import { OpenChamberLogo } from '@/components/ui/OpenChamberLogo'; import { ChatView } from '@/components/views/ChatView'; import { PlanView } from '@/components/views/PlanView'; import { SettingsView } from '@/components/views/SettingsView'; +import { AppLinkConfirmDialog } from '@/components/chat/AppLinkConfirmDialog'; import { ErrorBoundary } from '@/components/ui/ErrorBoundary'; import { RuntimeAPIProvider } from '@/contexts/RuntimeAPIProvider'; import { registerRuntimeAPIs } from '@/contexts/runtimeAPIRegistry'; @@ -1258,6 +1259,7 @@ export function MobileApp({ apis }: MobileAppProps) { switchRuntimeEndpoint({ apiBaseUrl: '', clientToken: null, runtimeKey: 'mobile-disconnected' }); setConnectionEpoch((value) => value + 1); }} /> + {isInitialized ? : null}
diff --git a/packages/ui/src/apps/VSCodeApp.tsx b/packages/ui/src/apps/VSCodeApp.tsx index 9090cd1d..737a0239 100644 --- a/packages/ui/src/apps/VSCodeApp.tsx +++ b/packages/ui/src/apps/VSCodeApp.tsx @@ -8,6 +8,7 @@ import { Toaster } from '@/components/ui/sonner'; import { ConfigUpdateOverlay } from '@/components/ui/ConfigUpdateOverlay'; import { ErrorBoundary } from '@/components/ui/ErrorBoundary'; import { OpenCodeUpdateToast } from '@/components/update/OpenCodeUpdateToast'; +import { AppLinkConfirmDialog } from '@/components/chat/AppLinkConfirmDialog'; import { VSCodeLayout } from '@/components/layout/VSCodeLayout'; import { usePushVisibilityBeacon } from '@/hooks/usePushVisibilityBeacon'; import { useGlobalSessionsPolling } from '@/hooks/useGlobalSessionsPolling'; @@ -110,6 +111,7 @@ export function VSCodeApp({ apis }: VSCodeAppProps) {
+
@@ -129,6 +131,7 @@ export function VSCodeApp({ apis }: VSCodeAppProps) {
+ diff --git a/packages/ui/src/components/chat/AppLinkConfirmDialog.test.tsx b/packages/ui/src/components/chat/AppLinkConfirmDialog.test.tsx new file mode 100644 index 00000000..ceb2624e --- /dev/null +++ b/packages/ui/src/components/chat/AppLinkConfirmDialog.test.tsx @@ -0,0 +1,46 @@ +import React from 'react'; +import { beforeEach, describe, expect, mock, test } from 'bun:test'; +import { renderToStaticMarkup } from 'react-dom/server'; + +import { I18nProvider } from '@/lib/i18n'; + +mock.module('@/components/ui/dialog', () => ({ + Dialog: ({ children }: React.PropsWithChildren) => <>{children}, + DialogContent: ({ children }: React.PropsWithChildren) =>
{children}
, + DialogDescription: ({ children }: React.PropsWithChildren) =>

{children}

, + DialogFooter: ({ children }: React.PropsWithChildren) =>
{children}
, + DialogHeader: ({ children }: React.PropsWithChildren) =>
{children}
, + DialogTitle: ({ children }: React.PropsWithChildren) =>

{children}

, +})); + +const { AppLinkConfirmDialog } = await import('./AppLinkConfirmDialog'); +const { + getAppLinkConfirmationSnapshot, + openAppLinkWithConfirmation, + settleAppLinkConfirmation, +} = await import('./appLinkConfirmation'); + +describe('AppLinkConfirmDialog', () => { + beforeEach(() => { + if (getAppLinkConfirmationSnapshot()) { + settleAppLinkConfirmation('cancel'); + } + }); + + test('keeps cancel visible and focused beside both open choices', () => { + void openAppLinkWithConfirmation('obsidian://open?vault=Notebook'); + + const markup = renderToStaticMarkup( + + + , + ); + + expect(markup).toContain('>Cancel'); + expect(markup).toContain('autofocus=""'); + expect(markup).toContain('>Open once'); + expect(markup).toContain('>Trust and open'); + + settleAppLinkConfirmation('cancel'); + }); +}); diff --git a/packages/ui/src/components/chat/AppLinkConfirmDialog.tsx b/packages/ui/src/components/chat/AppLinkConfirmDialog.tsx new file mode 100644 index 00000000..73960a9e --- /dev/null +++ b/packages/ui/src/components/chat/AppLinkConfirmDialog.tsx @@ -0,0 +1,77 @@ +import * as React from 'react'; + +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { useI18n } from '@/lib/i18n'; +import { getUrlScheme } from '@/lib/url'; + +import { + getAppLinkConfirmationSnapshot, + settleAppLinkConfirmation, + subscribeAppLinkConfirmation, + type AppLinkConfirmationChoice, +} from './appLinkConfirmation'; + +/** + * App-level dialog confirming application deep links (obsidian://, vscode://, + * ...) rendered in chat markdown before the OS is asked to open them. + * Dismissing via the close button, Escape, or the backdrop cancels the open. + */ +export const AppLinkConfirmDialog = () => { + const { t } = useI18n(); + const request = React.useSyncExternalStore( + subscribeAppLinkConfirmation, + getAppLinkConfirmationSnapshot, + getAppLinkConfirmationSnapshot, + ); + + const url = request?.url ?? ''; + const scheme = getUrlScheme(url) ?? ''; + + const settle = React.useCallback((choice: AppLinkConfirmationChoice) => { + settleAppLinkConfirmation(choice); + }, []); + + return ( + { + if (!open) { + settle('cancel'); + } + }} + > + + + {t('chat.appLink.confirm.title')} + + {scheme + ? t('chat.appLink.confirm.description', { scheme: `${scheme}://` }) + : t('chat.appLink.confirm.descriptionPlain')} + + +
+ {url} +
+ + + + + +
+
+ ); +}; diff --git a/packages/ui/src/components/chat/MarkdownRendererImpl.tsx b/packages/ui/src/components/chat/MarkdownRendererImpl.tsx index 29d5416d..b8af3e03 100644 --- a/packages/ui/src/components/chat/MarkdownRendererImpl.tsx +++ b/packages/ui/src/components/chat/MarkdownRendererImpl.tsx @@ -4,10 +4,12 @@ import { renderMermaidASCII, renderMermaidSVG } from 'beautiful-mermaid'; import type { Part } from '@opencode-ai/sdk/v2'; import { cn } from '@/lib/utils'; import { useI18n } from '@/lib/i18n'; -import { isExternalHttpUrl, openExternalUrl } from '@/lib/url'; +import { openExternalUrl } from '@/lib/url'; import { useOptionalThemeSystem } from '@/contexts/useThemeSystem'; import { getDefaultTheme } from '@/lib/theme/themes'; import type { Theme } from '@/types/theme'; +import { openAppLinkWithConfirmation } from './appLinkConfirmation'; +import { attachAppLinkInteractions } from './appLinkInteractions'; import type { ToolPopupContent } from './message/types'; import { FadeInOnReveal } from './message/FadeInOnReveal'; import { useUIStore } from '@/stores/useUIStore'; @@ -55,7 +57,7 @@ const useCurrentMermaidTheme = () => { : fallbackLight); }; -const useExternalLinkInteractions = ({ +const useLinkInteractions = ({ containerRef, enabled, }: { @@ -63,48 +65,16 @@ const useExternalLinkInteractions = ({ enabled?: boolean; }) => { React.useEffect(() => { - if (enabled === false) { - return; - } - const container = containerRef.current; if (!container) { return; } - const handleClick = (event: MouseEvent) => { - if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.altKey || event.shiftKey) { - return; - } - - const target = event.target; - if (!(target instanceof Element)) { - return; - } - - const anchor = target.closest('a[href]'); - if (!(anchor instanceof HTMLAnchorElement)) { - return; - } - - if (anchor.getAttribute('data-openchamber-file-link') === 'true') { - return; - } - - const href = anchor.getAttribute('href') ?? ''; - if (!isExternalHttpUrl(href)) { - return; - } - - event.preventDefault(); - event.stopPropagation(); - void openExternalUrl(href); - }; - - container.addEventListener('click', handleClick); - return () => { - container.removeEventListener('click', handleClick); - }; + return attachAppLinkInteractions(container, { + allowExternalHttp: enabled !== false, + openAppLink: (href) => void openAppLinkWithConfirmation(href), + openExternalHttp: (href) => void openExternalUrl(href), + }); }, [containerRef, enabled]); }; @@ -969,7 +939,7 @@ const MarkdownRendererImpl: React.FC = ({ preferRuntimeEditor: runtime.isVSCode, enabled: enableFileReferences && !isStreaming, }); - useExternalLinkInteractions({ containerRef }); + useLinkInteractions({ containerRef }); const syntaxVars = React.useMemo(() => getMarkdownSyntaxVars(currentTheme), [currentTheme]); const ctx = useDecorateContext(currentTheme, live, effectiveDirectory ? handlePreviewLoopback : undefined, DEFAULT_MERMAID_CONTROLS); @@ -1020,6 +990,7 @@ const SimpleMarkdownRendererImpl: React.FC<{ content: string; className?: string; variant?: MarkdownVariant; + // App links remain confirmed even where ordinary HTTP link handling is off. disableLinkSafety?: boolean; stripFrontmatter?: boolean; onShowPopup?: (content: ToolPopupContent) => void; @@ -1061,7 +1032,7 @@ const SimpleMarkdownRendererImpl: React.FC<{ preferRuntimeEditor: runtime.isVSCode, enabled: enableFileReferences, }); - useExternalLinkInteractions({ containerRef, enabled: !disableLinkSafety }); + useLinkInteractions({ containerRef, enabled: !disableLinkSafety }); const syntaxVars = React.useMemo(() => getMarkdownSyntaxVars(currentTheme), [currentTheme]); const ctx = useDecorateContext(currentTheme, false, undefined, mermaidControls); diff --git a/packages/ui/src/components/chat/appLinkConfirmation.test.ts b/packages/ui/src/components/chat/appLinkConfirmation.test.ts new file mode 100644 index 00000000..1fb41557 --- /dev/null +++ b/packages/ui/src/components/chat/appLinkConfirmation.test.ts @@ -0,0 +1,67 @@ +import { beforeEach, describe, expect, test } from 'bun:test'; + +import { useAppLinkTrustStore } from '@/stores/appLinkTrustStore'; + +import { + getAppLinkConfirmationSnapshot, + openAppLinkWithConfirmation, + settleAppLinkConfirmation, +} from './appLinkConfirmation'; + +describe('app link confirmation', () => { + beforeEach(() => { + useAppLinkTrustStore.setState({ trustedSchemes: [] }); + const pending = getAppLinkConfirmationSnapshot(); + if (pending) { + settleAppLinkConfirmation('cancel'); + } + }); + + test('opens trusted schemes without asking', async () => { + useAppLinkTrustStore.getState().trustScheme('obsidian'); + + await openAppLinkWithConfirmation('obsidian://open?vault=Notebook&file=notes'); + + expect(getAppLinkConfirmationSnapshot()).toBeNull(); + expect(useAppLinkTrustStore.getState().isSchemeTrusted('obsidian')).toBe(true); + }); + + test('asks once and trusts the scheme when the user chooses trust', async () => { + const pending = openAppLinkWithConfirmation('linear://issue/ABC-1'); + + expect(getAppLinkConfirmationSnapshot()?.url).toBe('linear://issue/ABC-1'); + + settleAppLinkConfirmation('trust'); + await pending; + + expect(getAppLinkConfirmationSnapshot()).toBeNull(); + expect(useAppLinkTrustStore.getState().isSchemeTrusted('linear')).toBe(true); + }); + + test('cancel opens nothing and keeps the scheme untrusted', async () => { + const pending = openAppLinkWithConfirmation('notion://note/xyz'); + + settleAppLinkConfirmation('cancel'); + await pending; + + expect(getAppLinkConfirmationSnapshot()).toBeNull(); + expect(useAppLinkTrustStore.getState().isSchemeTrusted('notion')).toBe(false); + }); + + test('a newer request cancels the pending one', async () => { + const first = openAppLinkWithConfirmation('obsidian://open?vault=a'); + const firstChoice = first.then( + () => 'settled', + () => 'settled', + ); + const second = openAppLinkWithConfirmation('linear://open/1'); + + expect(await firstChoice).toBe('settled'); + expect(getAppLinkConfirmationSnapshot()?.url).toBe('linear://open/1'); + + settleAppLinkConfirmation('open'); + await second; + + expect(getAppLinkConfirmationSnapshot()).toBeNull(); + }); +}); diff --git a/packages/ui/src/components/chat/appLinkConfirmation.ts b/packages/ui/src/components/chat/appLinkConfirmation.ts new file mode 100644 index 00000000..d9eea31e --- /dev/null +++ b/packages/ui/src/components/chat/appLinkConfirmation.ts @@ -0,0 +1,71 @@ +import { useAppLinkTrustStore } from '@/stores/appLinkTrustStore'; +import { getUrlScheme, openConfirmedAppLinkUrl } from '@/lib/url'; + +export type AppLinkConfirmationChoice = 'open' | 'trust' | 'cancel'; + +type PendingAppLinkRequest = { + url: string; + resolve: (choice: AppLinkConfirmationChoice) => void; +}; + +let pendingRequest: PendingAppLinkRequest | null = null; +const listeners = new Set<() => void>(); + +const emitChange = (): void => { + for (const listener of listeners) { + listener(); + } +}; + +const getSnapshot = (): PendingAppLinkRequest | null => pendingRequest; + +const subscribe = (listener: () => void): (() => void) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +}; + +/** + * Ask the user (via the app-level confirmation dialog) whether an application + * deep link may be opened. Resolves immediately when the scheme was trusted + * earlier. Only one request is active at a time; a new request cancels the + * pending one. + */ +export const openAppLinkWithConfirmation = (url: string): Promise => { + const scheme = getUrlScheme(url); + if (!scheme) { + return Promise.resolve(); + } + + const trustStore = useAppLinkTrustStore.getState(); + if (trustStore.isSchemeTrusted(scheme)) { + return openConfirmedAppLinkUrl(url).then(() => undefined); + } + + if (pendingRequest) { + pendingRequest.resolve('cancel'); + } + + return new Promise((resolve) => { + pendingRequest = { url, resolve }; + emitChange(); + }).then((choice) => { + if (choice === 'trust') { + useAppLinkTrustStore.getState().trustScheme(scheme); + } + if (choice === 'open' || choice === 'trust') { + return openConfirmedAppLinkUrl(url).then(() => undefined); + } + }); +}; + +export const settleAppLinkConfirmation = (choice: AppLinkConfirmationChoice): void => { + const request = pendingRequest; + pendingRequest = null; + emitChange(); + request?.resolve(choice); +}; + +export const subscribeAppLinkConfirmation = subscribe; +export const getAppLinkConfirmationSnapshot = getSnapshot; diff --git a/packages/ui/src/components/chat/appLinkInteractions.test.ts b/packages/ui/src/components/chat/appLinkInteractions.test.ts new file mode 100644 index 00000000..e32acbd9 --- /dev/null +++ b/packages/ui/src/components/chat/appLinkInteractions.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, test } from 'bun:test'; + +import { attachAppLinkInteractions } from './appLinkInteractions'; + +const TestElement = class Element {}; +const TestHTMLAnchorElement = class HTMLAnchorElement extends TestElement {}; +Object.assign(globalThis, { Element: TestElement, HTMLAnchorElement: TestHTMLAnchorElement }); + +class TestAnchor extends HTMLAnchorElement { + constructor(private readonly rawHref: string) { + super(); + } + + getAttribute(name: string): string | null { + return name === 'href' ? this.rawHref : null; + } + + closest(): TestAnchor { + return this; + } +} + +class TestContainer { + listeners = new Map(); + + addEventListener(name: string, listener: (event: MouseEvent) => void): void { + // SAFETY: dispatch constructs every mouse field read by the production listener. + this.listeners.set(name, (event) => listener(event as MouseEvent)); + } + + removeEventListener(name: string, listener: (event: MouseEvent) => void): void { + void listener; + this.listeners.delete(name); + } + + dispatch(name: string, href: string, init: Partial = {}): Event { + const event = new Event(name, { cancelable: true }); + Object.defineProperties(event, { + target: { value: new TestAnchor(href) }, + button: { value: init.button ?? 0 }, + metaKey: { value: init.metaKey ?? false }, + ctrlKey: { value: init.ctrlKey ?? false }, + altKey: { value: init.altKey ?? false }, + shiftKey: { value: init.shiftKey ?? false }, + }); + this.listeners.get(name)?.(event); + return event; + } +} + +const setup = (allowExternalHttp = true) => { + const container = new TestContainer(); + const appLinks: string[] = []; + const httpLinks: string[] = []; + const cleanup = attachAppLinkInteractions(container, { + allowExternalHttp, + openAppLink: (url) => appLinks.push(url), + openExternalHttp: (url) => httpLinks.push(url), + }); + return { container, appLinks, httpLinks, cleanup }; +}; + +describe('app link interactions', () => { + test('confirms plain, modifier, and middle-click activations', () => { + const { container, appLinks } = setup(); + const href = 'obsidian://open?vault=Notes'; + + expect(container.dispatch('click', href).defaultPrevented).toBe(true); + expect(container.dispatch('click', href, { metaKey: true }).defaultPrevented).toBe(true); + expect(container.dispatch('auxclick', href, { button: 1 }).defaultPrevented).toBe(true); + expect(appLinks).toEqual([href, href, href]); + }); + + test('blocks drag activation without opening immediately', () => { + const { container, appLinks } = setup(); + const href = 'obsidian://open?vault=Notes'; + + expect(container.dispatch('dragstart', href).defaultPrevented).toBe(true); + expect(appLinks).toEqual([]); + }); + + test('keeps HTTP modifier behavior and the disabled HTTP path unchanged', () => { + const enabled = setup(); + const disabled = setup(false); + const href = 'https://example.com'; + + expect(enabled.container.dispatch('click', href, { ctrlKey: true }).defaultPrevented).toBe(false); + expect(enabled.container.dispatch('click', href).defaultPrevented).toBe(true); + expect(disabled.container.dispatch('click', href).defaultPrevented).toBe(false); + expect(enabled.httpLinks).toEqual([href]); + expect(disabled.httpLinks).toEqual([]); + }); +}); diff --git a/packages/ui/src/components/chat/appLinkInteractions.ts b/packages/ui/src/components/chat/appLinkInteractions.ts new file mode 100644 index 00000000..595e671a --- /dev/null +++ b/packages/ui/src/components/chat/appLinkInteractions.ts @@ -0,0 +1,75 @@ +import { isAppLinkUrl, isExternalHttpUrl } from '@/lib/url'; + +type AppLinkInteractionOptions = { + allowExternalHttp: boolean; + openAppLink: (url: string) => void; + openExternalHttp: (url: string) => void; +}; + +type LinkInteractionContainer = { + addEventListener: (type: string, listener: (event: MouseEvent) => void) => void; + removeEventListener: (type: string, listener: (event: MouseEvent) => void) => void; +}; + +const findLink = (event: MouseEvent | DragEvent): HTMLAnchorElement | null => { + const target = event.target; + if (!(target instanceof Element)) return null; + const anchor = target.closest('a[href]'); + if (!(anchor instanceof HTMLAnchorElement)) return null; + if (anchor.getAttribute('data-openchamber-file-link') === 'true') return null; + return anchor; +}; + +const interceptAppLink = ( + event: MouseEvent | DragEvent, + openAppLink?: (url: string) => void, +): boolean => { + if (event.defaultPrevented) return false; + const anchor = findLink(event); + const href = anchor?.getAttribute('href') ?? ''; + if (!isAppLinkUrl(href)) return false; + + event.preventDefault(); + event.stopPropagation(); + openAppLink?.(href); + return true; +}; + +const isPlainPrimaryClick = (event: MouseEvent): boolean => ( + event.button === 0 + && !event.metaKey + && !event.ctrlKey + && !event.altKey + && !event.shiftKey +); + +export const attachAppLinkInteractions = ( + container: LinkInteractionContainer, + options: AppLinkInteractionOptions, +): (() => void) => { + const handleClick = (event: MouseEvent) => { + if (interceptAppLink(event, options.openAppLink)) return; + if (!options.allowExternalHttp || event.defaultPrevented || !isPlainPrimaryClick(event)) return; + + const href = findLink(event)?.getAttribute('href') ?? ''; + if (!isExternalHttpUrl(href)) return; + event.preventDefault(); + event.stopPropagation(); + options.openExternalHttp(href); + }; + const handleAuxClick = (event: MouseEvent) => { + if (event.button === 1) interceptAppLink(event, options.openAppLink); + }; + const blockAlternateAppLinkActivation = (event: MouseEvent | DragEvent) => { + interceptAppLink(event); + }; + + container.addEventListener('click', handleClick); + container.addEventListener('auxclick', handleAuxClick); + container.addEventListener('dragstart', blockAlternateAppLinkActivation); + return () => { + container.removeEventListener('click', handleClick); + container.removeEventListener('auxclick', handleAuxClick); + container.removeEventListener('dragstart', blockAlternateAppLinkActivation); + }; +}; diff --git a/packages/ui/src/components/chat/markdown/markdownCore.test.ts b/packages/ui/src/components/chat/markdown/markdownCore.test.ts index 968363d3..9153250d 100644 --- a/packages/ui/src/components/chat/markdown/markdownCore.test.ts +++ b/packages/ui/src/components/chat/markdown/markdownCore.test.ts @@ -1,10 +1,43 @@ import { describe, expect, mock, test } from 'bun:test'; +type SanitizeAttribute = { + attrName: string; + attrValue: string; + forceKeepAttr?: boolean; +}; + +class TestAnchorElement { + target = ''; + + setAttribute(name: string, value: string): void { + if (name === 'target') this.target = value; + } +} + +const sanitizeHooks: { + uponSanitizeAttribute?: (node: unknown, data: SanitizeAttribute) => void; + afterSanitizeAttributes?: (node: unknown) => void; +} = {}; + +Object.assign(globalThis, { + window: {}, + HTMLAnchorElement: TestAnchorElement, +}); + mock.module('dompurify', () => ({ default: { isSupported: true, - addHook: () => undefined, - sanitize: (html: string) => html, + addHook: (name: keyof typeof sanitizeHooks, hook: never) => { + sanitizeHooks[name] = hook; + }, + sanitize: (html: string) => html.replace(/ href="([^"]*)"/g, (attribute, href: string) => { + const anchor = new TestAnchorElement(); + const data: SanitizeAttribute = { attrName: 'href', attrValue: href }; + sanitizeHooks.uponSanitizeAttribute?.(anchor, data); + sanitizeHooks.afterSanitizeAttributes?.(anchor); + + return data.forceKeepAttr || /^(?:https?|mailto|tel):/i.test(href) ? attribute : ''; + }), }, })); mock.module('./markdown-worker', () => ({ @@ -40,6 +73,21 @@ describe('markdown sanitization', () => { expect(isLocalFileUrl('file://remote-host/share/report.html')).toBe(false); expect(isLocalFileUrl('javascript:alert(1)')).toBe(false); }); + + test('keeps app and local file links while stripping blocked schemes', () => { + const html = renderMarkdownSync([ + '[app](obsidian://open?vault=Notebook)', + '[file](file:///workspace/notes.md)', + '[script](javascript:alert(1))', + '[diagnostic](ms-msdt:/id%20PCWDiagnostic)', + ].join('\n\n'), 'inline'); + + expect(html).toContain('href="obsidian://open?vault=Notebook"'); + expect(html).toContain('href="file:///workspace/notes.md"'); + expect(html).not.toContain('href="javascript:alert(1)"'); + expect(html).not.toContain('href="ms-msdt:/id%20PCWDiagnostic"'); + }); + }); describe('Markdown images', () => { diff --git a/packages/ui/src/components/chat/markdown/markdownCore.ts b/packages/ui/src/components/chat/markdown/markdownCore.ts index 2d3cb7a9..822a3168 100644 --- a/packages/ui/src/components/chat/markdown/markdownCore.ts +++ b/packages/ui/src/components/chat/markdown/markdownCore.ts @@ -3,6 +3,7 @@ import remend from 'remend'; import katex from 'katex'; import DOMPurify from 'dompurify'; import { buildAgentMentionUrl, parseAgentHref, parseSkillHref } from '@/lib/messages/inlineMessageLinks'; +import { isAppLinkUrl } from '@/lib/url'; import { isVSCodeRuntime } from '@/lib/desktop'; import { contentFingerprint, HighlightResultCache, utf16Bytes } from './highlightResultCache'; import { highlightCodeInWorker } from './markdown-worker'; @@ -472,7 +473,10 @@ const ensureSanitizeHook = (): void => { sanitizeHookInstalled = true; DOMPurify.addHook('uponSanitizeAttribute', (node, data) => { if (!(node instanceof HTMLAnchorElement) || data.attrName !== 'href') return; - if (isLocalFileUrl(data.attrValue)) data.forceKeepAttr = true; + // DOMPurify's default URI policy strips custom application schemes + // (obsidian://, vscode://, ...). Keep them for anchors; dangerous schemes + // stay excluded via isAppLinkUrl and clicks go through confirmation. + if (isLocalFileUrl(data.attrValue) || isAppLinkUrl(data.attrValue)) data.forceKeepAttr = true; }); DOMPurify.addHook('afterSanitizeAttributes', (node) => { if (!(node instanceof HTMLAnchorElement)) return; @@ -544,7 +548,10 @@ export const __markdownBlockCacheSizesForTests = (): { full: number; live: numbe live: liveBlockCache.size, }); -const parseBlock = async (block: MarkdownBlock, imageMode: MarkdownImageMode): Promise => { +const parseBlock = async ( + block: MarkdownBlock, + imageMode: MarkdownImageMode, +): Promise => { const parser = imageMode === 'label' ? imageLabelParser : inlineImageParser; const parsed = await Promise.resolve(parser.parse(block.src)); const withMath = renderMathExpressions(parsed); @@ -561,7 +568,10 @@ const parseBlock = async (block: MarkdownBlock, imageMode: MarkdownImageMode): P * is synchronous (marked is not configured `async`), so this never blocks on a * worker round-trip. */ -export const renderMarkdownSync = (text: string, imageMode: MarkdownImageMode = 'inline'): string => { +export const renderMarkdownSync = ( + text: string, + imageMode: MarkdownImageMode = 'inline', +): string => { if (!text) return ''; const parser = imageMode === 'label' ? imageLabelParser : inlineImageParser; const parsed = parser.parse(text) as string; diff --git a/packages/ui/src/components/chat/message/parts/DOCUMENTATION.md b/packages/ui/src/components/chat/message/parts/DOCUMENTATION.md index 730b78e2..9cd7bf7a 100644 --- a/packages/ui/src/components/chat/message/parts/DOCUMENTATION.md +++ b/packages/ui/src/components/chat/message/parts/DOCUMENTATION.md @@ -54,7 +54,8 @@ Use this doc when you ask an agent to change tool/header/description behavior. - Assistant markdown treats raw HTML as inert visible text. The final generated HTML is sanitized as defense in depth, with script and style elements forbidden, so message content cannot inject active DOM or application-wide - CSS into any runtime surface. + CSS into any runtime surface. Safe custom application links go through the + app-link confirmation flow in every supported renderer, including VS Code. - Final assistant Markdown rendering is independent from image gallery extraction: gallery presence never changes the chat body. Assistant image syntax consistently renders as a shared image icon followed by its filename, diff --git a/packages/ui/src/components/sections/openchamber/AppLinkSecuritySettings.tsx b/packages/ui/src/components/sections/openchamber/AppLinkSecuritySettings.tsx new file mode 100644 index 00000000..55eafdda --- /dev/null +++ b/packages/ui/src/components/sections/openchamber/AppLinkSecuritySettings.tsx @@ -0,0 +1,48 @@ +import React from 'react'; + +import { Button } from '@/components/ui/button'; +import { SettingsSection } from '@/components/sections/shared/SettingsSection'; +import { useI18n } from '@/lib/i18n'; +import { useAppLinkTrustStore } from '@/stores/appLinkTrustStore'; + +/** + * Security section for application deep links (obsidian://, notion://, ...) + * that the user chose to always allow from chat. Removing a scheme restores + * the confirmation dialog for it. + */ +export const AppLinkSecuritySettings: React.FC = () => { + const { t } = useI18n(); + const trustedSchemes = useAppLinkTrustStore((state) => state.trustedSchemes); + const removeTrustedScheme = useAppLinkTrustStore((state) => state.removeTrustedScheme); + + return ( + +
+ {trustedSchemes.length === 0 ? ( +

+ {t('settings.openchamber.appLinks.empty')} +

+ ) : ( + trustedSchemes.map((scheme) => ( +
+ {`${scheme}://`} + +
+ )) + )} +
+
+ ); +}; diff --git a/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx b/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx index 08614a31..43fd56c0 100644 --- a/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx +++ b/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx @@ -3,6 +3,7 @@ import { OpenChamberVisualSettings } from './OpenChamberVisualSettings'; import { AboutSettings } from './AboutSettings'; import { SessionRetentionSettings } from './SessionRetentionSettings'; import { PasskeySettings } from './PasskeySettings'; +import { AppLinkSecuritySettings } from './AppLinkSecuritySettings'; import { DefaultsSettings } from './DefaultsSettings'; import { GitSettings } from './GitSettings'; import { NotificationSettings } from './NotificationSettings'; @@ -55,6 +56,7 @@ export const OpenChamberPage: React.FC = ({ section }) => {!isVSCode && } {!isVSCode && } + {isWebRuntime() && !isDesktopShell() && !isVSCode && !isCapacitorApp() && } {showAbout && } @@ -145,6 +147,7 @@ const GeneralSectionContent: React.FC = () => { <> {showDesktopNetworkSettings && } {showPasskeySettings && } + {!isVSCode && } {!isVSCode && } = { 'chat.dictation.retry': 'Reintentar transcripción', 'chat.dictation.discard': 'Descartar grabación', 'chat.history.loadOlder': 'Cargar mensajes anteriores', + "chat.appLink.confirm.title": "¿Abrir este enlace en otra aplicación?", + "chat.appLink.confirm.description": "Este enlace del chat usa el protocolo {scheme} y se abrirá en otra aplicación.", + "chat.appLink.confirm.descriptionPlain": "Este enlace del chat se abrirá en otra aplicación.", + "chat.appLink.confirm.cancel": "Cancelar", + "chat.appLink.confirm.open": "Abrir una vez", + "chat.appLink.confirm.trustAndOpen": "Confiar y abrir", 'chat.autoReview.title': 'El ciclo de revisión de código está en curso', 'chat.autoReview.status.waitingForReviewer': 'Esperando al revisor', 'chat.autoReview.status.waitingForImplementer': 'Esperando al implementador', diff --git a/packages/ui/src/lib/i18n/messages/fr.settings.ts b/packages/ui/src/lib/i18n/messages/fr.settings.ts index cdaffae8..88d46f33 100644 --- a/packages/ui/src/lib/i18n/messages/fr.settings.ts +++ b/packages/ui/src/lib/i18n/messages/fr.settings.ts @@ -324,6 +324,10 @@ export const settingsDict = { 'settings.common.actions.cancel': 'Annuler', 'settings.common.actions.create': 'Créer', 'settings.common.actions.delete': 'Supprimer', + 'settings.openchamber.appLinks.title': 'Liens d’application approuvés', + 'settings.openchamber.appLinks.info': 'Les liens de cette liste s’ouvrent sans nouvelle demande sur cet appareil. Les autres liens d’application demandent toujours une confirmation.', + 'settings.openchamber.appLinks.empty': 'Aucun lien d’application approuvé sur cet appareil. Choisissez « Approuver et ouvrir » lors de l’ouverture d’un lien pour l’ajouter ici.', + 'settings.openchamber.appLinks.removeAria': 'Supprimer les liens {scheme} approuvés', 'settings.common.actions.reset': 'Réinitialiser', 'settings.common.actions.rename': 'Rebaptiser', 'settings.common.actions.duplicate': 'Dupliquer', diff --git a/packages/ui/src/lib/i18n/messages/fr.ts b/packages/ui/src/lib/i18n/messages/fr.ts index fd989b8f..af006030 100644 --- a/packages/ui/src/lib/i18n/messages/fr.ts +++ b/packages/ui/src/lib/i18n/messages/fr.ts @@ -1314,6 +1314,12 @@ export const dict = { 'diffView.reviewDialog.toast.noSessionDirectory': 'Le dossier de session est indisponible', 'diffView.reviewDialog.toast.startFailed': 'Impossible de démarrer le flux de revue', 'chat.history.loadOlder': 'Charger les messages précédents', + 'chat.appLink.confirm.title': 'Ouvrir ce lien dans une autre application ?', + 'chat.appLink.confirm.description': "Ce lien de discussion utilise le protocole {scheme} et s'ouvrira dans une autre application.", + 'chat.appLink.confirm.descriptionPlain': "Ce lien de discussion s'ouvrira dans une autre application.", + 'chat.appLink.confirm.cancel': 'Annuler', + 'chat.appLink.confirm.open': 'Ouvrir une fois', + 'chat.appLink.confirm.trustAndOpen': 'Approuver et ouvrir', 'chat.autoReview.title': 'La boucle de revue de code est en cours', 'chat.autoReview.status.waitingForReviewer': 'En attente du reviewer', 'chat.autoReview.status.waitingForImplementer': 'En attente de l’implémenteur', diff --git a/packages/ui/src/lib/i18n/messages/ja.settings.ts b/packages/ui/src/lib/i18n/messages/ja.settings.ts index b83da80c..f4070831 100644 --- a/packages/ui/src/lib/i18n/messages/ja.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ja.settings.ts @@ -434,6 +434,10 @@ export const settingsDict = { 'settings.common.actions.cancel': 'キャンセル', 'settings.common.actions.create': '作成', 'settings.common.actions.delete': '削除', + 'settings.openchamber.appLinks.title': '信頼済みのアプリリンク', + 'settings.openchamber.appLinks.info': 'ここに表示されたリンクは、このデバイスでは次回から確認せずに開きます。その他のアプリリンクは開く前に必ず確認します。', + 'settings.openchamber.appLinks.empty': 'このデバイスには信頼済みのアプリリンクがありません。リンクを開く際に「信頼して開く」を選ぶとここに追加されます。', + 'settings.openchamber.appLinks.removeAria': '信頼済みの {scheme} リンクを削除', 'settings.common.actions.reset': 'リセット', 'settings.common.actions.rename': '名前変更', 'settings.common.actions.duplicate': '複製', diff --git a/packages/ui/src/lib/i18n/messages/ja.ts b/packages/ui/src/lib/i18n/messages/ja.ts index f233371b..ebe9b889 100644 --- a/packages/ui/src/lib/i18n/messages/ja.ts +++ b/packages/ui/src/lib/i18n/messages/ja.ts @@ -1554,6 +1554,12 @@ export const dict: Record = { 'diffView.hunk.unavailable': 'このハンクはもう利用できません。差分を更新してからもう一度お試しください。', 'diffView.hunk.unsupported': '個別のハンクのステージングはこのランタイムではサポートされていません。', 'chat.history.loadOlder': '以前のメッセージを読み込む', + 'chat.appLink.confirm.title': 'このリンクを別のアプリで開きますか?', + 'chat.appLink.confirm.description': 'このチャットのリンクは {scheme} プロトコルを使用し、別のアプリで開かれます。', + 'chat.appLink.confirm.descriptionPlain': 'このチャットのリンクは別のアプリで開かれます。', + 'chat.appLink.confirm.cancel': 'キャンセル', + 'chat.appLink.confirm.open': '一度だけ開く', + 'chat.appLink.confirm.trustAndOpen': '信頼して開く', 'chat.autoReview.title': 'コードレビューループが実行中です', 'chat.autoReview.status.waitingForReviewer': 'レビュアーを待機中', 'chat.autoReview.status.waitingForImplementer': '実装者を待機中', diff --git a/packages/ui/src/lib/i18n/messages/ko.settings.ts b/packages/ui/src/lib/i18n/messages/ko.settings.ts index a8e05bba..e4e2ef9e 100644 --- a/packages/ui/src/lib/i18n/messages/ko.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ko.settings.ts @@ -401,6 +401,10 @@ export const settingsDict = { 'settings.common.actions.cancel': '취소', 'settings.common.actions.create': '생성', 'settings.common.actions.delete': '삭제', + 'settings.openchamber.appLinks.title': '신뢰한 앱 링크', + 'settings.openchamber.appLinks.info': '여기에 표시된 링크는 이 기기에서 다시 묻지 않고 열립니다. 그 밖의 앱 링크는 열기 전에 항상 확인합니다.', + 'settings.openchamber.appLinks.empty': '이 기기에 신뢰한 앱 링크가 없습니다. 링크를 열 때 "신뢰하고 열기"를 선택하면 여기에 추가됩니다.', + 'settings.openchamber.appLinks.removeAria': '신뢰된 {scheme} 링크 제거', 'settings.common.actions.reset': '초기화', 'settings.common.actions.rename': '이름 변경', 'settings.common.actions.duplicate': '복제', diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index bfe88f96..6300cd13 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -1551,6 +1551,12 @@ export const dict: Record = { 'diffView.reviewDialog.toast.noSessionDirectory': '세션 디렉터리를 사용할 수 없습니다', 'diffView.reviewDialog.toast.startFailed': '리뷰 흐름을 시작하지 못했습니다', 'chat.history.loadOlder': '이전 메시지 불러오기', + 'chat.appLink.confirm.title': '이 링크를 다른 앱에서 열까요?', + 'chat.appLink.confirm.description': '이 채팅 링크는 {scheme} 프로토콜을 사용하며 다른 앱에서 열립니다.', + 'chat.appLink.confirm.descriptionPlain': '이 채팅 링크는 다른 앱에서 열립니다.', + 'chat.appLink.confirm.cancel': '취소', + 'chat.appLink.confirm.open': '한 번만 열기', + 'chat.appLink.confirm.trustAndOpen': '신뢰하고 열기', 'chat.autoReview.title': '코드 리뷰 루프 실행 중', 'chat.autoReview.status.waitingForReviewer': '리뷰어를 기다리는 중', 'chat.autoReview.status.waitingForImplementer': '구현 에이전트를 기다리는 중', diff --git a/packages/ui/src/lib/i18n/messages/pl.settings.ts b/packages/ui/src/lib/i18n/messages/pl.settings.ts index 3555958f..7bebca44 100644 --- a/packages/ui/src/lib/i18n/messages/pl.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pl.settings.ts @@ -216,6 +216,10 @@ export const settingsDict = { 'settings.common.actions.copyAll': 'Kopiuj wszystko', 'settings.common.actions.create': 'Utwórz', 'settings.common.actions.delete': 'Usuń', + 'settings.openchamber.appLinks.title': 'Zaufane linki aplikacji', + 'settings.openchamber.appLinks.info': 'Linki z tej listy otwierają się na tym urządzeniu bez ponownego pytania. Inne linki aplikacji zawsze wymagają potwierdzenia.', + 'settings.openchamber.appLinks.empty': 'Brak zaufanych linków aplikacji na tym urządzeniu. Wybierz „Zaufaj i otwórz” podczas otwierania linku, aby dodać go tutaj.', + 'settings.openchamber.appLinks.removeAria': 'Usuń zaufane linki {scheme}', 'settings.common.actions.duplicate': 'Duplikuj', 'settings.common.actions.import': 'Importuj', 'settings.common.actions.rename': 'Zmień nazwę', diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts index c8ded4ba..666c1d73 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -1763,6 +1763,12 @@ export const dict: Record = { 'diffView.reviewDialog.toast.noSessionDirectory': 'Katalog sesji jest niedostępny', 'diffView.reviewDialog.toast.startFailed': 'Nie udało się uruchomić flow review', 'chat.history.loadOlder': 'Wczytaj starsze wiadomości', + 'chat.appLink.confirm.title': 'Otworzyć ten link w innej aplikacji?', + 'chat.appLink.confirm.description': 'Ten link z czatu używa protokołu {scheme} i zostanie otwarty w innej aplikacji.', + 'chat.appLink.confirm.descriptionPlain': 'Ten link z czatu zostanie otwarty w innej aplikacji.', + 'chat.appLink.confirm.cancel': 'Anuluj', + 'chat.appLink.confirm.open': 'Otwórz raz', + 'chat.appLink.confirm.trustAndOpen': 'Zaufaj i otwórz', 'chat.autoReview.title': 'Pętla code review trwa', 'chat.autoReview.status.waitingForReviewer': 'Oczekiwanie na reviewera', 'chat.autoReview.status.waitingForImplementer': 'Oczekiwanie na implementatora', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts index 963fbf91..ce24a8d0 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts @@ -401,6 +401,10 @@ export const settingsDict = { "settings.common.actions.cancel": "Cancelar", "settings.common.actions.create": "Criar", "settings.common.actions.delete": "Excluir", + "settings.openchamber.appLinks.title": "Links de aplicativos confiáveis", + "settings.openchamber.appLinks.info": "Os links desta lista abrem sem perguntar novamente neste dispositivo. Outros links de aplicativos sempre pedem confirmação antes de abrir.", + "settings.openchamber.appLinks.empty": "Não há links de aplicativos confiáveis neste dispositivo. Escolha \"Confiar e abrir\" ao abrir um link para adicioná-lo aqui.", + "settings.openchamber.appLinks.removeAria": "Remover links {scheme} confiáveis", "settings.common.actions.reset": "Reiniciar", "settings.common.actions.rename": "Renomear", "settings.common.actions.duplicate": "Duplicar", diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index 48235bf6..59c2ff9e 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -1527,6 +1527,12 @@ export const dict: Record = { 'chat.dictation.retry': 'Tentar transcrever novamente', 'chat.dictation.discard': 'Descartar gravação', 'chat.history.loadOlder': 'Carregar mensagens anteriores', + "chat.appLink.confirm.title": "Abrir este link em outro aplicativo?", + "chat.appLink.confirm.description": "Este link do chat usa o protocolo {scheme} e será aberto em outro aplicativo.", + "chat.appLink.confirm.descriptionPlain": "Este link do chat será aberto em outro aplicativo.", + "chat.appLink.confirm.cancel": "Cancelar", + "chat.appLink.confirm.open": "Abrir uma vez", + "chat.appLink.confirm.trustAndOpen": "Confiar e abrir", 'chat.autoReview.title': 'O ciclo de revisão de código está em andamento', 'chat.autoReview.status.waitingForReviewer': 'Aguardando o revisor', 'chat.autoReview.status.waitingForImplementer': 'Aguardando o implementador', diff --git a/packages/ui/src/lib/i18n/messages/uk.settings.ts b/packages/ui/src/lib/i18n/messages/uk.settings.ts index 00035bca..2d7dcb16 100644 --- a/packages/ui/src/lib/i18n/messages/uk.settings.ts +++ b/packages/ui/src/lib/i18n/messages/uk.settings.ts @@ -401,6 +401,10 @@ export const settingsDict = { "settings.common.actions.cancel": "Скасувати", "settings.common.actions.create": "Створити", "settings.common.actions.delete": "Видалити", + "settings.openchamber.appLinks.title": "Довірені посилання програм", + "settings.openchamber.appLinks.info": "Посилання в цьому списку відкриваються без повторного запиту на цьому пристрої. Для інших посилань програм ми завжди просимо підтвердження.", + "settings.openchamber.appLinks.empty": "На цьому пристрої ще немає довірених посилань програм. Виберіть «Довірити і відкрити» під час відкриття посилання, щоб додати його сюди.", + "settings.openchamber.appLinks.removeAria": "Видалити довірені посилання {scheme}", "settings.common.actions.reset": "Скинути", "settings.common.actions.rename": "Перейменувати", "settings.common.actions.duplicate": "Дублювати", diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index 618fafa4..a90cab4c 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -1527,6 +1527,12 @@ export const dict: Record = { 'chat.dictation.retry': 'Повторити розшифровку', 'chat.dictation.discard': 'Відхилити запис', 'chat.history.loadOlder': 'Завантажити ще', + "chat.appLink.confirm.title": "Відкрити це посилання в іншій програмі?", + "chat.appLink.confirm.description": "Це посилання з чату використовує протокол {scheme} і буде відкрито в іншій програмі.", + "chat.appLink.confirm.descriptionPlain": "Це посилання з чату буде відкрито в іншій програмі.", + "chat.appLink.confirm.cancel": "Скасувати", + "chat.appLink.confirm.open": "Відкрити один раз", + "chat.appLink.confirm.trustAndOpen": "Довірити і відкрити", 'chat.autoReview.title': 'Цикл код-ревʼю триває', 'chat.autoReview.status.waitingForReviewer': 'Очікуємо ревʼювера', 'chat.autoReview.status.waitingForImplementer': 'Очікуємо імплементатора', diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts index eddb6b73..d648d30e 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts @@ -401,6 +401,10 @@ export const settingsDict = { 'settings.common.actions.cancel': '取消', 'settings.common.actions.create': '创建', 'settings.common.actions.delete': '删除', + 'settings.openchamber.appLinks.title': '受信任的应用链接', + 'settings.openchamber.appLinks.info': '此列表中的链接在本设备上打开时不再询问。其他应用链接在打开前始终需要确认。', + 'settings.openchamber.appLinks.empty': '本设备上暂无受信任的应用链接。打开链接时选择“信任并打开”即可添加到这里。', + 'settings.openchamber.appLinks.removeAria': '移除受信任的 {scheme} 链接', 'settings.common.actions.reset': '重置', 'settings.common.actions.rename': '重命名', 'settings.common.actions.duplicate': '复制', diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index df7f48c5..175b755b 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -1515,6 +1515,12 @@ export const dict: Record = { 'diffView.reviewDialog.toast.noSessionDirectory': 'Session directory is unavailable', 'diffView.reviewDialog.toast.startFailed': 'Failed to start review flow', 'chat.history.loadOlder': '加载更早的消息', + 'chat.appLink.confirm.title': '要在其他应用中打开此链接吗?', + 'chat.appLink.confirm.description': '此聊天链接使用 {scheme} 协议,将在其他应用中打开。', + 'chat.appLink.confirm.descriptionPlain': '此聊天链接将在其他应用中打开。', + 'chat.appLink.confirm.cancel': '取消', + 'chat.appLink.confirm.open': '打开一次', + 'chat.appLink.confirm.trustAndOpen': '信任并打开', 'chat.autoReview.title': '代码审查循环正在运行', 'chat.autoReview.status.waitingForReviewer': '等待审查者', 'chat.autoReview.status.waitingForImplementer': '等待实现者', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts index b1fdb079..28e71162 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts @@ -398,6 +398,10 @@ export const settingsDict = { 'settings.common.actions.cancel': '取消', 'settings.common.actions.create': '建立', 'settings.common.actions.delete': '刪除', + 'settings.openchamber.appLinks.title': '受信任的應用程式連結', + 'settings.openchamber.appLinks.info': '此清單中的連結在這台裝置上開啟時不再詢問。其他應用程式連結在開啟前一律需要確認。', + 'settings.openchamber.appLinks.empty': '這台裝置上目前沒有受信任的應用程式連結。開啟連結時選擇「信任並開啟」即可加入這裡。', + 'settings.openchamber.appLinks.removeAria': '移除受信任的 {scheme} 連結', 'settings.common.actions.reset': '重設', 'settings.common.actions.rename': '重新命名', 'settings.common.actions.duplicate': '複製', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.ts b/packages/ui/src/lib/i18n/messages/zh-TW.ts index 673ae58e..ae1d8a49 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts @@ -1525,6 +1525,12 @@ export const dict: Record = { 'diffView.reviewDialog.toast.noSessionDirectory': 'Session directory is unavailable', 'diffView.reviewDialog.toast.startFailed': 'Failed to start review flow', 'chat.history.loadOlder': '載入更早的訊息', + 'chat.appLink.confirm.title': '要在其他應用程式中開啟此連結嗎?', + 'chat.appLink.confirm.description': '此聊天連結使用 {scheme} 通訊協定,將在其他應用程式中開啟。', + 'chat.appLink.confirm.descriptionPlain': '此聊天連結將在其他應用程式中開啟。', + 'chat.appLink.confirm.cancel': '取消', + 'chat.appLink.confirm.open': '開啟一次', + 'chat.appLink.confirm.trustAndOpen': '信任並開啟', 'chat.autoReview.title': '程式碼審查循環執行中', 'chat.autoReview.status.waitingForReviewer': '等待審查者', 'chat.autoReview.status.waitingForImplementer': '等待實作者', diff --git a/packages/ui/src/lib/settings/search.ts b/packages/ui/src/lib/settings/search.ts index f247f679..9945f92c 100644 --- a/packages/ui/src/lib/settings/search.ts +++ b/packages/ui/src/lib/settings/search.ts @@ -50,7 +50,6 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [ page: 'appearance', titleKey: 'settings.openchamber.visual.field.weekStartsOn', keywords: ['calendar', 'monday', 'sunday'], - isAvailable: (ctx) => !ctx.isVSCode, }, { id: 'appearance.light-theme', @@ -177,6 +176,13 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [ descriptionKey: 'settings.openchamber.visual.field.sendAnonymousUsageReportsHint', keywords: ['telemetry', 'analytics'], }, + { + id: 'general.app-links', + page: 'general', + titleKey: 'settings.openchamber.appLinks.title', + descriptionKey: 'settings.openchamber.appLinks.info', + keywords: ['security', 'app link', 'deep link', 'scheme', 'protocol', 'obsidian', 'notion'], + }, { id: 'chat.render-mode', page: 'chat', diff --git a/packages/ui/src/lib/url.test.ts b/packages/ui/src/lib/url.test.ts new file mode 100644 index 00000000..4a3c5278 --- /dev/null +++ b/packages/ui/src/lib/url.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, test } from 'bun:test'; + +import { getUrlScheme, isAppLinkUrl } from '@/lib/url'; + +describe('getUrlScheme', () => { + test('extracts the lowercased scheme', () => { + expect(getUrlScheme('Obsidian://open?vault=X')).toBe('obsidian'); + expect(getUrlScheme('https://example.test')).toBe('https'); + }); + + test('returns null for unparseable values', () => { + expect(getUrlScheme('')).toBeNull(); + expect(getUrlScheme('not a url')).toBeNull(); + }); +}); + +describe('isAppLinkUrl', () => { + test('accepts custom application schemes', () => { + expect(isAppLinkUrl('obsidian://open?vault=Notebook&file=a%20b')).toBe(true); + expect(isAppLinkUrl('vscode://file/path/to/file.ts')).toBe(true); + expect(isAppLinkUrl('linear://issue/ABC-1')).toBe(true); + expect(isAppLinkUrl('notion://note/xyz')).toBe(true); + expect(isAppLinkUrl('slack://channel?id=C123')).toBe(true); + }); + + test('rejects browser and communication schemes', () => { + expect(isAppLinkUrl('https://example.test')).toBe(false); + expect(isAppLinkUrl('http://example.test')).toBe(false); + expect(isAppLinkUrl('mailto:user@example.test')).toBe(false); + expect(isAppLinkUrl('tel:+1234567890')).toBe(false); + expect(isAppLinkUrl('sms:+1234567890')).toBe(false); + expect(isAppLinkUrl('webcal://example.test/cal.ics')).toBe(false); + }); + + test('rejects dangerous and internal schemes', () => { + expect(isAppLinkUrl('javascript:alert(1)')).toBe(false); + expect(isAppLinkUrl('data:text/html;base64,PHNjcmlwdD4=')).toBe(false); + expect(isAppLinkUrl('vbscript:msgbox(1)')).toBe(false); + expect(isAppLinkUrl('blob:https://example.test/uuid')).toBe(false); + expect(isAppLinkUrl('about:blank')).toBe(false); + expect(isAppLinkUrl('file:///etc/passwd')).toBe(false); + expect(isAppLinkUrl('ws://localhost:8080')).toBe(false); + expect(isAppLinkUrl('ftp://files.example.test')).toBe(false); + expect(isAppLinkUrl('intent://scan/#Intent;scheme=zxing;end')).toBe(false); + expect(isAppLinkUrl('chrome://settings')).toBe(false); + expect(isAppLinkUrl('devtools://devtools/bundled/inspector.html')).toBe(false); + expect(isAppLinkUrl('ms-msdt:/id%20PCWDiagnostic')).toBe(false); + expect(isAppLinkUrl('search-ms:query=report')).toBe(false); + expect(isAppLinkUrl('shell:AppsFolder')).toBe(false); + }); + + test('rejects OpenChamber and Capacitor self-deep-links', () => { + expect(isAppLinkUrl('openchamber://connect?host=x')).toBe(false); + expect(isAppLinkUrl('openchamber-ui://app/index.html')).toBe(false); + expect(isAppLinkUrl('capacitor://localhost/index.html')).toBe(false); + }); + + test('rejects malformed input', () => { + expect(isAppLinkUrl('')).toBe(false); + expect(isAppLinkUrl('random text')).toBe(false); + }); +}); diff --git a/packages/ui/src/lib/url.ts b/packages/ui/src/lib/url.ts index 0e688c97..9696082b 100644 --- a/packages/ui/src/lib/url.ts +++ b/packages/ui/src/lib/url.ts @@ -20,6 +20,61 @@ export const isExternalHttpUrl = (url: string): boolean => { return parsed.protocol === 'http:' || parsed.protocol === 'https:'; }; +/** Lowercased URL scheme without the trailing colon, or null when unparseable. */ +export const getUrlScheme = (url: string): string | null => { + const parsed = parseUrlSafely(url.trim()); + if (!parsed) { + return null; + } + return parsed.protocol.replace(/:$/, '').toLowerCase(); +}; + +/** + * Schemes the browser or OS communication apps already handle natively + * (mailto:, tel:, sms:, ...). They are not application deep links. + */ +const BROWSER_HANDLED_SCHEMES = new Set(['http', 'https', 'mailto', 'tel', 'sms', 'callto', 'cid', 'xmpp', 'irc', 'news', 'nntp', 'feed', 'webcal']); + +/** + * Schemes that must never be preserved or opened from rendered chat content. + */ +const BLOCKED_APP_LINK_SCHEMES = new Set([ + // Scriptable or web-content schemes + 'javascript', 'data', 'vbscript', 'blob', 'filesystem', 'about', + // WebView/Electron internal schemes + 'chrome', 'chrome-extension', 'devtools', 'moz-extension', 'ms-browser-extension', + // Local files flow through the dedicated file-link handling + 'file', + // Network protocols that are not application links + 'ws', 'wss', 'ftp', 'ftps', + // Android intent URIs can launch arbitrary components with extras + 'intent', + // Historically abused Windows handlers can invoke diagnostic, shell, or + // file-search flows that must not be offered from untrusted chat content. + 'ms-msdt', 'search-ms', 'shell', + // OpenChamber's own schemes must not be re-launched from chat content + 'openchamber', 'openchamber-ui', 'capacitor', +]); + +const APP_LINK_SCHEME_RE = /^[a-z][a-z0-9+.-]{1,31}$/; + +/** + * True for custom application deep links such as `obsidian://`, `linear://`, + * or `vscode://`. Browser-handled and dangerous/internal schemes are excluded, + * so a true result means the link may be offered to the user behind a + * confirmation the first time its scheme appears. + */ +export const isAppLinkUrl = (url: string): boolean => { + const scheme = getUrlScheme(url); + if (!scheme) { + return false; + } + if (BROWSER_HANDLED_SCHEMES.has(scheme) || BLOCKED_APP_LINK_SCHEMES.has(scheme)) { + return false; + } + return APP_LINK_SCHEME_RE.test(scheme); +}; + export const getExternalFaviconUrl = (url: string): string | null => { const parsed = parseUrlSafely(url.trim()); if (!parsed || (parsed.protocol !== 'http:' && parsed.protocol !== 'https:')) { @@ -88,7 +143,7 @@ export const extractLoopbackUrls = (text: string): string[] => { * @param url - The URL to open * @returns Promise - true if the URL was opened successfully */ -export const openExternalUrl = async (url: string): Promise => { +const openValidatedExternalUrl = async (url: string): Promise => { if (typeof window === 'undefined') { return false; } @@ -103,10 +158,6 @@ export const openExternalUrl = async (url: string): Promise => { return false; } - if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { - return false; - } - const normalizedTarget = parsed.toString(); const runtimeApis = getRegisteredRuntimeAPIs(); @@ -136,3 +187,10 @@ export const openExternalUrl = async (url: string): Promise => { return false; } }; + +export const openExternalUrl = (url: string): Promise => + isExternalHttpUrl(url) ? openValidatedExternalUrl(url) : Promise.resolve(false); + +/** Opens a classified app link after the caller has completed confirmation. */ +export const openConfirmedAppLinkUrl = (url: string): Promise => + isAppLinkUrl(url) ? openValidatedExternalUrl(url) : Promise.resolve(false); diff --git a/packages/ui/src/stores/appLinkTrustStore.test.ts b/packages/ui/src/stores/appLinkTrustStore.test.ts new file mode 100644 index 00000000..91616e14 --- /dev/null +++ b/packages/ui/src/stores/appLinkTrustStore.test.ts @@ -0,0 +1,50 @@ +import { beforeEach, describe, expect, test } from 'bun:test'; + +import { useAppLinkTrustStore, MAX_TRUSTED_SCHEMES } from './appLinkTrustStore'; + +describe('app link trust store', () => { + beforeEach(() => { + useAppLinkTrustStore.setState({ trustedSchemes: [] }); + }); + + test('trusts a scheme with case and whitespace normalization', () => { + const store = useAppLinkTrustStore.getState(); + + store.trustScheme(' Obsidian '); + + expect(useAppLinkTrustStore.getState().trustedSchemes).toEqual(['obsidian']); + expect(useAppLinkTrustStore.getState().isSchemeTrusted('OBSIDIAN')).toBe(true); + expect(useAppLinkTrustStore.getState().isSchemeTrusted('linear')).toBe(false); + }); + + test('re-trusting moves the scheme to the front without duplicates', () => { + const store = useAppLinkTrustStore.getState(); + store.trustScheme('obsidian'); + store.trustScheme('linear'); + store.trustScheme('obsidian'); + + expect(useAppLinkTrustStore.getState().trustedSchemes).toEqual(['obsidian', 'linear']); + }); + + test('removes a trusted scheme', () => { + const store = useAppLinkTrustStore.getState(); + store.trustScheme('obsidian'); + store.trustScheme('linear'); + + useAppLinkTrustStore.getState().removeTrustedScheme('obsidian'); + + expect(useAppLinkTrustStore.getState().trustedSchemes).toEqual(['linear']); + expect(useAppLinkTrustStore.getState().isSchemeTrusted('obsidian')).toBe(false); + }); + + test('caps the stored scheme list', () => { + const store = useAppLinkTrustStore.getState(); + for (let index = 0; index < MAX_TRUSTED_SCHEMES + 5; index += 1) { + store.trustScheme(`scheme${index}`); + } + + const schemes = useAppLinkTrustStore.getState().trustedSchemes; + expect(schemes).toHaveLength(MAX_TRUSTED_SCHEMES); + expect(schemes[0]).toBe(`scheme${MAX_TRUSTED_SCHEMES + 4}`); + }); +}); diff --git a/packages/ui/src/stores/appLinkTrustStore.ts b/packages/ui/src/stores/appLinkTrustStore.ts new file mode 100644 index 00000000..b20de2dc --- /dev/null +++ b/packages/ui/src/stores/appLinkTrustStore.ts @@ -0,0 +1,48 @@ +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; + +import { createDeferredSafeJSONStorage } from '@/stores/utils/safeStorage'; + +export const MAX_TRUSTED_SCHEMES = 64; + +interface AppLinkTrustState { + /** Application deep-link schemes (obsidian, vscode, ...) the user chose to always allow. */ + trustedSchemes: string[]; + trustScheme: (scheme: string) => void; + removeTrustedScheme: (scheme: string) => void; + isSchemeTrusted: (scheme: string) => boolean; +} + +const normalizeScheme = (scheme: string): string => scheme.trim().toLowerCase(); + +/** + * Per-device trust for application deep links rendered in chat. Security + * decisions do not roam, so this persists locally through the shared safe + * storage rather than server-synced settings. + */ +export const useAppLinkTrustStore = create()( + persist( + (set, get) => ({ + trustedSchemes: [], + trustScheme: (scheme) => { + const normalized = normalizeScheme(scheme); + if (!normalized) return; + set((state) => { + const next = [normalized, ...state.trustedSchemes.filter((entry) => entry !== normalized)]; + return { trustedSchemes: next.slice(0, MAX_TRUSTED_SCHEMES) }; + }); + }, + removeTrustedScheme: (scheme) => { + const normalized = normalizeScheme(scheme); + set((state) => ({ trustedSchemes: state.trustedSchemes.filter((entry) => entry !== normalized) })); + }, + isSchemeTrusted: (scheme) => get().trustedSchemes.includes(normalizeScheme(scheme)), + }), + { + name: 'app-link-trust-store', + storage: createDeferredSafeJSONStorage(), + version: 1, + partialize: (state) => ({ trustedSchemes: state.trustedSchemes }), + }, + ), +); diff --git a/packages/vscode/CHANGELOG.md b/packages/vscode/CHANGELOG.md index 47e08755..26c4bac6 100644 --- a/packages/vscode/CHANGELOG.md +++ b/packages/vscode/CHANGELOG.md @@ -12,6 +12,7 @@ - If OpenCode restarts while a response is still running, the chat now stops with an interrupted state and a notification to continue instead of hanging silently (thanks to @sum117). - Usage: Z.ai credit limits now appear alongside its other quota windows. - Chat: file paths in messages now open from the session's workspace, even if you last browsed files in another workspace (thanks to @tomzx). +- Chat: app links such as `spotify://` now ask for confirmation before opening another app. You can trust an app link type on one device and manage trusted links in Settings. - While a reply streams, the model status line under the last message now turns into the finished message's info row in place, instead of jumping when the reply completes. - Chat: newly sent messages and syntax-highlighted code blocks no longer briefly flicker. Bash output can also grow with its content instead of being cut off. - Chat: long user messages can be expanded even when their final layout finishes after they first appear. From fe80d246eaf761dcc34a2997575ccc94122805aa Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sun, 23 Aug 2026 02:10:48 +0300 Subject: [PATCH 046/157] release v1.20.0 --- CHANGELOG.md | 4 +++- package.json | 2 +- packages/electron/package.json | 2 +- packages/ui/package.json | 2 +- packages/vscode/CHANGELOG.md | 2 +- packages/vscode/package.json | 2 +- packages/web/package.json | 2 +- 7 files changed, 9 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a0923699..38d653c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,9 @@ All notable changes to this project will be documented in this file. ## [Unreleased] -- **Chat: /btw side questions.** Type `/btw` followed by your question to ask something off-topic in a temporary session forked from the current conversation, so it inherits the full context but leaves the chat itself untouched. The answer streams into a panel above the composer, which talks to that session while the panel is open; you can collapse it to a slim header bar, keep it as a full session, or discard it. The temporary session stays out of the sidebar and session lists until you keep it (thanks to @jaygupta17). +## [1.20.0] - 2026-08-23 + +- **Session: /btw side questions.** Type `/btw` followed by your question to ask something off-topic in a temporary session forked from the current conversation, so it inherits the full context but leaves the chat itself untouched. The answer streams into a panel above the composer, which talks to that session while the panel is open; you can collapse it to a slim header bar, keep it as a full session, or discard it. The temporary session stays out of the sidebar and session lists until you keep it (thanks to @jaygupta17). - **Chat sessions:** start chats without choosing a project. They live in their own Chats section, rather than inheriting a project's repository and worktree context. - **Desktop/Remote instances:** adding an SSH connection now starts from the hosts in your SSH config instead of a blank command field. Ports, install method and passwords moved behind Advanced settings, and each connection shows Connected, Connecting, or Needs attention with the failure text and a button that resolves it. - Desktop/Remote instances: connecting to a remote machine now works when bun, OpenChamber or the opencode CLI live in your home directory rather than on the system path. Installing no longer fails with a permission error, and a missing opencode CLI is now reported before the connection starts instead of as a stack trace. diff --git a/package.json b/package.json index 9e518b9d..6e4cd2b6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "openchamber-monorepo", - "version": "1.19.0", + "version": "1.20.0", "description": "OpenChamber monorepo workspace for web, ui, and desktop runtimes", "private": true, "type": "module", diff --git a/packages/electron/package.json b/packages/electron/package.json index 8432487f..b4d03bec 100644 --- a/packages/electron/package.json +++ b/packages/electron/package.json @@ -1,6 +1,6 @@ { "name": "@openchamber/electron", - "version": "1.19.0", + "version": "1.20.0", "private": true, "description": "Electron desktop runtime for OpenChamber", "author": "OpenChamber", diff --git a/packages/ui/package.json b/packages/ui/package.json index dd218aa6..14eaf104 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@openchamber/ui", - "version": "1.19.0", + "version": "1.20.0", "private": true, "type": "module", "main": "src/main.tsx", diff --git a/packages/vscode/CHANGELOG.md b/packages/vscode/CHANGELOG.md index 26c4bac6..a40c5a1d 100644 --- a/packages/vscode/CHANGELOG.md +++ b/packages/vscode/CHANGELOG.md @@ -1,4 +1,4 @@ -## [Unreleased] +## [1.20.0] - 2026-08-23 - **/btw side questions:** type `/btw` followed by your question to ask something off-topic in a temporary session forked from the current conversation. The answer streams into a panel above the composer; collapse it, keep it as a full session, or discard it without touching the chat (thanks to @jaygupta17). - **Skills catalog:** browse curated GitHub skill collections in a card-based catalog with cross-source search and direct links to each skill's repository. diff --git a/packages/vscode/package.json b/packages/vscode/package.json index fc983df6..4fa8976c 100644 --- a/packages/vscode/package.json +++ b/packages/vscode/package.json @@ -2,7 +2,7 @@ "name": "openchamber", "displayName": "OpenChamber", "description": "%extension.description%", - "version": "1.19.0", + "version": "1.20.0", "publisher": "fedaykindev", "private": true, "repository": { diff --git a/packages/web/package.json b/packages/web/package.json index 3424f2cf..8da2d361 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -1,6 +1,6 @@ { "name": "@openchamber/web", - "version": "1.19.0", + "version": "1.20.0", "private": false, "type": "module", "main": "./server/index.js", From 236accedc498afc6aa8f279fb8753a16e878ea63 Mon Sep 17 00:00:00 2001 From: CodeNebula <121377123+gaojunran@users.noreply.github.com> Date: Sun, 23 Aug 2026 05:53:19 -0700 Subject: [PATCH 047/157] fix(ui): make timeline dialog fit small screens (#3090) * fix(ui): make timeline dialog fit small screens On phones the timeline dialog squeezed the message list to a couple of rows: header, search box, and the fixed Actions/help block consumed almost all of the 70vh dialog height, and the description text could end up underneath the search box. The dialog no longer scrolls as a whole. The header and search stay fixed (shrink-0), the message list is the only scrollable region and takes the remaining height (min-h-0 flex-1), the dialog is taller on small screens (85dvh), the Actions/help footer collapses to a single action row on phones, and the description is hidden on phones only. Message timestamps no longer wrap either: the time column grows from a fixed 64px minimum and forces nowrap, so "11:14 PM" stays on one line. * fix(ui): keep dialog-level scroll fallback in timeline overflow-y-visible removed the reusable dialog's built-in scrolling fallback: the timeline's header, search, and footer are all shrink-0, so under constrained layouts (very short desktop windows, enlarged text) the fixed controls could leave the popup with no way to reach them once the message list collapsed to zero. Revert to overflow-y-auto. The list is still the only flex-1 min-h-0 child and absorbs space first, so normal mobile and desktop layouts do not scroll; only when fixed content exceeds the dialog height does the popup scroll again, keeping every control reachable. --- .../ui/src/components/chat/TimelineDialog.tsx | 102 ++++++++++-------- 1 file changed, 58 insertions(+), 44 deletions(-) diff --git a/packages/ui/src/components/chat/TimelineDialog.tsx b/packages/ui/src/components/chat/TimelineDialog.tsx index 88a03d45..45e0f9f4 100644 --- a/packages/ui/src/components/chat/TimelineDialog.tsx +++ b/packages/ui/src/components/chat/TimelineDialog.tsx @@ -223,20 +223,48 @@ export const TimelineDialog: React.FC = ({ if (!currentSessionId) return null; + const turnActions = ( + <> + + / + + + ); + return ( - - + + {t('chat.timeline.title')} - - {t('chat.timeline.description')} - + {!isMobile && ( + + {t('chat.timeline.description')} + + )} -
+
= ({
{canLoadEarlier && onLoadEarlier && ( -
+
)} -
+
{filteredMessages.length === 0 ? (
{searchQuery ? t('chat.timeline.empty.search') : t('chat.timeline.empty.session')} @@ -312,7 +340,7 @@ export const TimelineDialog: React.FC = ({ onMouseEnter={() => setSelectedIndex(index)} > {messageTime} @@ -373,45 +401,31 @@ export const TimelineDialog: React.FC = ({ )}
-
-

{t('chat.timeline.actions.title')}

-
- - / - + {isMobile ? ( +
+ {turnActions}
-
-
- {t('chat.timeline.help.clickMessage')} + ) : ( +
+

{t('chat.timeline.actions.title')}

+
+ {turnActions}
-
- - {t('chat.timeline.help.undoToPoint')} -
-
- - {t('chat.timeline.help.createSessionFromHere')} +
+
+ {t('chat.timeline.help.clickMessage')} +
+
+ + {t('chat.timeline.help.undoToPoint')} +
+
+ + {t('chat.timeline.help.createSessionFromHere')} +
-
+ )}
); From 9a40eea7c1ad5418af8cf57ae795605485e6b379 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sun, 23 Aug 2026 15:54:18 +0300 Subject: [PATCH 048/157] fix: include archived sessions in header lookup Header now finds the current session in archived sessions too. --- packages/ui/src/components/layout/Header.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/components/layout/Header.tsx b/packages/ui/src/components/layout/Header.tsx index cfc8f555..e6b4ddc0 100644 --- a/packages/ui/src/components/layout/Header.tsx +++ b/packages/ui/src/components/layout/Header.tsx @@ -511,7 +511,8 @@ export const Header: React.FC = ({ const currentGlobalSession = useGlobalSessionsStore(useShallow(React.useCallback( (state): HeaderSessionSnapshot | null => { if (!currentSessionId) return null; - const session = state.activeSessions.find((candidate) => candidate.id === currentSessionId); + const session = [...state.activeSessions, ...state.archivedSessions] + .find((candidate) => candidate.id === currentSessionId); if (!session) return null; const record = session as typeof session & { directory?: string | null; slug?: string | null }; return { From 526bbe246dcb6d5ae49183329f9bcc438d119a49 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sun, 23 Aug 2026 16:01:30 +0300 Subject: [PATCH 049/157] fix: delay sidebar item tooltip hover --- packages/ui/src/components/session/sidebar/sortableItems.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui/src/components/session/sidebar/sortableItems.tsx b/packages/ui/src/components/session/sidebar/sortableItems.tsx index f5d518c0..4795f92e 100644 --- a/packages/ui/src/components/session/sidebar/sortableItems.tsx +++ b/packages/ui/src/components/session/sidebar/sortableItems.tsx @@ -334,7 +334,7 @@ export const SortableProjectItem: React.FC = ({ ) : ( - +
); }} + maxHeightClassName="max-h-[min(400px,calc(100dvh-var(--oc-header-height,56px)-4rem))] flex-1" tooltipsEnabled={agentMenuOpen} onEscape={() => setAgentMenuOpen(false)} /> @@ -2734,7 +2735,7 @@ export const ModelControls: React.FC = ({
- +
@@ -2750,7 +2751,7 @@ export const ModelControls: React.FC = ({ />
- +
{!agentSearchQuery.trim() && defaultAgentName && ( <> diff --git a/packages/ui/src/components/ui/dropdown-menu.tsx b/packages/ui/src/components/ui/dropdown-menu.tsx index fa70b1a7..495ff6bc 100644 --- a/packages/ui/src/components/ui/dropdown-menu.tsx +++ b/packages/ui/src/components/ui/dropdown-menu.tsx @@ -128,6 +128,7 @@ function DropdownMenuContent({ }} className={cn( dropdownMenuPopupClass, + "max-h-[calc(100dvh-var(--oc-header-height,56px)-0.5rem)] overflow-y-auto", className )} {...props} diff --git a/packages/ui/src/components/ui/select.tsx b/packages/ui/src/components/ui/select.tsx index cee5c00f..99673fda 100644 --- a/packages/ui/src/components/ui/select.tsx +++ b/packages/ui/src/components/ui/select.tsx @@ -198,7 +198,7 @@ function SelectContent({ color: 'var(--surface-elevated-foreground)', }} className={cn( - "oc-glass-popover oc-glass-floating pointer-events-auto transition-all duration-150 ease-out data-[starting-style]:opacity-0 data-[starting-style]:scale-95 data-[ending-style]:opacity-0 data-[ending-style]:scale-95 relative z-[120] max-h-[var(--available-height)] min-w-[8rem] origin-[var(--transform-origin)] overflow-x-hidden rounded-xl", + "oc-glass-popover oc-glass-floating pointer-events-auto transition-all duration-150 ease-out data-[starting-style]:opacity-0 data-[starting-style]:scale-95 data-[ending-style]:opacity-0 data-[ending-style]:scale-95 relative z-[120] max-h-[min(var(--available-height),calc(100dvh-var(--oc-header-height,56px)-0.5rem))] min-w-[8rem] origin-[var(--transform-origin)] overflow-x-hidden rounded-xl", !alignItemWithTrigger && "data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1", fitContent && "w-max min-w-0", @@ -208,7 +208,7 @@ function SelectContent({ > Date: Sun, 23 Aug 2026 18:27:35 +0300 Subject: [PATCH 052/157] fix(ui): constrain draft menus to chat area --- packages/ui/src/components/chat/ModelControls.tsx | 10 ++++++---- .../chat/composer/ui/DraftTargetSelectors.tsx | 4 ++-- packages/ui/src/components/ui/dropdown-menu.tsx | 15 +++++++++++++-- packages/ui/src/components/ui/select.tsx | 15 ++++++++++++--- 4 files changed, 33 insertions(+), 11 deletions(-) diff --git a/packages/ui/src/components/chat/ModelControls.tsx b/packages/ui/src/components/chat/ModelControls.tsx index 80d547b2..a290fde8 100644 --- a/packages/ui/src/components/chat/ModelControls.tsx +++ b/packages/ui/src/components/chat/ModelControls.tsx @@ -2342,9 +2342,11 @@ export const ModelControls: React.FC = ({
@@ -2401,7 +2403,7 @@ export const ModelControls: React.FC = ({
); }} - maxHeightClassName="max-h-[min(400px,calc(100dvh-var(--oc-header-height,56px)-4rem))] flex-1" + maxHeightClassName="max-h-[min(400px,calc(var(--available-height)-4rem))] flex-1" tooltipsEnabled={agentMenuOpen} onEscape={() => setAgentMenuOpen(false)} /> @@ -2735,7 +2737,7 @@ export const ModelControls: React.FC = ({
- +
@@ -2751,7 +2753,7 @@ export const ModelControls: React.FC = ({ />
- +
{!agentSearchQuery.trim() && defaultAgentName && ( <> diff --git a/packages/ui/src/components/chat/composer/ui/DraftTargetSelectors.tsx b/packages/ui/src/components/chat/composer/ui/DraftTargetSelectors.tsx index 8c44d434..c6fba04c 100644 --- a/packages/ui/src/components/chat/composer/ui/DraftTargetSelectors.tsx +++ b/packages/ui/src/components/chat/composer/ui/DraftTargetSelectors.tsx @@ -122,7 +122,7 @@ export function DraftTargetSelectors(props: DraftTargetProps) { : } - + {projects.map((project) => ( @@ -144,7 +144,7 @@ export function DraftTargetSelectors(props: DraftTargetProps) { {selectedBranchLabel ?? t('chat.chatInput.branch')} - + {projectRootBranchOption ? ( {t('chat.chatInput.projectRoot')} diff --git a/packages/ui/src/components/ui/dropdown-menu.tsx b/packages/ui/src/components/ui/dropdown-menu.tsx index 495ff6bc..e8fd327f 100644 --- a/packages/ui/src/components/ui/dropdown-menu.tsx +++ b/packages/ui/src/components/ui/dropdown-menu.tsx @@ -13,7 +13,9 @@ type AsChildRenderProps = { type DropdownPortalContextValue = { portalContainer: HTMLElement | null; + collisionBoundary: Element | null; setPortalContainer: (container: HTMLElement | null) => void; + setCollisionBoundary: (boundary: Element | null) => void; }; const DropdownPortalContext = React.createContext(null); @@ -36,10 +38,13 @@ function DropdownMenu({ ...props }: React.ComponentProps) { const [portalContainer, setPortalContainer] = React.useState(null); + const [collisionBoundary, setCollisionBoundary] = React.useState(null); const portalContextValue = React.useMemo(() => ({ portalContainer, + collisionBoundary, setPortalContainer, - }), [portalContainer]); + setCollisionBoundary, + }), [collisionBoundary, portalContainer]); return ( @@ -62,6 +67,7 @@ function DropdownMenuTrigger({ } const element = target instanceof HTMLElement ? target : null; portalContext.setPortalContainer(resolveDialogContainer(element)); + portalContext.setCollisionBoundary(element?.closest('main') ?? null); }, [portalContext]); const r = renderFromAsChild(asChild, children); @@ -89,6 +95,8 @@ type ContentProps = { alignOffset?: number; portalToBody?: boolean; positionerClassName?: string; + constrainToMain?: boolean; + collisionAvoidance?: React.ComponentProps["collisionAvoidance"]; style?: React.CSSProperties; className?: string; children?: React.ReactNode; @@ -103,6 +111,8 @@ function DropdownMenuContent({ alignOffset, portalToBody = false, positionerClassName, + constrainToMain = false, + collisionAvoidance, style, children, onCloseAutoFocus, @@ -118,6 +128,8 @@ function DropdownMenuContent({ align={align} side={side} alignOffset={alignOffset} + collisionBoundary={constrainToMain ? portalContext?.collisionBoundary ?? undefined : undefined} + collisionAvoidance={collisionAvoidance} className={cn("app-region-no-drag z-50", positionerClassName)} > void; + setCollisionBoundary: (boundary: Element | null) => void; }; const SelectPortalContext = React.createContext(null); @@ -44,10 +46,13 @@ function Select({ ...props }: SelectRootProps) { const [portalContainer, setPortalContainer] = React.useState(null); + const [collisionBoundary, setCollisionBoundary] = React.useState(null); const portalContextValue = React.useMemo(() => ({ portalContainer, + collisionBoundary, setPortalContainer, - }), [portalContainer]); + setCollisionBoundary, + }), [collisionBoundary, portalContainer]); const handleValueChange = React.useCallback( (value: unknown, eventDetails: SelectRootChangeEventDetails) => { @@ -119,6 +124,7 @@ function SelectTrigger({ } const element = target instanceof HTMLElement ? target : null; portalContext.setPortalContainer(resolveDialogContainer(element)); + portalContext.setCollisionBoundary(element?.closest('main') ?? null); }, [portalContext]); const asChildRender: AsChildRenderProps | null = asChild && React.isValidElement(children) @@ -164,6 +170,7 @@ type SelectContentExtra = { side?: "top" | "right" | "bottom" | "left"; align?: "start" | "center" | "end"; collisionAvoidance?: React.ComponentProps["collisionAvoidance"]; + constrainToMain?: boolean; }; function SelectContent({ @@ -176,6 +183,7 @@ function SelectContent({ side, align, collisionAvoidance, + constrainToMain = false, ...props }: React.ComponentProps & SelectContentExtra) { const portalContext = React.useContext(SelectPortalContext); @@ -190,6 +198,7 @@ function SelectContent({ side={side} align={align} collisionAvoidance={collisionAvoidance} + collisionBoundary={constrainToMain ? portalContext?.collisionBoundary ?? undefined : undefined} className="absolute z-[120] pointer-events-auto" > Date: Sun, 23 Aug 2026 19:13:14 +0300 Subject: [PATCH 053/157] fix(chat): unify OpenCode notice styling --- packages/ui/src/components/chat/ChatMessage.tsx | 12 ++---------- .../src/components/chat/message/MessageBody.tsx | 17 +++-------------- 2 files changed, 5 insertions(+), 24 deletions(-) diff --git a/packages/ui/src/components/chat/ChatMessage.tsx b/packages/ui/src/components/chat/ChatMessage.tsx index 3fe0ef62..9b39eb34 100644 --- a/packages/ui/src/components/chat/ChatMessage.tsx +++ b/packages/ui/src/components/chat/ChatMessage.tsx @@ -705,30 +705,25 @@ const ChatMessage: React.FC = ({ } if (errorName === 'SessionRetry') { return { - text: `Opencode failed to send a message. Retry attempt info: \n\`${detail}\``, - variant: 'info' as const, + text: `Opencode failed to send a message. Retry attempt info: ${detail}`, }; } if (isLikelyProviderAuthFailure(detail)) { return { text: PROVIDER_AUTH_FAILURE_MESSAGE, - variant: 'error' as const, }; } if (detail.trim().toLowerCase() === 'aborted') { return { text: 'The running turn was stopped before OpenCode could send the next message.', - variant: 'info' as const, }; } return { - text: `Opencode failed to send message with error:\n\`${detail}\``, - variant: 'error' as const, + text: `Opencode failed to send message with error: ${detail}`, }; }, [isUser, message.info]); const assistantErrorText = assistantError?.text; - const assistantErrorVariant = assistantError?.variant; const messageTextContent = React.useMemo(() => { if (isUser) { @@ -1089,7 +1084,6 @@ const ChatMessage: React.FC = ({ contextPinPending={pinPending} onToggleContextPin={canPinIntoContext && messageCreatedAt ? handleToggleContextPin : undefined} errorMessage={assistantErrorText} - errorVariant={assistantErrorVariant} userActionsMode={useExternalUserActionsRow ? 'external-content' : 'inline'} stickyUserHeaderEnabled={stickyUserHeader} /> @@ -1126,7 +1120,6 @@ const ChatMessage: React.FC = ({ contextPinPending={pinPending} onToggleContextPin={canPinIntoContext && messageCreatedAt ? handleToggleContextPin : undefined} errorMessage={assistantErrorText} - errorVariant={assistantErrorVariant} userActionsMode="external-actions" stickyUserHeaderEnabled={stickyUserHeader} /> @@ -1169,7 +1162,6 @@ const ChatMessage: React.FC = ({ agentMention={agentMention} turnGroupingContext={turnGroupingContext} errorMessage={assistantErrorText} - errorVariant={assistantErrorVariant} reviewTransferDirection={reviewTransferDirection} footerProviderID={headerProviderID} footerModelName={headerModelName} diff --git a/packages/ui/src/components/chat/message/MessageBody.tsx b/packages/ui/src/components/chat/message/MessageBody.tsx index dc870847..9def5286 100644 --- a/packages/ui/src/components/chat/message/MessageBody.tsx +++ b/packages/ui/src/components/chat/message/MessageBody.tsx @@ -432,7 +432,6 @@ interface MessageBodyProps { onRevert?: () => void; onFork?: () => void; errorMessage?: string; - errorVariant?: 'error' | 'info'; userActionsMode?: 'inline' | 'external-content' | 'external-actions'; stickyUserHeaderEnabled?: boolean; reviewTransferDirection?: ReviewTransferDirection | null; @@ -1094,7 +1093,6 @@ const AssistantMessageBody = React.memo(({ showReasoningTraces = false, turnGroupingContext, errorMessage, - errorVariant = 'error', reviewTransferDirection = null, contextPinned, contextPinPending, @@ -1704,7 +1702,6 @@ const AssistantMessageBody = React.memo(({ const shouldDeferSortedInlineText = isSortedRenderMode && !hasStopFinish; const showErrorMessage = Boolean(errorMessage); - const errorIconName = errorVariant === 'info' ? 'information' : 'error-warning'; const isPeekSurface = chatSurfaceMode === 'peek'; const shouldShowMessageActions = hasCopyableText && !isPeekSurface; const shouldShowTurnFooter = isLastAssistantInTurn && hasTextContent && (hasStopFinish || Boolean(errorMessage)) && !isPeekSurface; @@ -2217,17 +2214,9 @@ const AssistantMessageBody = React.memo(({ {renderedParts} {showErrorMessage && ( -
-
- +
+
+
Date: Sun, 23 Aug 2026 23:40:32 +0300 Subject: [PATCH 054/157] feat(chat): structured context attachments with metadata round-trip Every user-attached context item (diff/file/plan comments, terminal selections, browser annotations, PR comments and failed checks, linked issues/PRs, and new chat-quote comments from the selection menu) is now sent as its own synthetic text part carrying an openchamberContext metadata payload. The model-facing text keeps the previous wording; the timeline reads the metadata back and renders each item as a context card instead of raw prompt text. Legacy messages still render via the old text sniffing. The selection menu gains a Comment option with an inline multiline input, the quoted fragment stays highlighted while commenting, and on mobile the input overlays the composer pill by rendering inside the composer form. Add to chat is renamed Add to input; the menu is restyled and the mobile Copy tile removed. Terminal drafts move their terminal id out of the language field (persisted-draft migration v3), and the dead preview-console source is deleted. --- packages/ui/src/components/chat/ChatInput.tsx | 42 +- .../components/chat/composer/DOCUMENTATION.md | 12 +- .../__tests__/buildOutgoingMessage.test.ts | 83 +-- .../composer/submit/buildOutgoingMessage.ts | 42 +- .../chat/composer/ui/ComposerContextChips.tsx | 17 +- .../chat/composer/ui/MobilePillComposer.tsx | 3 +- .../chat/message/TextSelectionMenu.tsx | 484 ++++++++++++++---- .../chat/message/normalizeUserDisplayParts.ts | 23 + .../src/components/chat/message/partUtils.ts | 8 + .../chat/message/parts/DOCUMENTATION.md | 8 + .../chat/message/parts/UserContextPart.tsx | 132 +++++ .../chat/message/parts/UserTextPart.tsx | 10 + .../ui/src/components/views/TerminalView.tsx | 3 +- packages/ui/src/index.css | 12 + .../ui/src/lib/browser/annotationOverlay.ts | 9 +- packages/ui/src/lib/i18n/messages/de.ts | 17 +- packages/ui/src/lib/i18n/messages/en.ts | 17 +- packages/ui/src/lib/i18n/messages/es.ts | 17 +- packages/ui/src/lib/i18n/messages/fr.ts | 17 +- packages/ui/src/lib/i18n/messages/ja.ts | 17 +- packages/ui/src/lib/i18n/messages/ko.ts | 17 +- packages/ui/src/lib/i18n/messages/pl.ts | 17 +- packages/ui/src/lib/i18n/messages/pt-BR.ts | 17 +- packages/ui/src/lib/i18n/messages/uk.ts | 17 +- packages/ui/src/lib/i18n/messages/zh-CN.ts | 17 +- packages/ui/src/lib/i18n/messages/zh-TW.ts | 17 +- .../ui/src/lib/messages/contextParts.test.ts | 120 +++++ packages/ui/src/lib/messages/contextParts.ts | 280 ++++++++++ .../ui/src/lib/messages/inlineComments.ts | 72 --- .../ui/src/lib/messages/synthetic.test.ts | 22 + packages/ui/src/lib/messages/synthetic.ts | 9 + packages/ui/src/lib/opencode/client.ts | 13 +- ...seInlineCommentDraftStore.terminal.test.ts | 66 ++- .../src/stores/useInlineCommentDraftStore.ts | 66 ++- packages/ui/src/styles/mobile.css | 20 + packages/ui/src/sync/input-store.ts | 2 + packages/ui/src/sync/session-ui-store.ts | 13 +- 37 files changed, 1440 insertions(+), 318 deletions(-) create mode 100644 packages/ui/src/components/chat/message/parts/UserContextPart.tsx create mode 100644 packages/ui/src/lib/messages/contextParts.test.ts create mode 100644 packages/ui/src/lib/messages/contextParts.ts delete mode 100644 packages/ui/src/lib/messages/inlineComments.ts diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx index 2981248e..1a5a72f4 100644 --- a/packages/ui/src/components/chat/ChatInput.tsx +++ b/packages/ui/src/components/chat/ChatInput.tsx @@ -21,7 +21,6 @@ import { buildLinkedIssue } from '@/lib/linkedIssues'; import { useUserMessageHistory } from "@/sync/sync-context"; import { getInlineCommentDraftKey, useInlineCommentDraftStore, type InlineCommentDraft, type InlineCommentDraftTarget } from '@/stores/useInlineCommentDraftStore'; import { useSnippetsStore } from '@/stores/useSnippetsStore'; -import { appendInlineComments } from '@/lib/messages/inlineComments'; import { renderMagicPrompt } from '@/lib/magicPrompts'; import { startReviewFlow } from '@/lib/reviewFlow'; import { getRuntimeKey } from '@/lib/runtime-switch'; @@ -757,21 +756,21 @@ const ChatInputComponent: React.FC = ({ React.useCallback( (state) => { const drafts = inlineDraftKey ? (state.drafts[inlineDraftKey] ?? []) : []; - let previewConsole = 0; let previewAnnotation = 0; let review = 0; let terminal = 0; let prComment = 0; let prCheck = 0; + let chatQuote = 0; for (const draft of drafts) { - if (draft.source === 'preview-console') previewConsole += 1; - else if (draft.source === 'preview-annotation') previewAnnotation += 1; + if (draft.source === 'preview-annotation') previewAnnotation += 1; else if (draft.source === 'terminal') terminal += 1; else if (draft.source === 'pr-comment') prComment += 1; else if (draft.source === 'pr-check') prCheck += 1; + else if (draft.source === 'chat-quote') chatQuote += 1; else review += 1; } - return `${previewConsole}:${previewAnnotation}:${review}:${terminal}:${prComment}:${prCheck}`; + return `${previewAnnotation}:${review}:${terminal}:${prComment}:${prCheck}:${chatQuote}`; }, [inlineDraftKey] ) @@ -779,11 +778,11 @@ const ChatInputComponent: React.FC = ({ const consumeDrafts = useInlineCommentDraftStore((state) => state.consumeDrafts); const removeInlineCommentDraft = useInlineCommentDraftStore((state) => state.removeDraft); const hasDrafts = draftCount > 0; - const [previewConsoleCount, previewAnnotationCount, reviewCount, terminalContextCount, prCommentCount, prCheckCount] = draftSourceKey.split(':').map((entry) => Number(entry) || 0); + const [previewAnnotationCount, reviewCount, terminalContextCount, prCommentCount, prCheckCount, chatQuoteCount] = draftSourceKey.split(':').map((entry) => Number(entry) || 0); const terminalContextDrafts = terminalContextCount > 0 ? (inlineDraftKey ? useInlineCommentDraftStore.getState().drafts[inlineDraftKey] ?? [] : []).filter((draft) => draft.source === 'terminal') : []; - const removePreviewDrafts = React.useCallback((source: 'preview-console' | 'preview-annotation' | 'pr-comment' | 'pr-check') => { + const removePreviewDrafts = React.useCallback((source: 'preview-annotation' | 'pr-comment' | 'pr-check' | 'chat-quote') => { if (!inlineDraftTarget) return; const drafts = useInlineCommentDraftStore.getState().getDrafts(inlineDraftTarget); for (const draft of drafts) { @@ -797,7 +796,7 @@ const ChatInputComponent: React.FC = ({ if (!inlineDraftTarget) return; const drafts = useInlineCommentDraftStore.getState().getDrafts(inlineDraftTarget); for (const draft of drafts) { - if (draft.source !== 'preview-console' && draft.source !== 'preview-annotation' && draft.source !== 'terminal' && draft.source !== 'pr-comment' && draft.source !== 'pr-check') { + if (draft.source !== 'preview-annotation' && draft.source !== 'terminal' && draft.source !== 'pr-comment' && draft.source !== 'pr-check' && draft.source !== 'chat-quote') { removeInlineCommentDraft(inlineDraftTarget, draft.id); } } @@ -930,12 +929,9 @@ const ChatInputComponent: React.FC = ({ const inputSnapshot = getCurrentInputSnapshot(); if (!inputSnapshot.hasContent || !currentSessionId || !messageQueueTarget) return; - const drafts = inlineDraftTarget ? consumeDrafts(inlineDraftTarget) : []; - - let messageToQueue = inputSnapshot.message.replace(/^\n+|\n+$/g, ''); - if (drafts.length > 0) { - messageToQueue = appendInlineComments(messageToQueue, drafts); - } + // Context drafts stay in their store: the send that later delivers the + // queue consumes them and attaches them as structured context parts. + const messageToQueue = inputSnapshot.message.replace(/^\n+|\n+$/g, ''); const attachmentsToQueue = sanitizeAttachmentsForSend(attachedFiles); addToQueue(messageQueueTarget, { @@ -961,7 +957,7 @@ const ChatInputComponent: React.FC = ({ if (!isMobile) { composerRef.current?.focus(); } - }, [getCurrentInputSnapshot, currentSessionId, messageQueueTarget, inlineDraftTarget, attachedFiles, sanitizeAttachmentsForSend, addToQueue, clearAttachedFiles, isMobile, consumeDrafts, currentProviderId, currentModelId, currentAgentName, currentVariant]); + }, [getCurrentInputSnapshot, currentSessionId, messageQueueTarget, attachedFiles, sanitizeAttachmentsForSend, addToQueue, clearAttachedFiles, isMobile, currentProviderId, currentModelId, currentAgentName, currentVariant]); const handleQueuedMessageEdit = React.useCallback((content: string) => { setMessage(content); @@ -1140,9 +1136,11 @@ const ChatInputComponent: React.FC = ({ } // Inline review comments and synthetic context are consumed before - // assembly so a failed send can restore exactly what it took. + // assembly so a failed send can restore exactly what it took. Context + // drafts ride with whichever send goes out next, including queued + // auto-sends: queueing leaves them in the store on purpose. const syntheticParts = consumePendingSyntheticParts(); - const consumedDraftTarget = queuedOnly ? null : inlineDraftTarget; + const consumedDraftTarget = inlineDraftTarget; const drafts: InlineCommentDraft[] = consumedDraftTarget ? consumeDrafts(consumedDraftTarget) : []; @@ -1157,9 +1155,11 @@ const ChatInputComponent: React.FC = ({ composerAttachments: attachedFiles, inlineComments: drafts, syntheticTexts: syntheticParts?.map((part) => part.text) ?? [], - linkedIssueContext: linkedIssue?.contextText ?? null, + linkedIssue: linkedIssue + ? { number: linkedIssue.number, title: linkedIssue.title, url: linkedIssue.url, contextText: linkedIssue.contextText } + : null, linkedPr: linkedPr - ? { instructions: linkedPr.instructionsText, context: linkedPr.contextText } + ? { number: linkedPr.number, title: linkedPr.title, url: linkedPr.url, instructions: linkedPr.instructionsText, context: linkedPr.contextText } : null, }, { parseAgentMention: (text) => { @@ -1172,8 +1172,6 @@ const ChatInputComponent: React.FC = ({ }, sanitizeAttachments: sanitizeAttachmentsForSend, collectSkillNames: (text) => collectInlineSkillMentions(text, availableSkillNames), - appendComments: (text, comments) => - appendInlineComments(text, comments as InlineCommentDraft[]), buildSkillInstruction: buildSkillMentionInstruction, }); @@ -2667,8 +2665,8 @@ const ChatInputComponent: React.FC = ({ reviewCount={reviewCount} prCommentCount={prCommentCount} prCheckCount={prCheckCount} - previewConsoleCount={previewConsoleCount} previewAnnotationCount={previewAnnotationCount} + chatQuoteCount={chatQuoteCount} draftTarget={inlineDraftTarget} onRemoveDraft={removeInlineCommentDraft} onRemoveReviewDrafts={removeReviewDrafts} diff --git a/packages/ui/src/components/chat/composer/DOCUMENTATION.md b/packages/ui/src/components/chat/composer/DOCUMENTATION.md index f800230b..f3b03529 100644 --- a/packages/ui/src/components/chat/composer/DOCUMENTATION.md +++ b/packages/ui/src/components/chat/composer/DOCUMENTATION.md @@ -124,10 +124,14 @@ and the send path reading the same grammar. drawn caret through a class it only writes while applying an update, so the selection has to be the update that follows the focus. - `submit/buildOutgoingMessage.ts` flattens queued messages, the composer text, - inline comments and context into OpenCode's one-primary-plus-parts shape. The - oldest queued message becomes primary; **inline comments attach to the last - body the user authored** rather than becoming their own part; PR instructions - precede the PR diff. + context drafts and linked references into OpenCode's one-primary-plus-parts + shape. The oldest queued message becomes primary. **Every attached context + item (inline comments, terminal selections, browser annotations, PR context, + linked issue/PR) becomes its own synthetic text part carrying structured + metadata** built by `lib/messages/contextParts.ts`; the timeline reads that + metadata back to render context blocks. PR instructions precede the PR diff. + Queueing a message leaves context drafts in their store on purpose — the send + that later delivers the queue consumes them. - `state/useComposerDraft.ts` — a draft belongs to a (runtime, directory, session) identity. Writes are debounced while typing but forced at every edge where the page may stop running, because a pending timer is not a saved diff --git a/packages/ui/src/components/chat/composer/submit/__tests__/buildOutgoingMessage.test.ts b/packages/ui/src/components/chat/composer/submit/__tests__/buildOutgoingMessage.test.ts index 751f568b..2c219085 100644 --- a/packages/ui/src/components/chat/composer/submit/__tests__/buildOutgoingMessage.test.ts +++ b/packages/ui/src/components/chat/composer/submit/__tests__/buildOutgoingMessage.test.ts @@ -1,6 +1,8 @@ import { describe, expect, test } from 'bun:test'; import type { AttachedFile } from '@/stores/types/sessionTypes'; +import type { InlineCommentDraft } from '@/stores/useInlineCommentDraftStore'; +import { CONTEXT_METADATA_KEY, contextPayloadFromDraft } from '@/lib/messages/contextParts'; import { buildOutgoingMessage, type OutgoingMessageDeps, @@ -26,7 +28,6 @@ const deps = (overrides: Partial = {}): OutgoingMessageDeps }, sanitizeAttachments: (files) => [...(files ?? [])], collectSkillNames: (text) => [...text.matchAll(/\/(\w+)/g)].map((m) => m[1]), - appendComments: (text, comments) => `${text}\n[${comments.length} comments]`, buildSkillInstruction: (names) => (names.length ? `use: ${names.join(',')}` : null), ...overrides, }); @@ -37,7 +38,7 @@ const input = (overrides: Partial = {}): OutgoingMessageIn composerAttachments: [], inlineComments: [], syntheticTexts: [], - linkedIssueContext: null, + linkedIssue: null, linkedPr: null, ...overrides, }); @@ -130,36 +131,51 @@ describe('agent mentions', () => { }); }); -describe('inline comments', () => { - test('attach to the composer text when nothing was queued', () => { +const commentDraft = (overrides: Partial = {}): InlineCommentDraft => ({ + id: 'icd-1', + sessionKey: 's1', + source: 'diff', + fileLabel: 'src/app.ts', + startLine: 3, + endLine: 5, + side: 'modified', + code: 'const x = 1;', + language: 'ts', + text: 'fix this', + createdAt: 1, + ...overrides, +}); + +describe('context drafts', () => { + test('each becomes a synthetic part carrying structured metadata', () => { const result = buildOutgoingMessage(input({ composerText: 'body', - inlineComments: [{}, {}], + inlineComments: [commentDraft(), commentDraft({ id: 'icd-2', source: 'file', side: undefined })], }), deps()); - expect(result.primaryText).toBe('body\n[2 comments]'); + expect(result.primaryText).toBe('body'); + expect(result.additionalParts).toHaveLength(2); + expect(result.additionalParts.every((p) => p.synthetic)).toBe(true); + expect(result.additionalParts[0].metadata?.[CONTEXT_METADATA_KEY]) + .toEqual(contextPayloadFromDraft(commentDraft())); + expect(result.additionalParts[1].metadata?.[CONTEXT_METADATA_KEY]) + .toEqual(contextPayloadFromDraft(commentDraft({ id: 'icd-2', source: 'file', side: undefined }))); + expect(result.additionalParts[0].text).toContain('Comment on `src/app.ts` lines 3-5 (modified):'); + expect(result.additionalParts[0].text).toContain('fix this'); }); - test('attach to the last authored part when messages were queued', () => { + test('context parts precede other synthetic context', () => { const result = buildOutgoingMessage(input({ - queued: [{ content: 'queued' }], - composerText: 'typed', - inlineComments: [{}], + composerText: 'body', + inlineComments: [commentDraft()], + syntheticTexts: ['conflict note'], }), deps()); - expect(result.primaryText).toBe('queued'); - expect(result.additionalParts[0].text).toBe('typed\n[1 comments]'); + expect(result.additionalParts.map((p) => p.text.startsWith('Comment on') ? 'comment' : p.text)) + .toEqual(['comment', 'conflict note']); }); - test('fall back to primary when the queue produced no additional parts', () => { - const result = buildOutgoingMessage(input({ - queued: [{ content: 'only queued' }], - inlineComments: [{}], - }), deps()); - expect(result.primaryText).toBe('only queued\n[1 comments]'); - }); - - test('no comments changes nothing', () => { - expect(buildOutgoingMessage(input({ composerText: 'body' }), deps()).primaryText) - .toBe('body'); + test('no drafts changes nothing', () => { + expect(buildOutgoingMessage(input({ composerText: 'body' }), deps()).additionalParts) + .toEqual([]); }); }); @@ -167,26 +183,31 @@ describe('synthetic context', () => { test('a linked PR sends its instructions before its diff', () => { const result = buildOutgoingMessage(input({ composerText: 'review this', - linkedPr: { instructions: 'how to read it', context: 'the diff' }, + linkedPr: { number: 7, title: 'PR', url: 'https://x/pr/7', instructions: 'how to read it', context: 'the diff' }, }), deps()); expect(result.additionalParts.map((p) => p.text)) .toEqual(['how to read it', 'the diff']); expect(result.additionalParts.every((p) => p.synthetic)).toBe(true); + expect(result.additionalParts[1].metadata?.[CONTEXT_METADATA_KEY]) + .toEqual({ kind: 'github-pr', number: 7, title: 'PR', url: 'https://x/pr/7' }); }); test('a linked issue is sent as context', () => { const result = buildOutgoingMessage(input({ composerText: 'fix it', - linkedIssueContext: 'issue body', + linkedIssue: { number: 3, title: 'Bug', url: 'https://x/issues/3', contextText: 'issue body' }, }), deps()); - expect(result.additionalParts).toEqual([{ text: 'issue body', synthetic: true }]); + expect(result.additionalParts).toHaveLength(1); + expect(result.additionalParts[0].text).toBe('issue body'); + expect(result.additionalParts[0].metadata?.[CONTEXT_METADATA_KEY]) + .toEqual({ kind: 'github-issue', number: 3, title: 'Bug', url: 'https://x/issues/3' }); }); test('synthetic texts precede the linked references', () => { const result = buildOutgoingMessage(input({ composerText: 'x', syntheticTexts: ['conflict note'], - linkedIssueContext: 'issue body', + linkedIssue: { number: 3, title: 'Bug', url: 'https://x/issues/3', contextText: 'issue body' }, }), deps()); expect(result.additionalParts.map((p) => p.text)) .toEqual(['conflict note', 'issue body']); @@ -211,7 +232,9 @@ describe('synthetic context', () => { }); test('context alone is still worth sending', () => { - const result = buildOutgoingMessage(input({ linkedIssueContext: 'issue body' }), deps()); + const result = buildOutgoingMessage(input({ + linkedIssue: { number: 3, title: 'Bug', url: 'https://x/issues/3', contextText: 'issue body' }, + }), deps()); expect(result.isEmpty).toBe(false); }); @@ -230,8 +253,8 @@ describe('full assembly order', () => { queued: [{ content: 'q1' }, { content: 'q2' }], composerText: 'typed /deploy', syntheticTexts: ['synthetic'], - linkedIssueContext: 'issue', - linkedPr: { instructions: 'pr-how', context: 'pr-diff' }, + linkedIssue: { number: 3, title: 'Bug', url: 'https://x/issues/3', contextText: 'issue' }, + linkedPr: { number: 7, title: 'PR', url: 'https://x/pr/7', instructions: 'pr-how', context: 'pr-diff' }, }), deps()); expect(result.primaryText).toBe('q1'); diff --git a/packages/ui/src/components/chat/composer/submit/buildOutgoingMessage.ts b/packages/ui/src/components/chat/composer/submit/buildOutgoingMessage.ts index e92395aa..15c33d6d 100644 --- a/packages/ui/src/components/chat/composer/submit/buildOutgoingMessage.ts +++ b/packages/ui/src/components/chat/composer/submit/buildOutgoingMessage.ts @@ -14,12 +14,16 @@ */ import type { AttachedFile } from '@/stores/types/sessionTypes'; +import type { InlineCommentDraft } from '@/stores/useInlineCommentDraftStore'; +import { contextPayloadFromDraft, createContextPart, type ContextPartMetadata } from '@/lib/messages/contextParts'; export interface OutgoingPart { text: string; attachments?: AttachedFile[]; /** Synthetic parts are context for the model, not shown as user content. */ synthetic?: boolean; + /** Structured context (see contextParts.ts), persisted with the part. */ + metadata?: ContextPartMetadata; } export interface OutgoingMessage { @@ -43,12 +47,12 @@ export interface OutgoingMessageInput { /** The composer's own text, or null when this send skips it. */ composerText: string | null; composerAttachments: readonly AttachedFile[]; - /** Inline review comments, appended to the user's last authored text. */ - inlineComments: readonly unknown[]; + /** Context drafts (code comments, terminal selections, annotations, PR context). */ + inlineComments: readonly InlineCommentDraft[]; /** Synthetic context produced elsewhere (conflict resolution, and such). */ syntheticTexts: readonly string[]; - linkedIssueContext: string | null; - linkedPr: { instructions: string; context: string } | null; + linkedIssue: { number: number; title: string; url: string; contextText: string } | null; + linkedPr: { number: number; title: string; url: string; instructions: string; context: string } | null; } /** @@ -64,8 +68,6 @@ export interface OutgoingMessageDeps { sanitizeAttachments: (files: readonly AttachedFile[] | undefined) => AttachedFile[]; /** Skills named inline with `/name`. */ collectSkillNames: (text: string) => string[]; - /** Append inline review comments to a message body. */ - appendComments: (text: string, comments: readonly unknown[]) => string; /** Instruction telling the model which skills the user named. */ buildSkillInstruction: (names: string[]) => string | null; } @@ -134,33 +136,29 @@ export function buildOutgoingMessage( } } - // Inline comments attach to the last thing the user authored, so they read - // as a continuation of it rather than as a separate turn. - if (input.inlineComments.length > 0) { - const lastAuthored = input.queued.length > 0 && additionalParts.length > 0 - ? additionalParts[additionalParts.length - 1] - : null; - if (lastAuthored) { - lastAuthored.text = deps.appendComments(lastAuthored.text, input.inlineComments); - } else { - primaryText = deps.appendComments(primaryText, input.inlineComments); - } + // Everything below is context for the model, never plain user text. Each + // attached context item becomes its own synthetic part carrying structured + // metadata, so the timeline can render it as a context block after the + // server echoes the message back. + for (const draft of input.inlineComments) { + additionalParts.push(createContextPart(contextPayloadFromDraft(draft))); } - // Everything below is context for the model, never user-visible content. for (const text of input.syntheticTexts) { additionalParts.push({ text, synthetic: true }); } - if (input.linkedIssueContext) { - additionalParts.push({ text: input.linkedIssueContext, synthetic: true }); + if (input.linkedIssue) { + const { number, title, url, contextText } = input.linkedIssue; + additionalParts.push(createContextPart({ kind: 'github-issue', number, title, url }, contextText)); } if (input.linkedPr) { // Instructions before context: the model is told how to read the diff // before it is given the diff. - additionalParts.push({ text: input.linkedPr.instructions, synthetic: true }); - additionalParts.push({ text: input.linkedPr.context, synthetic: true }); + const { number, title, url, instructions, context } = input.linkedPr; + additionalParts.push({ text: instructions, synthetic: true }); + additionalParts.push(createContextPart({ kind: 'github-pr', number, title, url }, context)); } const skillInstruction = deps.buildSkillInstruction(skillNames); diff --git a/packages/ui/src/components/chat/composer/ui/ComposerContextChips.tsx b/packages/ui/src/components/chat/composer/ui/ComposerContextChips.tsx index d731e2b0..32373b3f 100644 --- a/packages/ui/src/components/chat/composer/ui/ComposerContextChips.tsx +++ b/packages/ui/src/components/chat/composer/ui/ComposerContextChips.tsx @@ -20,12 +20,12 @@ export interface ComposerContextChipsProps { reviewCount: number; prCommentCount: number; prCheckCount: number; - previewConsoleCount: number; previewAnnotationCount: number; + chatQuoteCount: number; draftTarget: InlineCommentDraftTarget | null; onRemoveDraft: (target: InlineCommentDraftTarget, draftId: string) => void; onRemoveReviewDrafts: () => void; - onRemovePreviewDrafts: (source: 'preview-console' | 'preview-annotation' | 'pr-comment' | 'pr-check') => void; + onRemovePreviewDrafts: (source: 'preview-annotation' | 'pr-comment' | 'pr-check' | 'chat-quote') => void; colors: Theme['colors']; } @@ -72,8 +72,8 @@ export function ComposerContextChips(props: ComposerContextChipsProps) { reviewCount, prCommentCount, prCheckCount, - previewConsoleCount, previewAnnotationCount, + chatQuoteCount, draftTarget, onRemoveDraft, onRemoveReviewDrafts, @@ -141,13 +141,14 @@ export function ComposerContextChips(props: ComposerContextChipsProps) { /> ) : null} - {previewConsoleCount > 0 ? ( + {chatQuoteCount > 0 ? ( onRemovePreviewDrafts('preview-console')} + label={t('chat.chatInput.chatQuoteContext')} + count={chatQuoteCount} + removeLabel={t('chat.chatInput.chatQuoteContextRemove')} + onRemove={() => onRemovePreviewDrafts('chat-quote')} colors={colors} + icon={} /> ) : null} diff --git a/packages/ui/src/components/chat/composer/ui/MobilePillComposer.tsx b/packages/ui/src/components/chat/composer/ui/MobilePillComposer.tsx index 831efacb..6bb2e10b 100644 --- a/packages/ui/src/components/chat/composer/ui/MobilePillComposer.tsx +++ b/packages/ui/src/components/chat/composer/ui/MobilePillComposer.tsx @@ -84,7 +84,8 @@ export function MobilePillComposer(props: MobilePillComposerProps) { />
( @@ -46,6 +48,103 @@ export const TextSelectionMenu: React.FC = ({ containerR const [position, setPosition] = React.useState({ x: 0, y: 0, show: false }); const [selectedText, setSelectedText] = React.useState(''); const [selectedTextMarkdown, setSelectedTextMarkdown] = React.useState(''); + const [selectedMessageId, setSelectedMessageId] = React.useState(null); + const [commentMode, setCommentMode] = React.useState(false); + const commentModeRef = React.useRef(false); + const [commentText, setCommentText] = React.useState(''); + const commentInputRef = React.useRef(null); + + // While the comment input owns focus the native selection is gone, so the + // quoted fragment is repainted with our own overlay rectangles. Raw + // Range.getClientRects() mixes block-container boxes with text boxes and + // the translucent overlaps paint double-dark bands, so the rects are taken + // from the text nodes only and merged into one strip per visual line. + const [commentRects, setCommentRects] = React.useState(null); + const updateCommentRects = React.useCallback(() => { + const range = pendingSelectionRef.current?.range; + if (!range) { + setCommentRects(null); + return; + } + + const textRects: DOMRect[] = []; + const pushNodeRects = (node: Text) => { + const nodeRange = document.createRange(); + nodeRange.selectNodeContents(node); + if (node === range.startContainer) nodeRange.setStart(node, range.startOffset); + if (node === range.endContainer) nodeRange.setEnd(node, range.endOffset); + // Text rects cover only the glyph box; the native selection paints the + // full line box, so each rect is stretched to its element's line-height. + const lineHeight = node.parentElement + ? Number.parseFloat(window.getComputedStyle(node.parentElement).lineHeight) + : Number.NaN; + for (const rect of nodeRange.getClientRects()) { + if (rect.width <= 0 || rect.height <= 0) continue; + if (Number.isFinite(lineHeight) && lineHeight > rect.height) { + const expand = (lineHeight - rect.height) / 2; + textRects.push(new DOMRect(rect.left, rect.top - expand, rect.width, lineHeight)); + } else { + textRects.push(rect); + } + } + }; + const root = range.commonAncestorContainer; + if (root instanceof Text) { + pushNodeRects(root); + } else { + // SAFETY: the walker is created with SHOW_TEXT, so every node it + // yields is a Text node. + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); + for (let node = walker.nextNode(); node; node = walker.nextNode()) { + if (range.intersectsNode(node)) pushNodeRects(node as Text); + } + } + + // Merge rects that sit on the same visual line into one strip, the way + // the native selection paints a line box. + const lines: Array<{ left: number; right: number; top: number; bottom: number }> = []; + for (const rect of textRects) { + const line = lines.find((candidate) => ( + Math.abs(candidate.top - rect.top) < 6 && Math.abs(candidate.bottom - rect.bottom) < 6 + )); + if (line) { + line.left = Math.min(line.left, rect.left); + line.right = Math.max(line.right, rect.right); + line.top = Math.min(line.top, rect.top); + line.bottom = Math.max(line.bottom, rect.bottom); + } else { + lines.push({ left: rect.left, right: rect.right, top: rect.top, bottom: rect.bottom }); + } + } + setCommentRects(lines.map((line) => new DOMRect(line.left, line.top, line.right - line.left, line.bottom - line.top))); + }, []); + + React.useEffect(() => { + if (!commentMode) return; + let frame: number | null = null; + const scheduleUpdate = () => { + if (frame !== null) return; + frame = window.requestAnimationFrame(() => { + frame = null; + updateCommentRects(); + }); + }; + document.addEventListener('scroll', scheduleUpdate, { capture: true, passive: true }); + window.addEventListener('resize', scheduleUpdate); + return () => { + if (frame !== null) window.cancelAnimationFrame(frame); + document.removeEventListener('scroll', scheduleUpdate, { capture: true }); + window.removeEventListener('resize', scheduleUpdate); + }; + }, [commentMode, updateCommentRects]); + + // Grow the comment box with its content, up to five lines. + const resizeCommentInput = React.useCallback(() => { + const element = commentInputRef.current; + if (!element) return; + element.style.height = 'auto'; + element.style.height = `${Math.min(element.scrollHeight, 120)}px`; + }, []); const isDraggingRef = React.useRef(false); const [isOpening, setIsOpening] = React.useState(false); const [isAddingToNotes, setIsAddingToNotes] = React.useState(false); @@ -57,6 +156,8 @@ export const TextSelectionMenu: React.FC = ({ containerR const isMenuVisibleRef = React.useRef(false); const createSession = useSessionUIStore((state) => state.createSession); const currentSessionId = useSessionUIStore((state) => state.currentSessionId); + const newSessionDraftOpen = useSessionUIStore((state) => state.newSessionDraft?.open); + const addContextDraft = useInlineCommentDraftStore((state) => state.addDraft); const setPendingInputText = useInputStore((state) => state.setPendingInputText); const isMobile = useUIStore((state) => state.isMobile); const projects = useProjectsStore((state) => state.projects); @@ -64,6 +165,39 @@ export const TextSelectionMenu: React.FC = ({ containerR const effectiveDirectory = useEffectiveDirectory(); const sessions = useSessions(); + // Mobile: the comment bar is rendered inside the composer form (its + // positioning context), so it inherits the runtime's own keyboard handling + // — browser viewport resizing and Capacitor choreography alike. This effect + // only centers it on the composer pill in the form's local coordinates; no + // viewport math, which Safari's keyboard handling reliably breaks for + // fixed elements. + React.useEffect(() => { + if (!commentMode || !isMobile) return; + const update = () => { + const element = menuRef.current; + const host = element?.offsetParent; + if (!element || !host) return; + const pill = document.querySelector('[data-mobile-composer-pill="true"]') + ?? document.querySelector('[data-chat-input="true"]'); + const pillRect = pill?.getBoundingClientRect(); + if (!pillRect || pillRect.height <= 0) return; + const hostRect = host.getBoundingClientRect(); + element.style.top = `${pillRect.top - hostRect.top + (pillRect.height - element.offsetHeight) / 2}px`; + element.style.left = `${pillRect.left - hostRect.left}px`; + element.style.width = `${pillRect.width}px`; + element.style.bottom = 'auto'; + }; + update(); + const raf = window.requestAnimationFrame(update); + // The composer relayouts with its own transitions and timeouts that emit + // no event; a light poll keeps the overlay glued to the pill. + const poll = window.setInterval(update, 200); + return () => { + window.cancelAnimationFrame(raf); + window.clearInterval(poll); + }; + }, [commentMode, isMobile]); + React.useEffect(() => { isMenuVisibleRef.current = position.show; }, [position.show]); @@ -83,6 +217,7 @@ export const TextSelectionMenu: React.FC = ({ containerR const hideMenu = React.useCallback(() => { pendingSelectionRef.current = null; + setCommentRects(null); if (!isMenuVisibleRef.current) { return; @@ -97,6 +232,10 @@ export const TextSelectionMenu: React.FC = ({ containerR setPosition((prev) => ({ ...prev, show: false })); setSelectedText(''); setSelectedTextMarkdown(''); + setSelectedMessageId(null); + setCommentMode(false); + commentModeRef.current = false; + setCommentText(''); isMenuVisibleRef.current = false; }, []); @@ -121,7 +260,7 @@ export const TextSelectionMenu: React.FC = ({ containerR const showMenu = React.useCallback(() => { if (!pendingSelectionRef.current) return; - const { plainText, markdownText, rect } = pendingSelectionRef.current; + const { plainText, markdownText, rect, messageId } = pendingSelectionRef.current; const shouldAnimateIn = !position.show; // Position menu above the selection @@ -132,6 +271,7 @@ export const TextSelectionMenu: React.FC = ({ containerR setSelectedText(plainText); setSelectedTextMarkdown(markdownText); + setSelectedMessageId(messageId); setPosition({ x: menuX, y: menuY, @@ -187,6 +327,11 @@ export const TextSelectionMenu: React.FC = ({ containerR }, [getDesktopClampedX, isMobile, position.show]); const handleSelectionChange = React.useCallback(() => { + // While the comment input is open, clicking or typing in it collapses the + // text selection; the captured quote must survive that. + if (commentModeRef.current) { + return; + } const selection = window.getSelection(); const container = containerRef.current; @@ -221,10 +366,15 @@ export const TextSelectionMenu: React.FC = ({ containerR const rect = range.getBoundingClientRect(); // Store the selection but don't show menu yet if dragging + const anchorElement = range.commonAncestorContainer instanceof Element + ? range.commonAncestorContainer + : range.commonAncestorContainer.parentElement; pendingSelectionRef.current = { plainText: text, markdownText: rangeToMarkdown(range, text), rect, + messageId: anchorElement?.closest('[data-message-id]')?.getAttribute('data-message-id') ?? null, + range: range.cloneRange(), }; // Only show menu if we're not currently dragging @@ -238,7 +388,12 @@ export const TextSelectionMenu: React.FC = ({ containerR if (!container) return; // Track when dragging starts - const handleMouseDown = () => { + const handleMouseDown = (event: MouseEvent) => { + // SAFETY: a MouseEvent target inside the document is always a Node; + // `contains` only needs that. + if (commentModeRef.current && menuRef.current?.contains(event.target as Node)) { + return; + } isDraggingRef.current = true; hideMenu(); }; @@ -254,6 +409,11 @@ export const TextSelectionMenu: React.FC = ({ containerR // Small delay to ensure selection is finalized mouseUpTimeoutRef.current = window.setTimeout(() => { mouseUpTimeoutRef.current = null; + // The click that opened the comment input cleared the selection on + // purpose; the input must survive this deferred check. + if (commentModeRef.current) { + return; + } const selection = window.getSelection(); if (selection && selection.toString().trim()) { showMenu(); @@ -275,7 +435,7 @@ export const TextSelectionMenu: React.FC = ({ containerR if ( menuRef.current && !menuRef.current.contains(e.target as Node) && - !window.getSelection()?.toString().trim() + (commentModeRef.current || !window.getSelection()?.toString().trim()) ) { hideMenu(); } @@ -310,6 +470,38 @@ export const TextSelectionMenu: React.FC = ({ containerR }); }, [selectedTextMarkdown, setPendingInputText, hideMenu]); + const handleOpenComment = React.useCallback(() => { + if (!selectedTextMarkdown) return; + setCommentMode(true); + commentModeRef.current = true; + updateCommentRects(); + window.getSelection()?.removeAllRanges(); + queueMicrotask(() => { + commentInputRef.current?.focus(); + }); + }, [selectedTextMarkdown, updateCommentRects]); + + const handleAttachComment = React.useCallback(() => { + const sessionKey = currentSessionId ?? (newSessionDraftOpen ? 'draft' : null); + if (!selectedTextMarkdown || !sessionKey || !effectiveDirectory) { + hideMenu(); + return; + } + addContextDraft({ directory: effectiveDirectory, sessionKey }, { + source: 'chat-quote', + fileLabel: selectedMessageId ?? '', + startLine: 1, + endLine: 1, + code: selectedTextMarkdown, + language: '', + text: commentText.trim(), + }); + hideMenu(); + queueMicrotask(() => { + focusChatInput(); + }); + }, [addContextDraft, commentText, currentSessionId, effectiveDirectory, hideMenu, newSessionDraftOpen, selectedMessageId, selectedTextMarkdown]); + const handleCreateNewSession = React.useCallback(async () => { if (!selectedText) return; @@ -322,18 +514,6 @@ export const TextSelectionMenu: React.FC = ({ containerR window.getSelection()?.removeAllRanges(); }, [selectedText, createSession, setPendingInputText, hideMenu]); - const handleCopy = React.useCallback(async () => { - if (!selectedText) return; - - const result = await copyTextToClipboard(selectedText); - if (!result.ok) { - console.error('Failed to copy:', result.error); - } - - hideMenu(); - window.getSelection()?.removeAllRanges(); - }, [selectedText, hideMenu]); - const currentSession = React.useMemo(() => { if (!currentSessionId) { return null; @@ -390,15 +570,110 @@ export const TextSelectionMenu: React.FC = ({ containerR if (!position.show) return null; + const commentHighlightOverlay = commentMode && commentRects && commentRects.length > 0 + ? createPortal( +
+ {commentRects.map((rect, index) => ( +
+ ))} +
, + document.body, + ) + : null; + + const commentInput = ( +
+