diff --git a/.gitignore b/.gitignore index bdedfe006..7ce8fdca2 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,5 @@ __screenshots__/ /browser-sidebar-comment-preload.js.map /preload.js.map /bootstrap.js.map +/.teacode/ +/.teacode-*/ diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 00242c719..8a0124a74 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@t3tools/desktop", - "version": "0.9.3", + "version": "0.9.4", "private": true, "main": "dist-electron/main.js", "scripts": { diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index d12d21b85..103c97440 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -1106,12 +1106,6 @@ function configureApplicationMenu(): void { { label: "View", submenu: [ - { - label: "New Terminal Tab", - ...acceleratorProps("CmdOrCtrl+T"), - click: () => dispatchMenuAction("new-terminal-tab"), - }, - { type: "separator" }, { label: "Toggle Sidebar", ...acceleratorProps("CmdOrCtrl+B"), @@ -2562,12 +2556,12 @@ function getIconOption(): { icon: string } | Record { // transparent (`#00000000`) over the vibrancy material. Windows/Linux have no vibrancy: // a transparent window there leaves backdrop-filter surfaces bleeding through and, on // fractional DPI, rendering blurry. So off macOS we create an opaque window and skip the -// macOS-only options. The background tracks the OS light/dark appearance purely to avoid -// a bright flash before the renderer paints — the window is shown only after first paint -// (`show: false`), so this color is not expected to match a custom in-app theme exactly. +// macOS-only options. The background is a fixed dark tone purely to avoid a flash before +// the renderer paints — the app is dark-only, and the window is shown only after first +// paint (`show: false`), so this color need not match the in-app surface exactly. function getWindowMaterialOptions(): BrowserWindowConstructorOptions { if (process.platform !== "darwin") { - return { backgroundColor: nativeTheme.shouldUseDarkColors ? "#181818" : "#ffffff" }; + return { backgroundColor: "#181818" }; } return { vibrancy: "under-window", @@ -2599,6 +2593,9 @@ function getTitleBarOptions(): BrowserWindowConstructorOptions { } function createWindow(): BrowserWindow { + // Let the renderer decide the theme via the setTheme IPC call. + // Start with system default; the renderer will override on first paint. + const window = new BrowserWindow({ width: 1100, height: 780, diff --git a/apps/server/package.json b/apps/server/package.json index 473b72592..748cfdfca 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -1,6 +1,6 @@ { "name": "t3", - "version": "0.9.3", + "version": "0.9.4", "license": "MIT", "repository": { "type": "git", diff --git a/apps/server/src/keybindings.test.ts b/apps/server/src/keybindings.test.ts index 6faa6f478..35fcfaca1 100644 --- a/apps/server/src/keybindings.test.ts +++ b/apps/server/src/keybindings.test.ts @@ -77,12 +77,12 @@ it.layer(NodeServices.layer)("keybindings", (it) => { Effect.sync(() => { const compiled = compileResolvedKeybindingRule({ key: "mod+d", - command: "terminal.split", + command: "chat.split", when: "terminalOpen && !terminalFocus", }); assert.deepEqual(compiled, { - command: "terminal.split", + command: "chat.split", shortcut: { key: "d", metaKey: false, @@ -106,7 +106,7 @@ it.layer(NodeServices.layer)("keybindings", (it) => { it.effect("encodes resolved plus-key shortcuts", () => Effect.gen(function* () { const encoded = yield* Schema.encodeEffect(ResolvedKeybindingFromConfig)({ - command: "terminal.toggle", + command: "sidebar.toggle", shortcut: { key: "+", metaKey: false, @@ -118,7 +118,7 @@ it.layer(NodeServices.layer)("keybindings", (it) => { }); assert.equal(encoded.key, "mod++"); - assert.equal(encoded.command, "terminal.toggle"); + assert.equal(encoded.command, "sidebar.toggle"); }), ); @@ -127,14 +127,14 @@ it.layer(NodeServices.layer)("keybindings", (it) => { assert.isNull( compileResolvedKeybindingRule({ key: "mod+shift+d+o", - command: "terminal.new", + command: "chat.new", }), ); assert.isNull( compileResolvedKeybindingRule({ key: "mod+d", - command: "terminal.split", + command: "chat.split", when: "terminalFocus && (", }), ); @@ -142,7 +142,7 @@ it.layer(NodeServices.layer)("keybindings", (it) => { assert.isNull( compileResolvedKeybindingRule({ key: "mod+d", - command: "terminal.split", + command: "chat.split", when: `${"!".repeat(300)}terminalFocus`, }), ); @@ -258,7 +258,7 @@ it.layer(NodeServices.layer)("keybindings", (it) => { const { keybindingsConfigPath } = yield* ServerConfig; yield* fs.writeFileString( keybindingsConfigPath, - JSON.stringify({ keybindings: [{ key: "mod+9", command: "terminal.toggle" }] }), + JSON.stringify({ keybindings: [{ key: "mod+9", command: "sidebar.toggle" }] }), ); const configState = yield* Effect.gen(function* () { @@ -268,7 +268,7 @@ it.layer(NodeServices.layer)("keybindings", (it) => { assert.deepEqual(configState.issues, []); assert.isTrue( configState.keybindings.some( - (entry) => entry.command === "terminal.toggle" && entry.shortcut.key === "9", + (entry) => entry.command === "sidebar.toggle" && entry.shortcut.key === "9", ), ); @@ -279,7 +279,7 @@ it.layer(NodeServices.layer)("keybindings", (it) => { const persisted = yield* readKeybindingsConfig(keybindingsConfigPath); assert.isTrue( - persisted.some((entry) => entry.key === "mod+9" && entry.command === "terminal.toggle"), + persisted.some((entry) => entry.key === "mod+9" && entry.command === "sidebar.toggle"), ); }).pipe(Effect.provide(makeKeybindingsLayer())), ); @@ -290,7 +290,7 @@ it.layer(NodeServices.layer)("keybindings", (it) => { const { keybindingsConfigPath } = yield* ServerConfig; yield* fs.writeFileString( keybindingsConfigPath, - '{"key":"mod+9","command":"terminal.toggle"}', + '{"key":"mod+9","command":"sidebar.toggle"}', ); const configState = yield* Effect.gen(function* () { @@ -300,7 +300,7 @@ it.layer(NodeServices.layer)("keybindings", (it) => { assert.deepEqual(configState.issues, []); assert.isTrue( configState.keybindings.some( - (entry) => entry.command === "terminal.toggle" && entry.shortcut.key === "9", + (entry) => entry.command === "sidebar.toggle" && entry.shortcut.key === "9", ), ); }).pipe(Effect.provide(makeKeybindingsLayer())), @@ -333,8 +333,8 @@ it.layer(NodeServices.layer)("keybindings", (it) => { yield* fs.writeFileString( keybindingsConfigPath, JSON.stringify([ - { key: "mod+j", command: "terminal.toggle" }, - { key: "mod+shift+d+o", command: "terminal.new" }, + { key: "mod+j", command: "sidebar.toggle" }, + { key: "mod+shift+d+o", command: "chat.new" }, { key: "mod+x", command: "invalid.command" }, ]), ); @@ -344,7 +344,7 @@ it.layer(NodeServices.layer)("keybindings", (it) => { return yield* keybindings.loadConfigState; }); - assert.isTrue(configState.keybindings.some((entry) => entry.command === "terminal.toggle")); + assert.isTrue(configState.keybindings.some((entry) => entry.command === "sidebar.toggle")); assert.isFalse( configState.keybindings.some((entry) => String(entry.command) === "invalid.command"), ); @@ -448,7 +448,7 @@ it.layer(NodeServices.layer)("keybindings", (it) => { // including one the user rebound to a custom key, plus a non-creation command. yield* writeKeybindingsConfig(keybindingsConfigPath, [ { key: "mod+n", command: "chat.new", when: "!terminalFocus" }, - { key: "mod+shift+k", command: "chat.newTerminal", when: "!terminalFocus" }, + { key: "mod+shift+k", command: "chat.newChat", when: "!terminalFocus" }, { key: "mod+shift+u", command: "settings.usage", when: "!terminalFocus" }, ]); @@ -471,7 +471,7 @@ it.layer(NodeServices.layer)("keybindings", (it) => { persisted.some( (entry) => entry.key === "mod+shift+k" && - entry.command === "chat.newTerminal" && + entry.command === "chat.newChat" && entry.when === "!terminalFocus || isMac", ), ); @@ -596,7 +596,7 @@ it.layer(NodeServices.layer)("keybindings", (it) => { Effect.gen(function* () { const { keybindingsConfigPath } = yield* ServerConfig; yield* writeKeybindingsConfig(keybindingsConfigPath, [ - { key: "mod+shift+t", command: "terminal.toggle" }, + { key: "mod+shift+t", command: "sidebar.toggle" }, { key: "mod+shift+r", command: "script.run-tests.run" }, ]); @@ -608,17 +608,13 @@ it.layer(NodeServices.layer)("keybindings", (it) => { const persisted = yield* readKeybindingsConfig(keybindingsConfigPath); const byCommand = new Map(persisted.map((entry) => [entry.command, entry])); - const persistedToggle = byCommand.get("terminal.toggle"); + const persistedToggle = byCommand.get("sidebar.toggle"); assert.isNotNull(persistedToggle); assert.equal(persistedToggle?.key, "mod+shift+t"); assert.isFalse( - persisted.some((entry) => entry.command === "terminal.toggle" && entry.key === "mod+j"), + persisted.some((entry) => entry.command === "sidebar.toggle" && entry.key === "mod+b"), ); - const persistedNewTerminalThread = byCommand.get("chat.newTerminal"); - assert.isNotNull(persistedNewTerminalThread); - assert.equal(persistedNewTerminalThread?.key, "mod+shift+t"); - for (const defaultRule of DEFAULT_KEYBINDINGS) { assert.isTrue(byCommand.has(defaultRule.command), `expected ${defaultRule.command}`); } @@ -635,7 +631,7 @@ it.layer(NodeServices.layer)("keybindings", (it) => { return Effect.gen(function* () { const { keybindingsConfigPath } = yield* ServerConfig; yield* writeKeybindingsConfig(keybindingsConfigPath, [ - { key: "mod+j", command: "script.custom-action.run" }, + { key: "mod+b", command: "script.custom-action.run" }, ]); yield* Effect.gen(function* () { @@ -644,7 +640,7 @@ it.layer(NodeServices.layer)("keybindings", (it) => { }); const persisted = yield* readKeybindingsConfig(keybindingsConfigPath); - assert.isFalse(persisted.some((entry) => entry.command === "terminal.toggle")); + assert.isFalse(persisted.some((entry) => entry.command === "sidebar.toggle")); assert.isTrue(persisted.some((entry) => entry.command === "script.custom-action.run")); assert.isTrue( @@ -666,7 +662,7 @@ it.layer(NodeServices.layer)("keybindings", (it) => { Effect.gen(function* () { const { keybindingsConfigPath } = yield* ServerConfig; yield* writeKeybindingsConfig(keybindingsConfigPath, [ - { key: "mod+j", command: "terminal.toggle" }, + { key: "mod+j", command: "sidebar.toggle" }, ]); const resolved = yield* Effect.gen(function* () { @@ -681,7 +677,7 @@ it.layer(NodeServices.layer)("keybindings", (it) => { const persistedView = persisted.map(({ key, command }) => ({ key, command })); assert.deepEqual(persistedView, [ - { key: "mod+j", command: "terminal.toggle" }, + { key: "mod+j", command: "sidebar.toggle" }, { key: "mod+shift+r", command: "script.run-tests.run" }, ]); assert.isTrue(resolved.some((entry) => entry.command === "script.run-tests.run")); @@ -761,7 +757,7 @@ it.layer(NodeServices.layer)("keybindings", (it) => { const { keybindingsConfigPath } = yield* ServerConfig; const { dirname } = yield* Path.Path; yield* writeKeybindingsConfig(keybindingsConfigPath, [ - { key: "mod+j", command: "terminal.toggle" }, + { key: "mod+j", command: "sidebar.toggle" }, ]); yield* fs.chmod(dirname(keybindingsConfigPath), 0o500); @@ -778,7 +774,7 @@ it.layer(NodeServices.layer)("keybindings", (it) => { const persisted = yield* readKeybindingsConfig(keybindingsConfigPath); const persistedView = persisted.map(({ key, command }) => ({ key, command })); - assert.deepEqual(persistedView, [{ key: "mod+j", command: "terminal.toggle" }]); + assert.deepEqual(persistedView, [{ key: "mod+j", command: "sidebar.toggle" }]); }).pipe(Effect.provide(makeKeybindingsLayer())), ); @@ -786,7 +782,7 @@ it.layer(NodeServices.layer)("keybindings", (it) => { Effect.gen(function* () { const { keybindingsConfigPath } = yield* ServerConfig; yield* writeKeybindingsConfig(keybindingsConfigPath, [ - { key: "mod+j", command: "terminal.toggle" }, + { key: "mod+j", command: "sidebar.toggle" }, ]); const [first, second] = yield* Effect.gen(function* () { @@ -797,7 +793,7 @@ it.layer(NodeServices.layer)("keybindings", (it) => { }); assert.deepEqual(first, second); - assert.isTrue(second.some((entry) => entry.command === "terminal.toggle")); + assert.isTrue(second.some((entry) => entry.command === "sidebar.toggle")); }).pipe(Effect.provide(makeKeybindingsLayer())), ); @@ -805,7 +801,7 @@ it.layer(NodeServices.layer)("keybindings", (it) => { Effect.gen(function* () { const { keybindingsConfigPath } = yield* ServerConfig; yield* writeKeybindingsConfig(keybindingsConfigPath, [ - { key: "mod+j", command: "terminal.toggle" }, + { key: "mod+j", command: "sidebar.toggle" }, ]); const loadedAfterUpsert = yield* Effect.gen(function* () { @@ -819,7 +815,7 @@ it.layer(NodeServices.layer)("keybindings", (it) => { }); assert.isTrue(loadedAfterUpsert.some((entry) => entry.command === "script.run-tests.run")); - assert.isTrue(loadedAfterUpsert.some((entry) => entry.command === "terminal.toggle")); + assert.isTrue(loadedAfterUpsert.some((entry) => entry.command === "sidebar.toggle")); }).pipe(Effect.provide(makeKeybindingsLayer())), ); diff --git a/apps/server/src/keybindings.ts b/apps/server/src/keybindings.ts index 4fa903839..a52f6f303 100644 --- a/apps/server/src/keybindings.ts +++ b/apps/server/src/keybindings.ts @@ -65,31 +65,18 @@ type WhenToken = | { type: "rparen" }; export const DEFAULT_KEYBINDINGS: ReadonlyArray = [ - { key: "mod+b", command: "sidebar.toggle", when: "!terminalFocus" }, - { key: "mod+alt+b", command: "rightPanel.toggle", when: "!terminalFocus" }, + { key: "mod+b", command: "sidebar.toggle" }, + { key: "mod+alt+b", command: "rightPanel.toggle" }, { key: "mod+k", command: "sidebar.search" }, - { key: "mod+shift+o", command: "sidebar.addProject", when: "!terminalFocus" }, - { key: "mod+i", command: "sidebar.importThread", when: "!terminalFocus" }, - { key: "mod+j", command: "terminal.toggle" }, - { key: "mod+d", command: "terminal.split", when: "terminalFocus" }, - { key: "mod+shift+arrowright", command: "terminal.splitRight", when: "terminalFocus" }, - { key: "mod+shift+arrowleft", command: "terminal.splitLeft", when: "terminalFocus" }, - { key: "mod+shift+arrowdown", command: "terminal.splitDown", when: "terminalFocus" }, - { key: "mod+shift+arrowup", command: "terminal.splitUp", when: "terminalFocus" }, - // Reserve Cmd/Ctrl+T for the terminal workspace's "new tab" action while focused. - { key: "mod+t", command: "terminal.new", when: "terminalFocus" }, - { key: "mod+w", command: "terminal.close", when: "terminalFocus" }, - { key: "mod+shift+j", command: "terminal.workspace.newFullWidth" }, - { key: "mod+w", command: "terminal.workspace.closeActive", when: "terminalWorkspaceOpen" }, - { key: "mod+1", command: "terminal.workspace.terminal", when: "terminalWorkspaceOpen" }, - { key: "mod+2", command: "terminal.workspace.chat", when: "terminalWorkspaceOpen" }, - { key: "mod+shift+b", command: "browser.toggle", when: "!terminalFocus" }, - { key: "mod+d", command: "diff.toggle", when: "!terminalFocus" }, + { key: "mod+shift+o", command: "sidebar.addProject" }, + { key: "mod+i", command: "sidebar.importThread" }, + { key: "mod+shift+b", command: "browser.toggle" }, + { key: "mod+d", command: "diff.toggle" }, // Cmd-only instead of mod so Ctrl+L remains available to shells on non-macOS. - { key: "cmd+l", command: "composer.focus.toggle", when: "!terminalFocus" }, - { key: "mod+shift+m", command: "modelPicker.toggle", when: "!terminalFocus" }, - { key: "mod+shift+e", command: "traitsPicker.toggle", when: "!terminalFocus" }, - { key: "mod+shift+u", command: "settings.usage", when: "!terminalFocus" }, + { key: "cmd+l", command: "composer.focus.toggle" }, + { key: "mod+shift+m", command: "modelPicker.toggle" }, + { key: "mod+shift+e", command: "traitsPicker.toggle" }, + { key: "mod+shift+u", command: "settings.usage" }, // New thread (chat.new) is the primary create action; it falls back to the most // recent project when no project is active. // @@ -98,25 +85,24 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray = [ // dropped the chord while the terminal had focus (you couldn't open a new chat/terminal // from the terminal). The `|| isMac` escape hatch fires them on macOS regardless of // focus, while Linux/Windows keep `!terminalFocus` so Ctrl-chords still reach the shell. - { key: "mod+n", command: "chat.new", when: "!terminalFocus || isMac" }, - { key: "mod+shift+n", command: "chat.newLatestProject", when: "!terminalFocus || isMac" }, - { key: "mod+alt+n", command: "chat.newChat", when: "!terminalFocus || isMac" }, - { key: "mod+shift+t", command: "chat.newTerminal", when: "!terminalFocus || isMac" }, - { key: "mod+alt+c", command: "chat.newClaude", when: "!terminalFocus || isMac" }, - { key: "mod+alt+x", command: "chat.newCodex", when: "!terminalFocus || isMac" }, - { key: "mod+alt+r", command: "chat.newCursor", when: "!terminalFocus || isMac" }, - { key: "mod+\\", command: "chat.split", when: "!terminalFocus || isMac" }, - { key: "mod+1", command: "thread.jump.1", when: "!terminalFocus && !terminalWorkspaceOpen" }, - { key: "mod+2", command: "thread.jump.2", when: "!terminalFocus && !terminalWorkspaceOpen" }, - { key: "mod+3", command: "thread.jump.3", when: "!terminalFocus && !terminalWorkspaceOpen" }, - { key: "mod+4", command: "thread.jump.4", when: "!terminalFocus && !terminalWorkspaceOpen" }, - { key: "mod+5", command: "thread.jump.5", when: "!terminalFocus && !terminalWorkspaceOpen" }, - { key: "mod+6", command: "thread.jump.6", when: "!terminalFocus && !terminalWorkspaceOpen" }, - { key: "mod+7", command: "thread.jump.7", when: "!terminalFocus && !terminalWorkspaceOpen" }, - { key: "mod+8", command: "thread.jump.8", when: "!terminalFocus && !terminalWorkspaceOpen" }, - { key: "mod+9", command: "thread.jump.9", when: "!terminalFocus && !terminalWorkspaceOpen" }, - { key: "mod+shift+]", command: "chat.visible.next", when: "!terminalFocus" }, - { key: "mod+shift+[", command: "chat.visible.previous", when: "!terminalFocus" }, + { key: "mod+n", command: "chat.new" }, + { key: "mod+shift+n", command: "chat.newLatestProject" }, + { key: "mod+alt+n", command: "chat.newChat" }, + { key: "mod+alt+c", command: "chat.newClaude" }, + { key: "mod+alt+x", command: "chat.newCodex" }, + { key: "mod+alt+r", command: "chat.newCursor" }, + { key: "mod+\\", command: "chat.split" }, + { key: "mod+1", command: "thread.jump.1" }, + { key: "mod+2", command: "thread.jump.2" }, + { key: "mod+3", command: "thread.jump.3" }, + { key: "mod+4", command: "thread.jump.4" }, + { key: "mod+5", command: "thread.jump.5" }, + { key: "mod+6", command: "thread.jump.6" }, + { key: "mod+7", command: "thread.jump.7" }, + { key: "mod+8", command: "thread.jump.8" }, + { key: "mod+9", command: "thread.jump.9" }, + { key: "mod+shift+]", command: "chat.visible.next" }, + { key: "mod+shift+[", command: "chat.visible.previous" }, { key: "mod+o", command: "editor.openFavorite" }, ]; @@ -576,7 +562,6 @@ const CREATION_COMMANDS_WITH_TERMINAL_ESCAPE = new Set const runtimeStartup = yield* ServerRuntimeStartup; const serverEnvironment = yield* ServerEnvironment; const serverSettings = yield* ServerSettingsService; - const terminalManager = yield* TerminalManager; const textGeneration = yield* TextGeneration; const workspaceEntries = yield* WorkspaceEntries; const workspaceFileSystem = yield* WorkspaceFileSystem; @@ -477,40 +473,6 @@ export const makeWsRpcLayer = () => providerSessionDirectory, }); - // Terminal-first threads are created with the generic "New terminal" placeholder. - // The tracker buffers per-terminal input and, once a meaningful command is submitted, - // surfaces a safe title used to auto-rename the thread on its first command. - const terminalTitleTracker = new TerminalThreadTitleTracker(); - const resetTerminalTitleBuffer = (threadId: string, terminalId: string | null) => - Effect.sync(() => terminalTitleTracker.reset(threadId, terminalId)); - // Terminal auto-titles are best-effort metadata and must never block or fail terminal writes. - const maybeAutoRenameTerminalThread = Effect.fnUntraced(function* (input: { - threadId: string; - terminalId: string; - data: string; - }) { - const readModel = yield* orchestrationEngine.getReadModel(); - const thread = readModel.threads.find((entry) => entry.id === input.threadId); - if (!thread) { - return; - } - const nextTitle = terminalTitleTracker.consumeWrite({ - currentTitle: thread.title, - data: input.data, - terminalId: input.terminalId, - threadId: input.threadId, - }); - if (!nextTitle) { - return; - } - yield* orchestrationEngine.dispatch({ - type: "thread.meta.update", - commandId: CommandId.makeUnsafe(`server:terminal-title-rename:${crypto.randomUUID()}`), - threadId: ThreadId.makeUnsafe(input.threadId), - title: nextTitle, - }); - }); - const stopLocalServerAndTrackedProjectRun = Effect.fnUntraced(function* (input: { pid: number; port: number; @@ -1135,58 +1097,6 @@ export const makeWsRpcLayer = () => [WS_METHODS.workspaceHandoffThread]: (input) => rpcEffect(handoffThreadWorkspace(input), "Failed to hand off the thread workspace"), - [WS_METHODS.terminalOpen]: (input) => - rpcEffect( - resetTerminalTitleBuffer(input.threadId, input.terminalId ?? DEFAULT_TERMINAL_ID).pipe( - Effect.andThen(terminalManager.open(input)), - ), - "Failed to open terminal", - ), - [WS_METHODS.terminalWrite]: (input) => - rpcEffect( - terminalManager.write(input).pipe( - Effect.tap(() => - maybeAutoRenameTerminalThread({ - threadId: input.threadId, - terminalId: input.terminalId ?? DEFAULT_TERMINAL_ID, - data: input.data, - }).pipe(Effect.catch(() => Effect.void)), - ), - ), - "Failed to write terminal", - ), - [WS_METHODS.terminalAckOutput]: (input) => - rpcEffect(terminalManager.ackOutput(input), "Failed to acknowledge terminal output"), - [WS_METHODS.terminalResize]: (input) => - rpcEffect(terminalManager.resize(input), "Failed to resize terminal"), - [WS_METHODS.terminalClear]: (input) => - rpcEffect(terminalManager.clear(input), "Failed to clear terminal"), - [WS_METHODS.terminalRestart]: (input) => - rpcEffect( - resetTerminalTitleBuffer(input.threadId, input.terminalId ?? DEFAULT_TERMINAL_ID).pipe( - Effect.andThen(terminalManager.restart(input)), - ), - "Failed to restart terminal", - ), - [WS_METHODS.terminalClose]: (input) => - rpcEffect( - resetTerminalTitleBuffer(input.threadId, input.terminalId ?? null).pipe( - Effect.andThen(terminalManager.close(input)), - ), - "Failed to close terminal", - ), - [WS_METHODS.subscribeTerminalEvents]: () => - // Terminal output is an ordered byte stream with renderer ACK accounting. - // Keep this lossless: dropping chunks would create holes until reattach. - Stream.callback((queue) => - Effect.gen(function* () { - const unsubscribe = yield* terminalManager.subscribe((event) => { - Effect.runFork(Queue.offer(queue, event).pipe(Effect.asVoid)); - }); - yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); - }), - ), - [WS_METHODS.serverGetConfig]: () => rpcEffect(loadServerConfig, "Failed to load server config"), [WS_METHODS.serverGetEnvironment]: () => diff --git a/apps/web/package.json b/apps/web/package.json index 2ac1ccc19..b8a6820c8 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@t3tools/web", - "version": "0.9.3", + "version": "0.9.4", "private": true, "type": "module", "scripts": { diff --git a/apps/web/src/appSettings.test.ts b/apps/web/src/appSettings.test.ts index 228069a67..0f3164bb1 100644 --- a/apps/web/src/appSettings.test.ts +++ b/apps/web/src/appSettings.test.ts @@ -668,7 +668,6 @@ describe("AppSettingsSchema", () => { ), ).toMatchObject({ claudeBinaryPath: "", - uiDensity: "comfortable", chatFontSizePx: DEFAULT_CHAT_FONT_SIZE_PX, codexBinaryPath: "/usr/local/bin/codex", codexHomePath: "", diff --git a/apps/web/src/appSettings.ts b/apps/web/src/appSettings.ts index 09a9861c4..076b5ce1c 100644 --- a/apps/web/src/appSettings.ts +++ b/apps/web/src/appSettings.ts @@ -40,16 +40,14 @@ import { import { ensureNativeApi } from "./nativeApi"; import { providerDiscoveryQueryKeys } from "./lib/providerDiscoveryReactQuery"; import { serverQueryKeys, serverSettingsQueryOptions } from "./lib/serverReactQuery"; -import { - DEFAULT_UI_DENSITY, - UI_DENSITY_MODES, - normalizeUiDensity as normalizeUiDensityValue, -} from "./lib/appDensity"; const APP_SETTINGS_STORAGE_KEY = "teacode:app-settings:v1"; const SERVER_SETTINGS_MIGRATION_STORAGE_KEY = "t3code:server-settings-migrated:v1"; const MAX_CUSTOM_MODEL_COUNT = 32; export const MAX_CUSTOM_MODEL_LENGTH = 256; +export const MIN_WINDOW_TRANSPARENCY = 0; +export const MAX_WINDOW_TRANSPARENCY = 30; +export const DEFAULT_WINDOW_TRANSPARENCY = 20; export const MIN_CHAT_FONT_SIZE_PX = 11; export const MAX_CHAT_FONT_SIZE_PX = 18; export const DEFAULT_CHAT_FONT_SIZE_PX = 14; @@ -71,17 +69,10 @@ export const SidebarPosition = Schema.Literals(["left", "right"]); export type SidebarPosition = typeof SidebarPosition.Type; export const DEFAULT_SIDEBAR_POSITION: SidebarPosition = "left"; /** Optional decorative image behind the main chat canvas. */ -export const AppBackground = Schema.Literals(["none", "london", "rio", "sf", "tokyo"]); -export type AppBackground = typeof AppBackground.Type; -export const DEFAULT_APP_BACKGROUND: AppBackground = "none"; export const TaskListDisplayMode = Schema.Literals(["sidebar", "composer"]); export type TaskListDisplayMode = typeof TaskListDisplayMode.Type; export const DEFAULT_TASK_LIST_DISPLAY_MODE: TaskListDisplayMode = "sidebar"; -export const UiDensity = Schema.Literals(UI_DENSITY_MODES); -export type UiDensity = typeof UiDensity.Type; -export { DEFAULT_UI_DENSITY }; - export const ChatHeaderControlIdSchema = Schema.Literals(DEFAULT_CHAT_HEADER_CONTROL_ORDER); export function getDefaultNativeFontSmoothing(platform = globalThis.navigator?.platform ?? "") { @@ -131,10 +122,11 @@ const withDefaults = export const AppSettingsSchema = Schema.Struct({ claudeBinaryPath: Schema.String.check(Schema.isMaxLength(4096)).pipe(withDefaults(() => "")), - uiDensity: UiDensity.pipe(withDefaults(() => DEFAULT_UI_DENSITY)), // Default color applied when highlighting selected transcript text. highlightColor: ThreadMarkerColor.pipe(withDefaults(() => "yellow" as const)), chatFontSizePx: Schema.Number.pipe(withDefaults(() => DEFAULT_CHAT_FONT_SIZE_PX)), + /** How much of the desktop shows through the window canvas, as a percentage. */ + windowTransparency: Schema.Number.pipe(withDefaults(() => DEFAULT_WINDOW_TRANSPARENCY)), terminalFontSizePx: Schema.Number.pipe(withDefaults(() => DEFAULT_TERMINAL_FONT_SIZE_PX)), codexBinaryPath: Schema.String.check(Schema.isMaxLength(4096)).pipe(withDefaults(() => "")), codexHomePath: Schema.String.check(Schema.isMaxLength(4096)).pipe(withDefaults(() => "")), @@ -176,7 +168,6 @@ export const AppSettingsSchema = Schema.Struct({ withDefaults(() => DEFAULT_SIDEBAR_THREAD_SORT_ORDER), ), sidebarPosition: SidebarPosition.pipe(withDefaults(() => DEFAULT_SIDEBAR_POSITION)), - appBackground: AppBackground.pipe(withDefaults(() => DEFAULT_APP_BACKGROUND)), timestampFormat: TimestampFormat.pipe(withDefaults(() => DEFAULT_TIMESTAMP_FORMAT)), customCodexModels: Schema.Array(Schema.String).pipe(withDefaults(() => [])), customClaudeModels: Schema.Array(Schema.String).pipe(withDefaults(() => [])), @@ -322,6 +313,14 @@ export function normalizeCustomModelSlugs( return normalizedModels; } +export function normalizeWindowTransparency(value: number | null | undefined): number { + if (typeof value !== "number" || !Number.isFinite(value)) { + return DEFAULT_WINDOW_TRANSPARENCY; + } + + return Math.min(MAX_WINDOW_TRANSPARENCY, Math.max(MIN_WINDOW_TRANSPARENCY, Math.round(value))); +} + export function normalizeChatFontSizePx(value: number | null | undefined): number { if (typeof value !== "number" || !Number.isFinite(value)) { return DEFAULT_CHAT_FONT_SIZE_PX; @@ -365,8 +364,8 @@ function normalizeAppSettings(settings: AppSettings): AppSettings { settings.openCodeBinaryPath, ), piBinaryPath: normalizeProviderBinaryPathOverride("pi", settings.piBinaryPath), - uiDensity: normalizeUiDensityValue(settings.uiDensity), chatFontSizePx: normalizeChatFontSizePx(settings.chatFontSizePx), + windowTransparency: normalizeWindowTransparency(settings.windowTransparency), terminalFontSizePx: normalizeTerminalFontSizePx(settings.terminalFontSizePx), customCodexModels: normalizeCustomModelSlugs(settings.customCodexModels, "codex"), customClaudeModels: normalizeCustomModelSlugs(settings.customClaudeModels, "claudeAgent"), diff --git a/apps/web/src/chatHeaderLayout.test.ts b/apps/web/src/chatHeaderLayout.test.ts index da2a8a2d2..19fa699a6 100644 --- a/apps/web/src/chatHeaderLayout.test.ts +++ b/apps/web/src/chatHeaderLayout.test.ts @@ -29,26 +29,25 @@ describe("chatHeaderLayout", () => { }); it("rejects unknown control ids", () => { - expect(isChatHeaderControlId("diff")).toBe(true); + expect(isChatHeaderControlId("gitActions")).toBe(true); expect(isChatHeaderControlId("bogus")).toBe(false); }); it("dedupes and drops unknown ids from the hidden set", () => { - expect(normalizeHiddenChatHeaderControls(["bogus", "diff", "diff", "usage"])).toEqual([ - "diff", - "usage", - ]); + expect( + normalizeHiddenChatHeaderControls(["bogus", "gitActions", "gitActions", "usage"]), + ).toEqual(["gitActions", "usage"]); }); it("dedupes and drops unknown ids from a persisted order", () => { - expect(normalizeChatHeaderControlOrder(["diff", "bogus", "diff", "usage"]).slice(0, 2)).toEqual( - ["diff", "usage"], - ); + expect( + normalizeChatHeaderControlOrder(["gitActions", "bogus", "gitActions", "usage"]).slice(0, 2), + ).toEqual(["gitActions", "usage"]); }); it("appends controls missing from a stale persisted order", () => { - const stale = normalizeChatHeaderControlOrder(["diff", "usage"]); - expect(stale.slice(0, 2)).toEqual(["diff", "usage"]); + const stale = normalizeChatHeaderControlOrder(["gitActions", "usage"]); + expect(stale.slice(0, 2)).toEqual(["gitActions", "usage"]); expect(new Set(stale)).toEqual(new Set(ALL_CHAT_HEADER_CONTROL_IDS)); expect(stale).toHaveLength(ALL_CHAT_HEADER_CONTROL_IDS.length); }); @@ -60,25 +59,17 @@ describe("chatHeaderLayout", () => { DEFAULT_CHAT_HEADER_CONTROL_ORDER, ), ).toBe(true); - expect(sameChatHeaderControlOrder(["diff"], ["usage"])).toBe(false); + expect(sameChatHeaderControlOrder(["handoff"], ["usage"])).toBe(false); }); it("sorts by the configured order and pushes unlisted controls last", () => { - const order: ChatHeaderControlId[] = ["diff", "usage"]; - expect(compareChatHeaderControlsByOrder(order, "diff", "usage")).toBeLessThan(0); + const order: ChatHeaderControlId[] = ["gitActions", "usage"]; + expect(compareChatHeaderControlsByOrder(order, "gitActions", "usage")).toBeLessThan(0); expect(compareChatHeaderControlsByOrder(order, "usage", "handoff")).toBeLessThan(0); expect( DEFAULT_CHAT_HEADER_CONTROL_ORDER.toSorted((left, right) => compareChatHeaderControlsByOrder(order, left, right), ), - ).toEqual([ - "diff", - "usage", - "handoff", - "projectScripts", - "environment", - "openIn", - "gitActions", - ]); + ).toEqual(["gitActions", "usage", "handoff", "projectScripts", "environment", "openIn"]); }); }); diff --git a/apps/web/src/chatHeaderLayout.ts b/apps/web/src/chatHeaderLayout.ts index d44bb1150..370a9ae0b 100644 --- a/apps/web/src/chatHeaderLayout.ts +++ b/apps/web/src/chatHeaderLayout.ts @@ -9,8 +9,7 @@ export type ChatHeaderControlId = | "projectScripts" | "environment" | "openIn" - | "gitActions" - | "diff"; + | "gitActions"; export const DEFAULT_CHAT_HEADER_CONTROL_ORDER = [ "usage", @@ -19,7 +18,6 @@ export const DEFAULT_CHAT_HEADER_CONTROL_ORDER = [ "environment", "openIn", "gitActions", - "diff", ] as const satisfies readonly ChatHeaderControlId[]; export const CHAT_HEADER_CONTROL_LABELS: Record< @@ -50,10 +48,6 @@ export const CHAT_HEADER_CONTROL_LABELS: Record< title: "Git actions", description: "Commit, push, and pull request shortcuts.", }, - diff: { - title: "Diff panel", - description: "Toggle the diff panel and the +/- change count.", - }, }; const CHAT_HEADER_CONTROL_ID_SET: ReadonlySet = new Set( diff --git a/apps/web/src/chatPanelState.ts b/apps/web/src/chatPanelState.ts deleted file mode 100644 index 355cb5c72..000000000 --- a/apps/web/src/chatPanelState.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { TurnId } from "@t3tools/contracts"; - -export interface ChatPanelState { - panel: "diff" | null; - diffTurnId: TurnId | null; - diffFilePath: string | null; - hasOpenedPanel: boolean; - lastOpenPanel: "diff"; -} diff --git a/apps/web/src/components/AppSnapCoordinator.tsx b/apps/web/src/components/AppSnapCoordinator.tsx index 6068cfd32..97979bce1 100644 --- a/apps/web/src/components/AppSnapCoordinator.tsx +++ b/apps/web/src/components/AppSnapCoordinator.tsx @@ -37,7 +37,6 @@ import { effectiveComposerAttachmentCount, } from "../lib/composerSend"; import { useStore } from "../store"; -import { useTerminalStateStore } from "../terminalStateStore"; import { toastManager } from "./ui/toast"; const MAX_DEDUPED_CAPTURE_IDS = 128; @@ -93,7 +92,6 @@ export function AppSnapCoordinator() { const navigate = useNavigate(); const { handleNewChat } = useHandleNewChat(); const { focusedThreadId } = useFocusedChatContext(); - const openChatThreadPage = useTerminalStateStore((state) => state.openChatThreadPage); const { settings } = useAppSettings(); const focusedThreadRef = useRef(focusedThreadId); const lastInteractionRef = useRef(null); @@ -266,13 +264,12 @@ export function AppSnapCoordinator() { const routeToThread = useCallback( async (threadId: ThreadId) => { - openChatThreadPage(threadId); if (focusedThreadRef.current !== threadId) { await navigate({ to: "/$threadId", params: { threadId } }); } requestComposerFocus(threadId); }, - [navigate, openChatThreadPage], + [navigate], ); const resolveDestination = useCallback( diff --git a/apps/web/src/components/ChatMarkdown.test.tsx b/apps/web/src/components/ChatMarkdown.test.tsx index fec071602..3f57ded87 100644 --- a/apps/web/src/components/ChatMarkdown.test.tsx +++ b/apps/web/src/components/ChatMarkdown.test.tsx @@ -13,10 +13,6 @@ vi.mock("@pierre/diffs", () => ({ }), })); -vi.mock("../hooks/useTheme", () => ({ - useTheme: () => ({ resolvedTheme: "light" }), -})); - async function renderMarkdown( text: string, cwd = "C:\\Users\\LENOVO\\dpcode", diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 2b600b495..1146dde2c 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -28,12 +28,11 @@ import rehypeKatex from "rehype-katex"; import remarkGfm from "remark-gfm"; import remarkMath from "remark-math"; import { copyTextToClipboard } from "../hooks/useCopyToClipboard"; -import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering"; +import { DIFF_THEME_NAME, type DiffThemeName } from "../lib/diffRendering"; import { dedentCode, parseCodeFenceInfo, type CodeFenceInfo } from "../lib/codeFence"; import { getFileIconName, pathLooksLikeKnownFile } from "../file-icons"; import { CentralIcon } from "~/lib/central-icons"; import { isLocalImageMarkdownSrc } from "../lib/localImageUrls"; -import { useTheme } from "../hooks/useTheme"; import { useSmoothStreamedText } from "../hooks/useSmoothStreamedText"; import { openWorkspaceFileReference, useWorkspaceFileOpener } from "../lib/workspaceFileOpener"; import { resolveMarkdownFileLinkTarget, rewriteMarkdownFileUriHref } from "../markdown-links"; @@ -726,18 +725,12 @@ function inlineCodeFilePath(raw: string): string | null { // meta/ctrl-click — or a surface without a viewer — opens the preferred // external editor. `targetPath` may carry a `:line` suffix (used to open); the // chip icon and title use the position-free path. -function OpenableFileChip(props: { - targetPath: string; - theme: "light" | "dark"; - label?: ReactNode; - href?: string; -}) { +function OpenableFileChip(props: { targetPath: string; label?: ReactNode; href?: string }) { const opener = useWorkspaceFileOpener(); const chipPath = props.targetPath.replace(MARKDOWN_LINK_POSITION_SUFFIX_PATTERN, ""); return ( { event.preventDefault(); @@ -957,8 +950,7 @@ function ChatMarkdown({ markers, onTaskToggle, }: ChatMarkdownProps) { - const { resolvedTheme } = useTheme(); - const diffThemeName = resolveDiffThemeName(resolvedTheme); + const diffThemeName = DIFF_THEME_NAME; // Reveal streamed text at a steady, adaptive cadence so tokens appear fluidly instead of // in the ~100ms network clumps that land in the store. No-ops (returns `text`) when not // streaming or under reduced motion. Governs cadence only; the deferred value below still @@ -1019,7 +1011,6 @@ function ChatMarkdown({ return ( @@ -1058,7 +1049,7 @@ function ChatMarkdown({ const filePath = inlineCodeFilePath(nodeToPlainText(children)); if (filePath) { const targetPath = resolveMarkdownFileLinkTarget(filePath, cwd) ?? filePath; - return ; + return ; } } return ( @@ -1106,7 +1097,7 @@ function ChatMarkdown({ return ; }, }), - [cwd, diffThemeName, isStreaming, onImageExpand, onTaskToggle, resolvedTheme], + [cwd, diffThemeName, isStreaming, onImageExpand, onTaskToggle], ); return ( diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 263096933..97c8e6367 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -29,11 +29,6 @@ import { AUTO_SCROLL_BOTTOM_THRESHOLD_PX, getScrollContainerDistanceFromBottom, } from "../chat-scroll"; -import { - INLINE_TERMINAL_CONTEXT_PLACEHOLDER, - type TerminalContextDraft, - removeInlineTerminalContextPlaceholder, -} from "../lib/terminalContext"; import { isMacPlatform } from "../lib/utils"; import { readNativeApi } from "../nativeApi"; import { getRouter } from "../router"; @@ -45,7 +40,6 @@ import { sendEffectRpcChunk, sendEffectRpcExit, } from "../test/effectRpcWebSocketMock"; -import { useTerminalStateStore } from "../terminalStateStore"; import { resetWsNativeApiForTest } from "../wsNativeApi"; import { estimateTimelineMessageHeight } from "./timelineHeight"; @@ -192,25 +186,6 @@ function createAssistantMessage(options: { id: MessageId; text: string; offsetSe }; } -function createTerminalContext(input: { - id: string; - terminalLabel: string; - lineStart: number; - lineEnd: number; - text: string; -}): TerminalContextDraft { - return { - id: input.id, - threadId: THREAD_ID, - terminalId: `terminal-${input.id}`, - terminalLabel: input.terminalLabel, - lineStart: input.lineStart, - lineEnd: input.lineEnd, - text: input.text, - createdAt: NOW_ISO, - }; -} - function createComposerImage(input: { id: string; previewUrl: string; @@ -1038,20 +1013,7 @@ function resolveWsRpc(body: WsRequestEnvelope["body"]): unknown { truncated: false, }; } - if (tag === WS_METHODS.terminalOpen) { - return { - threadId: typeof body.threadId === "string" ? body.threadId : THREAD_ID, - terminalId: typeof body.terminalId === "string" ? body.terminalId : "default", - cwd: typeof body.cwd === "string" ? body.cwd : "/repo/project", - status: "running", - pid: 123, - history: "", - exitCode: null, - exitSignal: null, - updatedAt: NOW_ISO, - }; - } - if (tag === WS_METHODS.shellOpenInEditor || tag === WS_METHODS.terminalWrite) { + if (tag === WS_METHODS.shellOpenInEditor) { return null; } return {}; @@ -1121,7 +1083,6 @@ const worker = setupWorker( if ( method === WS_METHODS.subscribeServerProviderStatuses || method === WS_METHODS.subscribeServerSettings || - method === WS_METHODS.subscribeTerminalEvents || method === WS_METHODS.subscribeOrchestrationDomainEvents ) { return; @@ -1593,9 +1554,6 @@ describe("ChatView timeline estimator parity (full app)", () => { sidebarThreadSummaryById: {}, threadsHydrated: false, }); - useTerminalStateStore.setState({ - terminalStateByThreadId: {}, - }); }); afterEach(() => { @@ -2095,7 +2053,6 @@ describe("ChatView timeline estimator parity (full app)", () => { projectId: PROJECT_ID, createdAt: NOW_ISO, runtimeMode: "full-access", - entryPoint: "chat", branch: "feature/draft-automation", worktreePath: "/repo/worktrees/draft-automation", envMode: "worktree", @@ -2198,7 +2155,6 @@ describe("ChatView timeline estimator parity (full app)", () => { projectId: PROJECT_ID, createdAt: NOW_ISO, runtimeMode: "full-access", - entryPoint: "chat", branch: null, worktreePath: null, envMode: "local", @@ -2284,7 +2240,6 @@ describe("ChatView timeline estimator parity (full app)", () => { projectId: PROJECT_ID, createdAt: NOW_ISO, runtimeMode: "full-access", - entryPoint: "chat", branch: null, worktreePath: null, envMode: "local", @@ -2358,148 +2313,6 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); - it("runs project scripts from local draft threads at the project cwd", async () => { - useComposerDraftStore.setState({ - draftThreadsByThreadId: { - [THREAD_ID]: { - projectId: PROJECT_ID, - createdAt: NOW_ISO, - runtimeMode: "full-access", - entryPoint: "chat", - branch: null, - worktreePath: null, - envMode: "local", - }, - }, - projectDraftThreadIdByProjectId: { - [PROJECT_ID]: THREAD_ID, - }, - }); - - const mounted = await mountChatView({ - viewport: { ...DEFAULT_VIEWPORT, width: 1_400 }, - snapshot: withProjectScripts(createDraftOnlySnapshot(), [ - { - id: "lint", - name: "Lint", - command: "bun run lint", - icon: "lint", - runOnWorktreeCreate: false, - }, - ]), - }); - - try { - const runButton = await waitForElement( - () => - Array.from(document.querySelectorAll("button")).find( - (button) => button.title === "Run Lint", - ) as HTMLButtonElement | null, - "Unable to find Run Lint button.", - ); - runButton.click(); - - await vi.waitFor( - () => { - const openRequest = wsRequests.find( - (request) => - request._tag === WS_METHODS.terminalOpen && request.cwd === "/repo/project", - ); - expect(openRequest).toMatchObject({ - _tag: WS_METHODS.terminalOpen, - threadId: THREAD_ID, - cwd: "/repo/project", - env: { - CHITAURI_PROJECT_ROOT: "/repo/project", - }, - }); - }, - { timeout: 8_000, interval: 16 }, - ); - - await vi.waitFor( - () => { - const writeRequest = wsRequests.find( - (request) => request._tag === WS_METHODS.terminalWrite, - ); - expect(writeRequest).toMatchObject({ - _tag: WS_METHODS.terminalWrite, - threadId: THREAD_ID, - data: "bun run lint\r", - }); - }, - { timeout: 8_000, interval: 16 }, - ); - } finally { - await mounted.cleanup(); - } - }); - - it("runs project scripts from worktree draft threads at the worktree cwd", async () => { - useComposerDraftStore.setState({ - draftThreadsByThreadId: { - [THREAD_ID]: { - projectId: PROJECT_ID, - createdAt: NOW_ISO, - runtimeMode: "full-access", - entryPoint: "chat", - branch: "feature/draft", - worktreePath: "/repo/worktrees/feature-draft", - envMode: "worktree", - }, - }, - projectDraftThreadIdByProjectId: { - [PROJECT_ID]: THREAD_ID, - }, - }); - - const mounted = await mountChatView({ - viewport: { ...DEFAULT_VIEWPORT, width: 1_400 }, - snapshot: withProjectScripts(createDraftOnlySnapshot(), [ - { - id: "test", - name: "Test", - command: "bun run test", - icon: "test", - runOnWorktreeCreate: false, - }, - ]), - }); - - try { - const runButton = await waitForElement( - () => - Array.from(document.querySelectorAll("button")).find( - (button) => button.title === "Run Test", - ) as HTMLButtonElement | null, - "Unable to find Run Test button.", - ); - runButton.click(); - - await vi.waitFor( - () => { - const openRequest = wsRequests.find( - (request) => - request._tag === WS_METHODS.terminalOpen && - request.cwd === "/repo/worktrees/feature-draft", - ); - expect(openRequest).toMatchObject({ - _tag: WS_METHODS.terminalOpen, - threadId: THREAD_ID, - cwd: "/repo/worktrees/feature-draft", - env: { - CHITAURI_PROJECT_ROOT: "/repo/project", - CHITAURI_WORKTREE_PATH: "/repo/worktrees/feature-draft", - }, - }); - }, - { timeout: 8_000, interval: 16 }, - ); - } finally { - await mounted.cleanup(); - } - }); - it("toggles composer focus with Cmd+L", async () => { const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, @@ -2617,162 +2430,6 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); - it("keeps removed terminal context pills removed when a new one is added", async () => { - const removedLabel = "Terminal 1 lines 1-2"; - const addedLabel = "Terminal 2 lines 9-10"; - useComposerDraftStore.getState().addTerminalContext( - THREAD_ID, - createTerminalContext({ - id: "ctx-removed", - terminalLabel: "Terminal 1", - lineStart: 1, - lineEnd: 2, - text: "bun i\nno changes", - }), - ); - - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-terminal-pill-backspace" as MessageId, - targetText: "terminal pill backspace target", - }), - }); - - try { - await vi.waitFor( - () => { - expect(document.body.textContent).toContain(removedLabel); - }, - { timeout: 8_000, interval: 16 }, - ); - - const store = useComposerDraftStore.getState(); - const currentPrompt = store.draftsByThreadId[THREAD_ID]?.prompt ?? ""; - const nextPrompt = removeInlineTerminalContextPlaceholder(currentPrompt, 0); - store.setPrompt(THREAD_ID, nextPrompt.prompt); - store.removeTerminalContext(THREAD_ID, "ctx-removed"); - - await vi.waitFor( - () => { - expect(useComposerDraftStore.getState().draftsByThreadId[THREAD_ID]).toBeUndefined(); - expect(document.body.textContent).not.toContain(removedLabel); - }, - { timeout: 8_000, interval: 16 }, - ); - - useComposerDraftStore.getState().addTerminalContext( - THREAD_ID, - createTerminalContext({ - id: "ctx-added", - terminalLabel: "Terminal 2", - lineStart: 9, - lineEnd: 10, - text: "git status\nOn branch main", - }), - ); - - await vi.waitFor( - () => { - const draft = useComposerDraftStore.getState().draftsByThreadId[THREAD_ID]; - expect(draft?.terminalContexts.map((context) => context.id)).toEqual(["ctx-added"]); - expect(document.body.textContent).toContain(addedLabel); - expect(document.body.textContent).not.toContain(removedLabel); - }, - { timeout: 8_000, interval: 16 }, - ); - } finally { - await mounted.cleanup(); - } - }); - - it("disables send when the composer only contains an expired terminal pill", async () => { - const expiredLabel = "Terminal 1 line 4"; - useComposerDraftStore.getState().addTerminalContext( - THREAD_ID, - createTerminalContext({ - id: "ctx-expired-only", - terminalLabel: "Terminal 1", - lineStart: 4, - lineEnd: 4, - text: "", - }), - ); - - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-expired-pill-disabled" as MessageId, - targetText: "expired pill disabled target", - }), - }); - - try { - await vi.waitFor( - () => { - expect(document.body.textContent).toContain(expiredLabel); - }, - { timeout: 8_000, interval: 16 }, - ); - - const sendButton = await waitForSendButton(); - expect(sendButton.disabled).toBe(true); - } finally { - await mounted.cleanup(); - } - }); - - it("warns when sending text while omitting expired terminal pills", async () => { - const expiredLabel = "Terminal 1 line 4"; - useComposerDraftStore.getState().addTerminalContext( - THREAD_ID, - createTerminalContext({ - id: "ctx-expired-send-warning", - terminalLabel: "Terminal 1", - lineStart: 4, - lineEnd: 4, - text: "", - }), - ); - useComposerDraftStore - .getState() - .setPrompt(THREAD_ID, `yoo${INLINE_TERMINAL_CONTEXT_PLACEHOLDER}waddup`); - - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-expired-pill-warning" as MessageId, - targetText: "expired pill warning target", - }), - }); - - try { - await vi.waitFor( - () => { - expect(document.body.textContent).toContain(expiredLabel); - }, - { timeout: 8_000, interval: 16 }, - ); - - const sendButton = await waitForSendButton(); - expect(sendButton.disabled).toBe(false); - sendButton.click(); - - await vi.waitFor( - () => { - expect(document.body.textContent).toContain( - "Expired terminal context omitted from message", - ); - expect(document.body.textContent).not.toContain(expiredLabel); - expect(document.body.textContent).toContain("yoowaddup"); - }, - { timeout: 8_000, interval: 16 }, - ); - } finally { - await mounted.cleanup(); - } - }); - it("shows a pointer cursor for the running stop button", async () => { const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, @@ -2929,7 +2586,6 @@ describe("ChatView timeline estimator parity (full app)", () => { images: [queuedImage], files: [], assistantSelections: [], - terminalContexts: [], fileComments: [], pastedTexts: [], skills: [], @@ -2953,7 +2609,6 @@ describe("ChatView timeline estimator parity (full app)", () => { images: [], files: [], assistantSelections: [], - terminalContexts: [], fileComments: [], pastedTexts: [], skills: [], @@ -3043,7 +2698,6 @@ describe("ChatView timeline estimator parity (full app)", () => { images: [], files: [], assistantSelections: [], - terminalContexts: [], fileComments: [], pastedTexts: [], skills: [], @@ -3119,7 +2773,6 @@ describe("ChatView timeline estimator parity (full app)", () => { images: [queuedImage], files: [], assistantSelections: [], - terminalContexts: [], fileComments: [], pastedTexts: [], skills: [], @@ -3372,7 +3025,6 @@ describe("ChatView timeline estimator parity (full app)", () => { projectId: HOME_PROJECT_ID, createdAt: NOW_ISO, runtimeMode: "full-access", - entryPoint: "chat", branch: null, worktreePath: null, envMode: "local", @@ -3738,114 +3390,6 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); - it("runs the setup action from the newly-created worktree before starting the turn", async () => { - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: withProjectScripts( - createSnapshotForTargetUser({ - targetMessageId: "msg-user-new-worktree-setup-action-test" as MessageId, - targetText: "new worktree setup action test", - }), - [ - { - id: "setup", - name: "Setup", - command: "printf setup", - icon: "configure", - runOnWorktreeCreate: true, - }, - ], - ), - }); - - try { - const newThreadButton = page.getByTestId("new-thread-button"); - await expect.element(newThreadButton).toBeInTheDocument(); - await newThreadButton.click(); - - const newThreadPath = await waitForURL( - mounted.router, - (path) => UUID_ROUTE_RE.test(path), - "Route should have changed to a new draft thread UUID.", - ); - const newThreadId = newThreadPath.slice(1) as ThreadId; - - await waitForEnvironmentModeButton("Worktree"); - await waitForServerConfigToApply(); - - useComposerDraftStore.getState().setPrompt(newThreadId, "Ship it with setup"); - const composerEditor = await waitForComposerEditor(); - await vi.waitFor( - () => { - expect(composerEditor.textContent ?? "").toContain("Ship it with setup"); - }, - { timeout: 8_000, interval: 16 }, - ); - - const sendButton = await waitForSendButton(); - expect(sendButton.disabled).toBe(false); - await sendButton.click(); - - await vi.waitFor( - () => { - const createWorktreeIndex = wsRequests.findIndex((request) => { - return request._tag === WS_METHODS.workspaceProvisionThreadWorktree; - }); - expect(createWorktreeIndex).toBeGreaterThanOrEqual(0); - const createWorktreeRequest = wsRequests[createWorktreeIndex]; - if ( - !createWorktreeRequest || - createWorktreeRequest._tag !== WS_METHODS.workspaceProvisionThreadWorktree - ) { - throw new Error("Expected create worktree request."); - } - expect(createWorktreeRequest).toMatchObject({ - baseBranch: null, - newBranch: expect.stringMatching(/^teacode\/[0-9a-f]{8}$/), - }); - const worktreePath = `/repo/.codex/worktrees/project/${String( - createWorktreeRequest.newBranch, - ).replaceAll("/", "-")}`; - - const terminalOpenIndex = wsRequests.findIndex((request) => { - return ( - request._tag === WS_METHODS.terminalOpen && - request.threadId === newThreadId && - request.cwd === worktreePath - ); - }); - expect(terminalOpenIndex).toBeGreaterThan(createWorktreeIndex); - expect(wsRequests[terminalOpenIndex]).toMatchObject({ - _tag: WS_METHODS.terminalOpen, - cwd: worktreePath, - env: { - CHITAURI_PROJECT_ROOT: "/repo/project", - CHITAURI_WORKTREE_PATH: worktreePath, - }, - }); - - const terminalWriteIndex = wsRequests.findIndex((request) => { - return ( - request._tag === WS_METHODS.terminalWrite && - request.threadId === newThreadId && - request.data === "printf setup\r" - ); - }); - expect(terminalWriteIndex).toBeGreaterThan(terminalOpenIndex); - - const turnStartIndex = wsRequests.findIndex((request) => { - const command = readDispatchedCommand(request); - return command?.type === "thread.turn.start" && command.threadId === newThreadId; - }); - expect(turnStartIndex).toBeGreaterThan(terminalWriteIndex); - }, - { timeout: 10_000, interval: 16 }, - ); - } finally { - await mounted.cleanup(); - } - }); - it("hydrates the provider alongside a sticky claude model", async () => { useComposerDraftStore.setState({ stickyModelSelectionByProvider: { @@ -4065,211 +3609,6 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); - it("promotes terminal-first shortcut threads so they render as terminal rows", async () => { - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-terminal-shortcut-test" as MessageId, - targetText: "terminal shortcut test", - }), - configureFixture: (nextFixture) => { - nextFixture.serverConfig = { - ...nextFixture.serverConfig, - keybindings: [ - { - command: "chat.newTerminal", - shortcut: { - key: "t", - metaKey: false, - ctrlKey: false, - shiftKey: true, - altKey: false, - modKey: true, - }, - whenAst: { - type: "not", - node: { type: "identifier", name: "terminalFocus" }, - }, - }, - ], - }; - }, - }); - - try { - await waitForServerConfigToApply(); - const composerEditor = await waitForComposerEditor(); - composerEditor.focus(); - await waitForLayout(); - const newThreadPath = await triggerTerminalThreadShortcutUntilPath( - mounted.router, - (path) => UUID_ROUTE_RE.test(path), - "Route should have changed to a new terminal-first draft thread UUID from the shortcut.", - ); - const newThreadId = newThreadPath.slice(1) as ThreadId; - - await vi.waitFor( - () => { - expect( - wsRequests.some( - (request) => - request._tag === ORCHESTRATION_WS_METHODS.dispatchCommand && - typeof request.command === "object" && - request.command !== null && - "type" in request.command && - "threadId" in request.command && - request.command.type === "thread.create" && - request.command.threadId === newThreadId, - ), - ).toBe(true); - }, - { timeout: 8_000, interval: 16 }, - ); - - useStore.getState().syncServerReadModel(addThreadToSnapshot(fixture.snapshot, newThreadId)); - useComposerDraftStore.getState().clearDraftThread(newThreadId); - - await vi.waitFor( - () => { - const terminalThreadRow = document.querySelector( - '[data-thread-entry-point="terminal"]', - ); - expect(terminalThreadRow).not.toBeNull(); - expect(terminalThreadRow?.textContent).toContain("New thread"); - }, - { timeout: 8_000, interval: 16 }, - ); - } finally { - await mounted.cleanup(); - } - }); - - it("promotes a stored terminal draft using its saved context and model selection", async () => { - const draftThreadId = ThreadId.makeUnsafe("thread-terminal-draft-reuse"); - useComposerDraftStore.setState({ - draftsByThreadId: { - [draftThreadId]: { - prompt: "", - promptHistorySavedDraft: null, - images: [], - files: [], - nonPersistedImageIds: [], - persistedAttachments: [], - assistantSelections: [], - terminalContexts: [], - fileComments: [], - pastedTexts: [], - skills: [], - mentions: [], - queuedTurns: [], - modelSelectionByProvider: { - claudeAgent: { - provider: "claudeAgent", - model: "claude-opus-4-6", - options: { - effort: "max", - }, - }, - }, - activeProvider: "claudeAgent", - runtimeMode: null, - }, - }, - draftThreadsByThreadId: { - [draftThreadId]: { - projectId: PROJECT_ID, - createdAt: NOW_ISO, - runtimeMode: "approval-required", - entryPoint: "terminal", - branch: "feature/terminal-title", - worktreePath: "/repo/project/.worktrees/terminal-title", - envMode: "worktree", - }, - }, - projectDraftThreadIdByProjectId: { - [`${PROJECT_ID}::terminal`]: draftThreadId, - }, - stickyModelSelectionByProvider: {}, - stickyActiveProvider: null, - }); - - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-terminal-draft-reuse-test" as MessageId, - targetText: "terminal draft reuse test", - }), - configureFixture: (nextFixture) => { - nextFixture.serverConfig = { - ...nextFixture.serverConfig, - keybindings: [ - { - command: "chat.newTerminal", - shortcut: { - key: "t", - metaKey: false, - ctrlKey: false, - shiftKey: true, - altKey: false, - modKey: true, - }, - whenAst: { - type: "not", - node: { type: "identifier", name: "terminalFocus" }, - }, - }, - ], - }; - }, - }); - - try { - await waitForServerConfigToApply(); - const composerEditor = await waitForComposerEditor(); - composerEditor.focus(); - await waitForLayout(); - dispatchTerminalThreadShortcut(); - - await waitForURL( - mounted.router, - (path) => path === `/${draftThreadId}`, - "Shortcut should reuse the stored terminal draft thread route.", - ); - - await vi.waitFor( - () => { - const createRequest = wsRequests.find( - (request) => - request._tag === ORCHESTRATION_WS_METHODS.dispatchCommand && - typeof request.command === "object" && - request.command !== null && - "type" in request.command && - "threadId" in request.command && - request.command.type === "thread.create" && - request.command.threadId === draftThreadId, - ); - - expect(createRequest).toBeTruthy(); - expect(createRequest?.command).toMatchObject({ - branch: "feature/terminal-title", - worktreePath: "/repo/project/.worktrees/terminal-title", - runtimeMode: "approval-required", - modelSelection: { - provider: "claudeAgent", - model: "claude-opus-4-6", - options: { - effort: "max", - }, - }, - }); - }, - { timeout: 8_000, interval: 16 }, - ); - } finally { - await mounted.cleanup(); - } - }); - it("exposes the plan details sidebar button when a plan is awaiting follow-up", async () => { const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 466c51499..21f644cd3 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -18,19 +18,14 @@ import { resolveActiveThreadTitle, resolveActiveTurnLiveDiffState, resolveCommittedProviderModel, - resolveProjectScriptTerminalTarget, resolveQueuedSteerGateTransition, resolveRuntimeModeAfterApprovalDecision, QUEUED_STEER_GATE_TIMEOUT_MS, - buildExpiredTerminalContextToastCopy, - shouldAutoDeleteTerminalThreadOnLastClose, shouldConsumePendingCustomBinaryConfirmation, shouldEnableComposerPastedTextCollapse, shouldHandlePromptHistoryNavigationKey, - shouldRenderProviderHealthBanner, shouldShowComposerModelBootstrapSkeleton, shouldStartActiveTurnLayoutGrace, - shouldRenderTerminalWorkspace, worktreeSetupHasError, } from "./ChatView.logic"; @@ -103,7 +98,7 @@ describe("prompt history navigation", () => { }, { role: "user", - text: "First prompt\n\n\n# Terminal\noutput\n", + text: "First prompt", source: "native", }, { @@ -762,61 +757,6 @@ describe("shouldConsumePendingCustomBinaryConfirmation", () => { }); describe("deriveComposerSendState", () => { - it("treats expired terminal pills as non-sendable content", () => { - const state = deriveComposerSendState({ - prompt: "\uFFFC", - imageCount: 0, - fileCount: 0, - assistantSelectionCount: 0, - fileCommentCount: 0, - terminalContexts: [ - { - id: "ctx-expired", - threadId: ThreadId.makeUnsafe("thread-1"), - terminalId: "default", - terminalLabel: "Terminal 1", - lineStart: 4, - lineEnd: 4, - text: "", - createdAt: "2026-03-17T12:52:29.000Z", - }, - ], - pastedTexts: [], - }); - - expect(state.trimmedPrompt).toBe(""); - expect(state.sendableTerminalContexts).toEqual([]); - expect(state.expiredTerminalContextCount).toBe(1); - expect(state.hasSendableContent).toBe(false); - }); - - it("keeps text sendable while excluding expired terminal pills", () => { - const state = deriveComposerSendState({ - prompt: `yoo \uFFFC waddup`, - imageCount: 0, - fileCount: 0, - assistantSelectionCount: 0, - fileCommentCount: 0, - terminalContexts: [ - { - id: "ctx-expired", - threadId: ThreadId.makeUnsafe("thread-1"), - terminalId: "default", - terminalLabel: "Terminal 1", - lineStart: 4, - lineEnd: 4, - text: "", - createdAt: "2026-03-17T12:52:29.000Z", - }, - ], - pastedTexts: [], - }); - - expect(state.trimmedPrompt).toBe("yoo waddup"); - expect(state.expiredTerminalContextCount).toBe(1); - expect(state.hasSendableContent).toBe(true); - }); - it("treats assistant selections as sendable content", () => { const state = deriveComposerSendState({ prompt: "", @@ -824,7 +764,6 @@ describe("deriveComposerSendState", () => { fileCount: 0, assistantSelectionCount: 1, fileCommentCount: 0, - terminalContexts: [], pastedTexts: [], }); @@ -838,7 +777,6 @@ describe("deriveComposerSendState", () => { fileCount: 0, assistantSelectionCount: 0, fileCommentCount: 1, - terminalContexts: [], pastedTexts: [], }); @@ -852,7 +790,6 @@ describe("deriveComposerSendState", () => { fileCount: 1, assistantSelectionCount: 0, fileCommentCount: 0, - terminalContexts: [], pastedTexts: [], }); @@ -860,134 +797,6 @@ describe("deriveComposerSendState", () => { }); }); -describe("buildExpiredTerminalContextToastCopy", () => { - it("formats clear empty-state guidance", () => { - expect(buildExpiredTerminalContextToastCopy(1, "empty")).toEqual({ - title: "Expired terminal context won't be sent", - description: "Remove it or re-add it to include terminal output.", - }); - }); - - it("formats omission guidance for sent messages", () => { - expect(buildExpiredTerminalContextToastCopy(2, "omitted")).toEqual({ - title: "Expired terminal contexts omitted from message", - description: "Re-add it if you want that terminal output included.", - }); - }); -}); - -describe("shouldRenderTerminalWorkspace", () => { - it("renders the workspace shell before the active project has hydrated", () => { - expect( - shouldRenderTerminalWorkspace({ - presentationMode: "workspace", - terminalOpen: true, - }), - ).toBe(true); - }); - - it("renders only for an open workspace terminal", () => { - expect( - shouldRenderTerminalWorkspace({ - presentationMode: "workspace", - terminalOpen: true, - }), - ).toBe(true); - expect( - shouldRenderTerminalWorkspace({ - presentationMode: "drawer", - terminalOpen: true, - }), - ).toBe(false); - }); -}); - -describe("resolveProjectScriptTerminalTarget", () => { - it("reuses the base terminal only when no terminal is open or running", () => { - const target = resolveProjectScriptTerminalTarget({ - baseTerminalId: "default", - createTerminalId: () => "new-terminal", - hasRunningTerminal: false, - terminalOpen: false, - }); - - expect(target).toEqual({ - shouldCreateNewTerminal: false, - terminalId: "default", - }); - }); - - it("creates a fresh terminal when a live terminal could keep stale cwd or env", () => { - expect( - resolveProjectScriptTerminalTarget({ - baseTerminalId: "default", - createTerminalId: () => "visible-script-terminal", - hasRunningTerminal: false, - terminalOpen: true, - }), - ).toEqual({ - shouldCreateNewTerminal: true, - terminalId: "visible-script-terminal", - }); - - expect( - resolveProjectScriptTerminalTarget({ - baseTerminalId: "default", - createTerminalId: () => "running-script-terminal", - hasRunningTerminal: true, - terminalOpen: false, - }), - ).toEqual({ - shouldCreateNewTerminal: true, - terminalId: "running-script-terminal", - }); - }); - - it("honors explicit requests for a new terminal", () => { - const target = resolveProjectScriptTerminalTarget({ - baseTerminalId: "default", - createTerminalId: () => "forced-script-terminal", - hasRunningTerminal: false, - preferNewTerminal: true, - terminalOpen: false, - }); - - expect(target).toEqual({ - shouldCreateNewTerminal: true, - terminalId: "forced-script-terminal", - }); - }); -}); - -describe("shouldRenderProviderHealthBanner", () => { - it("does not show chat provider health while a terminal thread is active", () => { - expect( - shouldRenderProviderHealthBanner({ - threadEntryPoint: "terminal", - terminalWorkspaceTerminalTabActive: false, - }), - ).toBe(false); - }); - - it("does not show chat provider health while the terminal workspace tab is active", () => { - expect( - shouldRenderProviderHealthBanner({ - threadEntryPoint: "chat", - terminalWorkspaceTerminalTabActive: true, - }), - ).toBe(false); - }); - - it("shows chat provider health only on the chat surface", () => { - expect( - shouldRenderProviderHealthBanner({ - threadEntryPoint: "chat", - terminalWorkspaceTerminalTabActive: false, - }), - ).toBe(true); - }); -}); - describe("shouldStartActiveTurnLayoutGrace", () => { it("starts the grace window when a live turn just became settled", () => { expect( @@ -1261,68 +1070,6 @@ describe("hasServerAcknowledgedLocalDispatch", () => { }); }); -describe("shouldAutoDeleteTerminalThreadOnLastClose", () => { - it("deletes untouched terminal-first placeholder threads when the last terminal closes", () => { - expect( - shouldAutoDeleteTerminalThreadOnLastClose({ - isLastTerminal: true, - isServerThread: true, - terminalEntryPoint: "terminal", - thread: { - title: "New terminal", - messages: [], - latestTurn: null, - session: null, - activities: [], - proposedPlans: [], - }, - }), - ).toBe(true); - }); - - it("keeps non-placeholder or already-used threads", () => { - expect( - shouldAutoDeleteTerminalThreadOnLastClose({ - isLastTerminal: true, - isServerThread: true, - terminalEntryPoint: "terminal", - thread: { - title: "Manual rename", - messages: [], - latestTurn: null, - session: null, - activities: [], - proposedPlans: [], - }, - }), - ).toBe(false); - - expect( - shouldAutoDeleteTerminalThreadOnLastClose({ - isLastTerminal: true, - isServerThread: true, - terminalEntryPoint: "terminal", - thread: { - title: "New terminal", - messages: [ - { - id: "msg-1" as never, - role: "user", - text: "hello", - createdAt: "2026-04-06T12:00:00.000Z", - streaming: false, - }, - ], - latestTurn: null, - session: null, - activities: [], - proposedPlans: [], - }, - }), - ).toBe(false); - }); -}); - describe("resolveRuntimeModeAfterApprovalDecision", () => { it("switches approval-required threads to full-access on acceptForSession", () => { expect(resolveRuntimeModeAfterApprovalDecision("approval-required", "acceptForSession")).toBe( diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index dd3caa1b6..52b94145d 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -12,7 +12,6 @@ import { import { normalizeModelSlug } from "@t3tools/shared/model"; import { buildTeaCodeBranchName } from "@t3tools/shared/git"; import { isGenericChatThreadTitle } from "@t3tools/shared/chatThreads"; -import { isGenericTerminalThreadTitle } from "@t3tools/shared/terminalThreads"; import { type ChatAssistantSelectionAttachment, type ChatMessage, @@ -25,11 +24,6 @@ import { } from "../types"; import { type DraftThreadState } from "../composerDraftStore"; import { Schema } from "effect"; -import { - filterTerminalContextsWithText, - stripInlineTerminalContextPlaceholders, - type TerminalContextDraft, -} from "../lib/terminalContext"; import { deriveDisplayedUserMessageState } from "../lib/composerMessageContext"; import { filterPastedTextsWithText, type PastedTextDraft } from "../lib/composerPastedText"; import { @@ -70,13 +64,6 @@ export function resolveRuntimeModeAfterApprovalDecision( return null; } -export function shouldRenderProviderHealthBanner(input: { - threadEntryPoint: ThreadPrimarySurface; - terminalWorkspaceTerminalTabActive: boolean; -}): boolean { - return input.threadEntryPoint === "chat" && !input.terminalWorkspaceTerminalTabActive; -} - // Big-paste cards are sent only by the normal chat path; non-chat composer flows // read plain editor text, so they must let Lexical insert pasted text normally. export function shouldEnableComposerPastedTextCollapse(input: { @@ -386,7 +373,7 @@ export function buildLocalDraftThread( id: threadId, codexThreadId: null, projectId: draftThread.projectId, - title: draftThread.entryPoint === "terminal" ? "New terminal" : "New thread", + title: "New thread", modelSelection: fallbackModelSelection, runtimeMode: draftThread.runtimeMode, session: null, @@ -786,24 +773,16 @@ export function deriveComposerSendState(options: { fileCount: number; assistantSelectionCount: number; fileCommentCount: number; - terminalContexts: ReadonlyArray; pastedTexts: ReadonlyArray; }): { trimmedPrompt: string; - sendableTerminalContexts: TerminalContextDraft[]; - expiredTerminalContextCount: number; sendablePastedTexts: PastedTextDraft[]; hasSendableContent: boolean; } { - const trimmedPrompt = stripInlineTerminalContextPlaceholders(options.prompt).trim(); - const sendableTerminalContexts = filterTerminalContextsWithText(options.terminalContexts); - const expiredTerminalContextCount = - options.terminalContexts.length - sendableTerminalContexts.length; + const trimmedPrompt = options.prompt.trim(); const sendablePastedTexts = filterPastedTextsWithText(options.pastedTexts); return { trimmedPrompt, - sendableTerminalContexts, - expiredTerminalContextCount, sendablePastedTexts, hasSendableContent: trimmedPrompt.length > 0 || @@ -811,7 +790,6 @@ export function deriveComposerSendState(options: { options.fileCount > 0 || options.assistantSelectionCount > 0 || options.fileCommentCount > 0 || - sendableTerminalContexts.length > 0 || sendablePastedTexts.length > 0, }; } @@ -828,79 +806,6 @@ export function collectUserMessageAssistantSelections( ); } -export function buildExpiredTerminalContextToastCopy( - expiredTerminalContextCount: number, - variant: "omitted" | "empty", -): { title: string; description: string } { - const count = Math.max(1, Math.floor(expiredTerminalContextCount)); - const noun = count === 1 ? "Expired terminal context" : "Expired terminal contexts"; - if (variant === "empty") { - return { - title: `${noun} won't be sent`, - description: "Remove it or re-add it to include terminal output.", - }; - } - return { - title: `${noun} omitted from message`, - description: "Re-add it if you want that terminal output included.", - }; -} - -export function shouldRenderTerminalWorkspace(options: { - presentationMode: "drawer" | "workspace"; - terminalOpen: boolean; -}): boolean { - // The workspace shell should paint immediately; the terminal viewport gates the - // backend attach until a valid cwd is available. - return options.terminalOpen && options.presentationMode === "workspace"; -} - -export function resolveProjectScriptTerminalTarget(options: { - baseTerminalId: string; - createTerminalId: () => string; - hasRunningTerminal: boolean; - preferNewTerminal?: boolean | undefined; - terminalOpen: boolean; -}): { shouldCreateNewTerminal: boolean; terminalId: string } { - // Project scripts require their requested cwd/env before the command write; - // live PTYs keep their launch context, so visible or running terminals get a new tab. - const shouldCreateNewTerminal = - Boolean(options.preferNewTerminal) || options.terminalOpen || options.hasRunningTerminal; - - return { - shouldCreateNewTerminal, - terminalId: shouldCreateNewTerminal ? options.createTerminalId() : options.baseTerminalId, - }; -} - -export function shouldAutoDeleteTerminalThreadOnLastClose(options: { - isLastTerminal: boolean; - isServerThread: boolean; - terminalEntryPoint: ThreadPrimarySurface; - thread: - | Pick - | null - | undefined; -}): boolean { - const { thread } = options; - if ( - !options.isLastTerminal || - !options.isServerThread || - options.terminalEntryPoint !== "terminal" || - !thread - ) { - return false; - } - return ( - isGenericTerminalThreadTitle(thread.title) && - thread.messages.length === 0 && - thread.latestTurn === null && - thread.session === null && - thread.activities.length === 0 && - thread.proposedPlans.length === 0 - ); -} - export interface ThreadBreadcrumb { threadId: ThreadIdType; title: string; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index f53873e70..29544f31f 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -133,7 +133,6 @@ import { reconcileDeletedThreadFromClient } from "../lib/deletedThreadClientReco import { dispatchThreadRename } from "../lib/threadRename"; import { useHandleNewChat } from "../hooks/useHandleNewChat"; import { useComposerDropzone } from "../hooks/useComposerDropzone"; -import { useDiffRouteSearch } from "../hooks/useDiffRouteSearch"; import { buildThreadBreadcrumbs, derivePromptHistoryFromMessages, @@ -143,7 +142,6 @@ import { resolveActiveThreadTitle, resolveActiveTurnLiveDiffState, resolveCommittedProviderModel, - resolveProjectScriptTerminalTarget, resolvePromptHistoryNavigation, shouldHandlePromptHistoryNavigationKey, shouldEnableComposerPastedTextCollapse, @@ -201,7 +199,7 @@ import { togglePendingUserInputOptionSelection, type PendingUserInputDraftAnswer, } from "../pendingUserInput"; -import { selectRightDockState, useRightDockStore } from "../rightDockStore"; +// rightDock removed import { useStore } from "../store"; import { RenameThreadDialog } from "./RenameThreadDialog"; import { getThreadFromState } from "../threadDerivation"; @@ -213,13 +211,7 @@ import { resolvePlanFollowUpSubmission, } from "../proposedPlan"; import { truncateTitle } from "../truncateTitle"; -import { - DEFAULT_THREAD_TERMINAL_ID, - MAX_TERMINALS_PER_GROUP, - type ChatMessage, - type Thread, -} from "../types"; -import { useTheme } from "../hooks/useTheme"; +import { type ChatMessage, type Thread } from "../types"; import { useThreadWorkspaceHandoff } from "../hooks/useThreadWorkspaceHandoff"; import { useComposerCommandMenuItems } from "../hooks/useComposerCommandMenuItems"; import { useThreadHandoff } from "../hooks/useThreadHandoff"; @@ -232,8 +224,7 @@ import { shortcutLabelForCommand, } from "../keybindings"; import PlanSidebar from "./PlanSidebar"; -import TerminalWorkspaceTabs from "./TerminalWorkspaceTabs"; -import ThreadTerminalDrawer from "./terminal/LazyThreadTerminalDrawer"; +// Terminal workspace tabs and drawer removed import { ChevronDownIcon, ChevronLeftIcon, @@ -251,7 +242,7 @@ import { DisclosureChevron } from "./ui/DisclosureChevron"; import { DisclosureRegion } from "./ui/DisclosureRegion"; import { Skeleton } from "./ui/skeleton"; import { Menu, MenuItem, MenuPopup, MenuTrigger } from "./ui/menu"; -import { disposeAndCloseTerminalSession, randomTerminalId } from "./terminal/terminalSession"; +// terminalSession removed import { cn, isMacPlatform, randomUUID } from "~/lib/utils"; import { toastManager } from "./ui/toast"; import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings"; @@ -265,14 +256,10 @@ import { type ProjectScriptRunOptions, type ProjectScriptRunResult, } from "~/projectScripts"; -import { runProjectCommandInTerminal } from "~/projectTerminalRunner"; +// projectTerminalRunner removed import { newCommandId, newMessageId, newProjectId, newThreadId } from "~/lib/utils"; import { readNativeApi } from "~/nativeApi"; -import { - confirmTerminalTabClose, - resolveTerminalCloseTitle, - shouldPromptForTerminalClose, -} from "~/lib/terminalCloseConfirmation"; +// terminalCloseConfirmation removed import { promoteThreadCreate } from "~/lib/threadCreatePromotion"; import { getAppModelOptions, @@ -283,8 +270,7 @@ import { resolveAssistantDeliveryMode, useAppSettings, } from "../appSettings"; -import { resolveTerminalNewAction } from "../lib/terminalNewAction"; -import { isTerminalFocused } from "../lib/terminalFocus"; +// terminalNewAction and terminalFocus removed import { compareProvidersByOrder } from "../providerOrdering"; import { type ComposerFileAttachment, @@ -303,14 +289,7 @@ import { } from "../composerDraftStore"; import { useComposerFocusRequestStore } from "../composerFocusRequestStore"; import { appendComposerPromptText } from "../lib/chatReferences"; -import { - IMAGE_ONLY_BOOTSTRAP_PROMPT, - formatTerminalContextLabel, - insertInlineTerminalContextPlaceholder, - removeInlineTerminalContextPlaceholder, - type TerminalContextDraft, - type TerminalContextSelection, -} from "../lib/terminalContext"; +import { IMAGE_ONLY_BOOTSTRAP_PROMPT } from "../lib/composerPlaceholders"; import { appendComposerMessageContext, appendOriginalComposerPromptBlocks, @@ -340,9 +319,8 @@ import { resolveNextComposerFooterTier, shouldUseCompactComposerFooter, } from "./composerFooterLayout"; -import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; -import { collectTerminalIdsFromLayout } from "../terminalPaneLayout"; -import type { ChatPanelState } from "../chatPanelState"; +// terminalStateStore and terminalPaneLayout removed + import { ComposerPromptEditor, type ComposerPromptEditorHandle } from "./ComposerPromptEditor"; import { PullRequestThreadDialog } from "./PullRequestThreadDialog"; import { ChatHeader } from "./chat/ChatHeader"; @@ -423,12 +401,9 @@ import { import { ACTIVE_TURN_LAYOUT_SETTLE_DELAY_MS, shouldStartActiveTurnLayoutGrace, - shouldAutoDeleteTerminalThreadOnLastClose, - buildExpiredTerminalContextToastCopy, buildLocalDraftThread, DISMISSED_PROVIDER_HEALTH_BANNERS_KEY, DismissedProviderHealthBannersSchema, - shouldRenderTerminalWorkspace, collectUserMessageBlobPreviewUrls, deriveComposerSendState, failWorktreeSetupSnapshot, @@ -443,7 +418,6 @@ import { PullRequestDialogState, type QueuedSteerGate, resolveQueuedSteerGateTransition, - shouldRenderProviderHealthBanner, resolveRuntimeModeAfterApprovalDecision, revokeBlobPreviewUrl, revokeUserMessagePreviewUrls, @@ -491,88 +465,6 @@ const LOCAL_PROJECT_DRAFT_CONTEXT = { } as const; const DRAFT_PROJECT_SYNC_MAX_ATTEMPTS = 6; const DRAFT_PROJECT_SYNC_DELAY_MS = 50; -const SETUP_SCRIPT_TERMINAL_ACTIVITY_START_TIMEOUT_MS = 1_000; -const SETUP_SCRIPT_TERMINAL_MAX_RUNTIME_MS = 10 * 60 * 1000; - -function terminalHasRunningSubprocess(threadId: ThreadId, terminalId: string): boolean { - const terminalState = selectThreadTerminalState( - useTerminalStateStore.getState().terminalStateByThreadId, - threadId, - ); - return terminalState.runningTerminalIds.includes(terminalId); -} - -function waitForSetupScriptTerminalActivity(input: { - threadId: ThreadId; - terminalId: string; - observeStartTimeoutMs?: number; - maxRuntimeMs?: number; -}): Promise { - if (typeof window === "undefined") { - return Promise.resolve(); - } - - const observeStartTimeoutMs = - input.observeStartTimeoutMs ?? SETUP_SCRIPT_TERMINAL_ACTIVITY_START_TIMEOUT_MS; - const maxRuntimeMs = input.maxRuntimeMs ?? SETUP_SCRIPT_TERMINAL_MAX_RUNTIME_MS; - - return new Promise((resolve) => { - let resolved = false; - let observedRunning = terminalHasRunningSubprocess(input.threadId, input.terminalId); - let observeStartTimer: number | null = null; - let maxRuntimeTimer: number | null = null; - - const unsubscribe = useTerminalStateStore.subscribe(() => { - checkRunningState(); - }); - - const clearTimers = () => { - if (observeStartTimer !== null) { - window.clearTimeout(observeStartTimer); - observeStartTimer = null; - } - if (maxRuntimeTimer !== null) { - window.clearTimeout(maxRuntimeTimer); - maxRuntimeTimer = null; - } - }; - - const finish = () => { - if (resolved) return; - resolved = true; - clearTimers(); - unsubscribe(); - resolve(); - }; - - const ensureMaxRuntimeTimer = () => { - if (maxRuntimeTimer !== null) return; - maxRuntimeTimer = window.setTimeout(finish, maxRuntimeMs); - }; - - function checkRunningState() { - const running = terminalHasRunningSubprocess(input.threadId, input.terminalId); - if (running) { - observedRunning = true; - if (observeStartTimer !== null) { - window.clearTimeout(observeStartTimer); - observeStartTimer = null; - } - ensureMaxRuntimeTimer(); - return; - } - if (observedRunning) { - finish(); - } - } - - checkRunningState(); - if (!observedRunning) { - observeStartTimer = window.setTimeout(finish, observeStartTimeoutMs); - } - }); -} - function waitForDraftProjectSyncDelay(ms: number): Promise { return new Promise((resolve) => { window.setTimeout(resolve, ms); @@ -726,7 +618,6 @@ function buildQueuedComposerPreviewText(input: { images: ReadonlyArray; files: ReadonlyArray; assistantSelections: ReadonlyArray<{ id: string }>; - terminalContexts: ReadonlyArray; fileComments: ReadonlyArray; pastedTexts: ReadonlyArray; }): string { @@ -744,10 +635,6 @@ function buildQueuedComposerPreviewText(input: { if (input.assistantSelections.length > 0) { return formatAssistantSelectionQueuePreview(input.assistantSelections.length); } - const firstTerminalContext = input.terminalContexts[0]; - if (firstTerminalContext) { - return formatTerminalContextLabel(firstTerminalContext); - } const firstFileComment = input.fileComments[0]; if (firstFileComment) { return formatFileCommentLabel(firstFileComment); @@ -770,22 +657,6 @@ function formatPastedTextTitleSeed(pastedTexts: ReadonlyArray): } const COMPOSER_PATH_QUERY_DEBOUNCE_MS = 120; -const syncTerminalContextsByIds = ( - contexts: ReadonlyArray, - ids: ReadonlyArray, -): TerminalContextDraft[] => { - const contextsById = new Map(contexts.map((context) => [context.id, context])); - return ids.flatMap((id) => { - const context = contextsById.get(id); - return context ? [context] : []; - }); -}; - -const terminalContextIdListsEqual = ( - contexts: ReadonlyArray, - ids: ReadonlyArray, -): boolean => - contexts.length === ids.length && contexts.every((context, index) => context.id === ids[index]); function ComposerControlSkeleton(props: { widthClassName: string }) { return ( @@ -822,7 +693,6 @@ interface ChatViewProps { surfaceMode?: "single" | "split"; presentationMode?: "default" | "editor"; isFocusedPane?: boolean; - panelState?: ChatPanelState; onToggleDiffPanel?: () => void; onOpenTurnDiffPanel?: (turnId: TurnId, filePath?: string) => void; onSplitSurface?: () => void; @@ -879,7 +749,6 @@ export default function ChatView({ surfaceMode = "single", presentationMode = "default", isFocusedPane = true, - panelState, onToggleDiffPanel, onOpenTurnDiffPanel, onSplitSurface, @@ -909,8 +778,7 @@ export default function ChatView({ const { handleNewThread } = useHandleNewThread(); const { handleNewChat } = useHandleNewChat(); const { createThreadHandoff } = useThreadHandoff(); - const rawSearch = useDiffRouteSearch(); - const { resolvedTheme } = useTheme(); + const rawSearch = { panel: null as string | null }; const queryClient = useQueryClient(); const isEditorRail = presentationMode === "editor"; const isInactiveSplitPane = surfaceMode === "split" && !isFocusedPane; @@ -926,7 +794,6 @@ export default function ChatView({ const composerFiles = composerDraft.files; const composerAssistantSelections = composerDraft.assistantSelections; const composerFileComments = composerDraft.fileComments; - const composerTerminalContexts = composerDraft.terminalContexts; const composerPastedTexts = composerDraft.pastedTexts; const composerSkills = composerDraft.skills; const composerMentions = composerDraft.mentions; @@ -940,7 +807,6 @@ export default function ChatView({ fileCount: composerFiles.length, assistantSelectionCount: composerAssistantSelections.length, fileCommentCount: composerFileComments.length, - terminalContexts: composerTerminalContexts, pastedTexts: composerPastedTexts, }), [ @@ -948,7 +814,6 @@ export default function ChatView({ composerFileComments.length, composerFiles.length, composerImages.length, - composerTerminalContexts, composerPastedTexts, prompt, ], @@ -984,20 +849,8 @@ export default function ChatView({ ); const addComposerDraftFileComment = useComposerDraftStore((store) => store.addFileComment); const clearComposerDraftFileComments = useComposerDraftStore((store) => store.clearFileComments); - const insertComposerDraftTerminalContext = useComposerDraftStore( - (store) => store.insertTerminalContext, - ); - const addComposerDraftTerminalContexts = useComposerDraftStore( - (store) => store.addTerminalContexts, - ); - const removeComposerDraftTerminalContext = useComposerDraftStore( - (store) => store.removeTerminalContext, - ); const addComposerDraftPastedTexts = useComposerDraftStore((store) => store.addPastedTexts); const removeComposerDraftPastedText = useComposerDraftStore((store) => store.removePastedText); - const setComposerDraftTerminalContexts = useComposerDraftStore( - (store) => store.setTerminalContexts, - ); const setComposerDraftSkills = useComposerDraftStore((store) => store.setSkills); const setComposerDraftMentions = useComposerDraftStore((store) => store.setMentions); const syncComposerDraftPersistedAttachments = useComposerDraftStore( @@ -1037,7 +890,6 @@ export default function ChatView({ const composerAssistantSelectionsRef = useRef( composerAssistantSelections, ); - const composerTerminalContextsRef = useRef(composerTerminalContexts); const composerFileCommentsRef = useRef(composerFileComments); const composerPastedTextsRef = useRef(composerPastedTexts); const [localDraftErrorsByThreadId, setLocalDraftErrorsByThreadId] = useState< @@ -1084,7 +936,6 @@ export default function ChatView({ // When set, the thread-change reset effect will open the sidebar instead of closing it. // Used by "Implement in a new thread" to carry the sidebar-open intent across navigation. const planSidebarOpenOnNextThreadRef = useRef(false); - const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0); const [composerHighlightedItemId, setComposerHighlightedItemId] = useState(null); const [pullRequestDialogState, setPullRequestDialogState] = useState(null); @@ -1212,7 +1063,6 @@ export default function ChatView({ const sendInFlightRef = useRef(false); const sendPreflightInFlightRef = useRef(false); const dragDepthRef = useRef(0); - const terminalOpenByThreadRef = useRef>({}); const activatedThreadIdRef = useRef(null); useEffect(() => { promptHistoryNavigationRef.current = null; @@ -1247,35 +1097,7 @@ export default function ChatView({ restoredQueuedSourceProposedPlanRef.current = restoredSourceProposedPlan ?? null; }, [restoredSourceProposedPlan]); - const terminalState = useTerminalStateStore((state) => - selectThreadTerminalState(state.terminalStateByThreadId, threadId), - ); - const storeSetTerminalOpen = useTerminalStateStore((s) => s.setTerminalOpen); - const storeSetTerminalPresentationMode = useTerminalStateStore( - (s) => s.setTerminalPresentationMode, - ); - const storeSetTerminalWorkspaceLayout = useTerminalStateStore( - (s) => s.setTerminalWorkspaceLayout, - ); - const storeOpenChatThreadPage = useTerminalStateStore((s) => s.openChatThreadPage); - const storeOpenTerminalThreadPage = useTerminalStateStore((s) => s.openTerminalThreadPage); - const storeSetTerminalWorkspaceTab = useTerminalStateStore((s) => s.setTerminalWorkspaceTab); - const storeSetTerminalHeight = useTerminalStateStore((s) => s.setTerminalHeight); - const storeSetTerminalMetadata = useTerminalStateStore((s) => s.setTerminalMetadata); - const storeSetTerminalActivity = useTerminalStateStore((s) => s.setTerminalActivity); - const storeSplitTerminalLeft = useTerminalStateStore((s) => s.splitTerminalLeft); - const storeSplitTerminalRight = useTerminalStateStore((s) => s.splitTerminalRight); - const storeSplitTerminalDown = useTerminalStateStore((s) => s.splitTerminalDown); - const storeSplitTerminalUp = useTerminalStateStore((s) => s.splitTerminalUp); - const storeNewTerminal = useTerminalStateStore((s) => s.newTerminal); - const storeNewTerminalTab = useTerminalStateStore((s) => s.newTerminalTab); - const storeOpenNewFullWidthTerminal = useTerminalStateStore((s) => s.openNewFullWidthTerminal); - const storeCloseWorkspaceChat = useTerminalStateStore((s) => s.closeWorkspaceChat); - const storeSetActiveTerminal = useTerminalStateStore((s) => s.setActiveTerminal); - const storeCloseTerminal = useTerminalStateStore((s) => s.closeTerminal); - const storeCloseTerminalGroup = useTerminalStateStore((s) => s.closeTerminalGroup); - const storeResizeTerminalSplit = useTerminalStateStore((s) => s.resizeTerminalSplit); - const storeClearTerminalState = useTerminalStateStore((s) => s.clearTerminalState); + // terminal state store removed const setPrompt = useCallback( (nextPrompt: string) => { @@ -1326,13 +1148,6 @@ export default function ChatView({ threadId, ], ); - const addComposerTerminalContextsToDraft = useCallback( - (contexts: TerminalContextDraft[]) => { - discardPromptHistoryNavigationForComposerMutation(); - addComposerDraftTerminalContexts(threadId, contexts); - }, - [addComposerDraftTerminalContexts, discardPromptHistoryNavigationForComposerMutation, threadId], - ); const addComposerPastedTextsToDraft = useCallback( (pastedTexts: PastedTextDraft[]) => { discardPromptHistoryNavigationForComposerMutation(); @@ -1366,35 +1181,6 @@ export default function ChatView({ discardPromptHistoryNavigationForComposerMutation(); clearComposerDraftFileComments(threadId); }, [clearComposerDraftFileComments, discardPromptHistoryNavigationForComposerMutation, threadId]); - const removeComposerTerminalContextFromDraft = useCallback( - (contextId: string) => { - discardPromptHistoryNavigationForComposerMutation(); - const contextIndex = composerTerminalContexts.findIndex( - (context) => context.id === contextId, - ); - if (contextIndex < 0) { - return; - } - const nextPrompt = removeInlineTerminalContextPlaceholder(promptRef.current, contextIndex); - promptRef.current = nextPrompt.prompt; - setPrompt(nextPrompt.prompt); - removeComposerDraftTerminalContext(threadId, contextId); - setComposerCursor(nextPrompt.cursor); - setComposerTrigger( - detectComposerTrigger( - nextPrompt.prompt, - expandCollapsedComposerCursor(nextPrompt.prompt, nextPrompt.cursor), - ), - ); - }, - [ - composerTerminalContexts, - discardPromptHistoryNavigationForComposerMutation, - removeComposerDraftTerminalContext, - setPrompt, - threadId, - ], - ); const removeComposerPastedTextFromDraft = useCallback( (pastedTextId: string) => { discardPromptHistoryNavigationForComposerMutation(); @@ -1465,7 +1251,7 @@ export default function ChatView({ const isLocalDraftThread = !isServerThread && localDraftThread !== undefined; const canCheckoutPullRequestIntoThread = isLocalDraftThread; const diffOpen = rawSearch.panel === "diff"; - const resolvedDiffOpen = panelState ? panelState.panel === "diff" : diffOpen; + const resolvedDiffOpen = diffOpen; const activeThreadId = activeThread?.id ?? null; const activeLatestTurn = activeThread?.latestTurn ?? null; const threadActivities = activeThread?.activities ?? EMPTY_ACTIVITIES; @@ -1623,11 +1409,7 @@ export default function ChatView({ } const activeDraftThread = getDraftThread(threadId); - if ( - !isServerThread && - activeDraftThread?.projectId === activeProject.id && - activeDraftThread.entryPoint === "chat" - ) { + if (!isServerThread && activeDraftThread?.projectId === activeProject.id) { setDraftThreadContext(threadId, draftThreadContext); setProjectDraftThreadId(activeProject.id, threadId, draftThreadContext); return; @@ -3013,7 +2795,7 @@ export default function ChatView({ canOfferReviewSlashCommand({ prompt: composerPromptWithoutActiveSlashTrigger, imageCount: composerImages.length, - terminalContextCount: composerTerminalContexts.length, + terminalContextCount: 0, selectedSkillCount: selectedComposerSkills.length, selectedMentionCount: selectedComposerMentions.length, }); @@ -3023,7 +2805,7 @@ export default function ChatView({ canOfferForkSlashCommand({ prompt: composerPromptWithoutActiveSlashTrigger, imageCount: composerImages.length, - terminalContextCount: composerTerminalContexts.length, + terminalContextCount: 0, selectedSkillCount: selectedComposerSkills.length, selectedMentionCount: selectedComposerMentions.length, }); @@ -3287,28 +3069,7 @@ export default function ChatView({ }), [activeLatestTurn?.turnId, rawWorkLogEntries, turnDiffSummaries], ); - const splitTerminalShortcutLabel = useMemo( - () => - shortcutLabelForCommand(keybindings, "terminal.splitRight") ?? - shortcutLabelForCommand(keybindings, "terminal.split"), - [keybindings], - ); - const splitTerminalDownShortcutLabel = useMemo( - () => shortcutLabelForCommand(keybindings, "terminal.splitDown"), - [keybindings], - ); - const newTerminalShortcutLabel = useMemo( - () => shortcutLabelForCommand(keybindings, "terminal.new"), - [keybindings], - ); - const closeTerminalShortcutLabel = useMemo( - () => shortcutLabelForCommand(keybindings, "terminal.close"), - [keybindings], - ); - const closeWorkspaceShortcutLabel = useMemo( - () => shortcutLabelForCommand(keybindings, "terminal.workspace.closeActive"), - [keybindings], - ); + // terminal shortcut labels removed const diffPanelShortcutLabel = useMemo( () => shortcutLabelForCommand(keybindings, "diff.toggle"), [keybindings], @@ -3380,39 +3141,10 @@ export default function ChatView({ (activeThread.messages.length > 0 || (activeThread.session !== null && activeThread.session.status !== "closed")), ); - const activeTerminalGroup = - terminalState.terminalGroups.find( - (group) => group.id === terminalState.activeTerminalGroupId, - ) ?? - terminalState.terminalGroups.find((group) => - collectTerminalIdsFromLayout(group.layout).includes(terminalState.activeTerminalId), - ) ?? - null; - const hasReachedSplitLimit = - (activeTerminalGroup ? collectTerminalIdsFromLayout(activeTerminalGroup.layout).length : 0) >= - MAX_TERMINALS_PER_GROUP; - const terminalWorkspaceOpen = shouldRenderTerminalWorkspace({ - presentationMode: terminalState.presentationMode, - terminalOpen: terminalState.terminalOpen, - }); - const terminalWorkspaceTerminalTabActive = - terminalWorkspaceOpen && - (terminalState.workspaceLayout === "terminal-only" || - terminalState.workspaceActiveTab === "terminal"); - const isTerminalPrimarySurface = terminalState.entryPoint === "terminal"; - const isTerminalEnvironmentContext = - isTerminalPrimarySurface || terminalWorkspaceTerminalTabActive; - const shouldShowProviderHealthBanner = shouldRenderProviderHealthBanner({ - threadEntryPoint: terminalState.entryPoint, - terminalWorkspaceTerminalTabActive, - }); - // Terminal-only threads should not pay to mount the hidden chat/composer pane. - const shouldRenderChatPaneContent = !( - terminalWorkspaceTerminalTabActive && terminalState.workspaceLayout === "terminal-only" - ); + const shouldShowProviderHealthBanner = true; + const shouldRenderChatPaneContent = true; const secondaryChromeThreadId = activeThread?.id ?? threadId; - const shouldDeferSecondaryChrome = - activeThread !== undefined && !isCenteredEmptyLanding && !terminalWorkspaceTerminalTabActive; + const shouldDeferSecondaryChrome = activeThread !== undefined && !isCenteredEmptyLanding; const [secondaryChromeState, setSecondaryChromeState] = useState(() => ({ threadId: secondaryChromeThreadId, ready: true, @@ -3445,10 +3177,7 @@ export default function ChatView({ window.cancelAnimationFrame(frame); }; }, [secondaryChromeThreadId, shouldDeferSecondaryChrome]); - const terminalWorkspaceChatTabActive = - terminalWorkspaceOpen && - terminalState.workspaceLayout === "both" && - terminalState.workspaceActiveTab === "chat"; + // terminalWorkspaceChatTabActive removed const setThreadError = useCallback( (targetThreadId: ThreadId | null, error: string | null) => { if (!targetThreadId) return; @@ -3533,56 +3262,6 @@ export default function ChatView({ setIsModelPickerOpen(false); } }, []); - const addTerminalContextToDraft = useCallback( - (selection: TerminalContextSelection) => { - if (!activeThread) { - return; - } - discardPromptHistoryNavigationForComposerMutation(); - const snapshot = composerEditorRef.current?.readSnapshot() ?? { - value: promptRef.current, - cursor: composerCursor, - expandedCursor: expandCollapsedComposerCursor(promptRef.current, composerCursor), - selectionCollapsed: true, - terminalContextIds: composerTerminalContexts.map((context) => context.id), - }; - const insertion = insertInlineTerminalContextPlaceholder( - snapshot.value, - snapshot.expandedCursor, - ); - const nextCollapsedCursor = collapseExpandedComposerCursor( - insertion.prompt, - insertion.cursor, - ); - const inserted = insertComposerDraftTerminalContext( - activeThread.id, - insertion.prompt, - { - id: randomUUID(), - threadId: activeThread.id, - createdAt: new Date().toISOString(), - ...selection, - }, - insertion.contextIndex, - ); - if (!inserted) { - return; - } - promptRef.current = insertion.prompt; - setComposerCursor(nextCollapsedCursor); - setComposerTrigger(detectComposerTrigger(insertion.prompt, insertion.cursor)); - window.requestAnimationFrame(() => { - composerEditorRef.current?.focusAt(nextCollapsedCursor); - }); - }, - [ - activeThread, - composerCursor, - composerTerminalContexts, - discardPromptHistoryNavigationForComposerMutation, - insertComposerDraftTerminalContext, - ], - ); // Collapse an oversized paste into an attachment card above the composer instead // of flooding the editor with raw text. The card holds the full content until the // user sends or clicks "Show in text field". @@ -3602,382 +3281,10 @@ export default function ChatView({ }, [activeThread, addComposerDraftPastedTexts, discardPromptHistoryNavigationForComposerMutation], ); - const setTerminalOpen = useCallback( - (open: boolean) => { - if (!activeThreadId) return; - storeSetTerminalOpen(activeThreadId, open); - }, - [activeThreadId, storeSetTerminalOpen], - ); - const setTerminalPresentationMode = useCallback( - (mode: "drawer" | "workspace") => { - if (!activeThreadId) return; - storeSetTerminalPresentationMode(activeThreadId, mode); - }, - [activeThreadId, storeSetTerminalPresentationMode], - ); - const setTerminalWorkspaceLayout = useCallback( - (layout: "both" | "terminal-only") => { - if (!activeThreadId) return; - storeSetTerminalWorkspaceLayout(activeThreadId, layout); - }, - [activeThreadId, storeSetTerminalWorkspaceLayout], - ); - const setTerminalWorkspaceTab = useCallback( - (tab: "terminal" | "chat") => { - if (!activeThreadId) return; - storeSetTerminalWorkspaceTab(activeThreadId, tab); - }, - [activeThreadId, storeSetTerminalWorkspaceTab], - ); - const setTerminalHeight = useCallback( - (height: number) => { - if (!activeThreadId) return; - storeSetTerminalHeight(activeThreadId, height); - }, - [activeThreadId, storeSetTerminalHeight], - ); - const toggleTerminalVisibility = useCallback(() => { - if (!activeThreadId) return; - if (!terminalState.terminalOpen) { - setTerminalPresentationMode("drawer"); - } - setTerminalOpen(!terminalState.terminalOpen); - }, [activeThreadId, setTerminalOpen, setTerminalPresentationMode, terminalState.terminalOpen]); - const expandTerminalWorkspace = useCallback(() => { - if (!activeThreadId) return; - setTerminalPresentationMode("workspace"); - setTerminalWorkspaceLayout("both"); - setTerminalWorkspaceTab("terminal"); - }, [ - activeThreadId, - setTerminalPresentationMode, - setTerminalWorkspaceLayout, - setTerminalWorkspaceTab, - ]); - const collapseTerminalWorkspace = useCallback(() => { - if (!activeThreadId) return; - setTerminalPresentationMode("drawer"); - }, [activeThreadId, setTerminalPresentationMode]); - const splitTerminalRight = useCallback(() => { - if (!activeThreadId || hasReachedSplitLimit) return; - const terminalId = randomTerminalId(); - storeSplitTerminalRight(activeThreadId, terminalId); - setTerminalFocusRequestId((value) => value + 1); - }, [activeThreadId, hasReachedSplitLimit, storeSplitTerminalRight]); - const splitTerminalLeft = useCallback(() => { - if (!activeThreadId || hasReachedSplitLimit) return; - const terminalId = randomTerminalId(); - storeSplitTerminalLeft(activeThreadId, terminalId); - setTerminalFocusRequestId((value) => value + 1); - }, [activeThreadId, hasReachedSplitLimit, storeSplitTerminalLeft]); - const splitTerminalDown = useCallback(() => { - if (!activeThreadId || hasReachedSplitLimit) return; - const terminalId = randomTerminalId(); - storeSplitTerminalDown(activeThreadId, terminalId); - setTerminalFocusRequestId((value) => value + 1); - }, [activeThreadId, hasReachedSplitLimit, storeSplitTerminalDown]); - const splitTerminalUp = useCallback(() => { - if (!activeThreadId || hasReachedSplitLimit) return; - const terminalId = randomTerminalId(); - storeSplitTerminalUp(activeThreadId, terminalId); - setTerminalFocusRequestId((value) => value + 1); - }, [activeThreadId, hasReachedSplitLimit, storeSplitTerminalUp]); - const createNewTerminal = useCallback(() => { - if (!activeThreadId) return; - const terminalId = randomTerminalId(); - storeNewTerminal(activeThreadId, terminalId); - setTerminalFocusRequestId((value) => value + 1); - }, [activeThreadId, storeNewTerminal]); - const createNewTerminalTab = useCallback( - (targetTerminalId: string) => { - if (!activeThreadId) return; - const terminalId = randomTerminalId(); - storeNewTerminalTab(activeThreadId, targetTerminalId, terminalId); - setTerminalFocusRequestId((value) => value + 1); - }, - [activeThreadId, storeNewTerminalTab], - ); - const createTerminalFromShortcut = useCallback(() => { - const action = resolveTerminalNewAction({ - terminalOpen: terminalState.terminalOpen, - activeTerminalId: terminalState.activeTerminalId, - activeTerminalGroupId: terminalState.activeTerminalGroupId, - terminalGroups: terminalState.terminalGroups, - }); - - if (action.kind === "new-group") { - if (!terminalState.terminalOpen) { - setTerminalOpen(true); - } - createNewTerminal(); - return; - } - - createNewTerminalTab(action.targetTerminalId); - }, [ - createNewTerminal, - createNewTerminalTab, - setTerminalOpen, - terminalState.activeTerminalGroupId, - terminalState.activeTerminalId, - terminalState.terminalGroups, - terminalState.terminalOpen, - ]); - const moveTerminalToNewGroup = useCallback( - (terminalId: string) => { - if (!activeThreadId) return; - storeNewTerminal(activeThreadId, terminalId); - setTerminalFocusRequestId((value) => value + 1); - }, - [activeThreadId, storeNewTerminal], - ); - const openNewFullWidthTerminal = useCallback(() => { - if (!activeThreadId || !activeProject) return; - const terminalId = randomTerminalId(); - storeOpenNewFullWidthTerminal(activeThreadId, terminalId); - setTerminalFocusRequestId((value) => value + 1); - }, [activeProject, activeThreadId, storeOpenNewFullWidthTerminal]); - // Desktop accelerators like Cmd+T can be claimed by Electron before the page sees keydown. - useEffect(() => { - const onMenuAction = window.desktopBridge?.onMenuAction; - if (typeof onMenuAction !== "function" || !isFocusedPane) { - return; - } - - const unsubscribe = onMenuAction((action) => { - if (action !== "new-terminal-tab") return; - createTerminalFromShortcut(); - }); - - return () => { - unsubscribe?.(); - }; - }, [createTerminalFromShortcut, isFocusedPane]); - const activateTerminal = useCallback( - (terminalId: string) => { - if (!activeThreadId) return; - storeSetActiveTerminal(activeThreadId, terminalId); - setTerminalFocusRequestId((value) => value + 1); - }, - [activeThreadId, storeSetActiveTerminal], - ); - const closeTerminal = useCallback( - async (terminalId: string) => { - const api = readNativeApi(); - if (!activeThreadId || !api) return; - const isFinalTerminal = terminalState.terminalIds.length <= 1; - const shouldDeletePlaceholderTerminalThread = shouldAutoDeleteTerminalThreadOnLastClose({ - isLastTerminal: isFinalTerminal, - isServerThread, - terminalEntryPoint: terminalState.entryPoint, - thread: activeThread, - }); - const confirmed = await confirmTerminalTabClose({ - api, - enabled: shouldPromptForTerminalClose({ - confirmationEnabled: settings.confirmTerminalTabClose, - runningTerminalIds: terminalState.runningTerminalIds, - terminalAttentionStatesById: terminalState.terminalAttentionStatesById, - terminalId, - }), - terminalTitle: resolveTerminalCloseTitle({ - terminalId, - terminalLabelsById: terminalState.terminalLabelsById, - terminalTitleOverridesById: terminalState.terminalTitleOverridesById, - }), - willDeleteThread: shouldDeletePlaceholderTerminalThread, - }); - if (!confirmed) { - return; - } - disposeAndCloseTerminalSession({ - api, - threadId: activeThreadId, - terminalId, - clearHistoryBeforeClose: isFinalTerminal, - }); - storeCloseTerminal(activeThreadId, terminalId); - setTerminalFocusRequestId((value) => value + 1); - if (!shouldDeletePlaceholderTerminalThread) { - return; - } - void (async () => { - try { - await api.orchestration.dispatchCommand({ - type: "thread.delete", - commandId: newCommandId(), - threadId: activeThreadId, - }); - void reconcileDeletedThreadFromClient({ - threadId: activeThreadId, - removeDeletedThreadFromClientState: - useStore.getState().removeDeletedThreadFromClientState, - }); - useComposerDraftStore.getState().clearDraftThread(activeThreadId); - storeClearTerminalState(activeThreadId); - await handleNewChat({ fresh: true }); - } catch (error) { - console.error("Failed to delete empty terminal thread after closing its last terminal", { - threadId: activeThreadId, - error, - }); - } - })(); - }, - [ - activeThread, - activeThreadId, - handleNewChat, - isServerThread, - navigate, - storeClearTerminalState, - storeCloseTerminal, - syncServerShellSnapshot, - settings.confirmTerminalTabClose, - terminalState.entryPoint, - terminalState.runningTerminalIds, - terminalState.terminalAttentionStatesById, - terminalState.terminalIds.length, - terminalState.terminalLabelsById, - terminalState.terminalTitleOverridesById, - ], - ); - const closeActiveWorkspaceView = useCallback(() => { - if (!activeThreadId || !terminalWorkspaceOpen) { - return; - } - if (terminalState.workspaceLayout === "both" && terminalState.workspaceActiveTab === "chat") { - // Going terminal-only hides the chat/terminal switcher, leaving chat-backed - // threads with no mouse path back to chat. For those, collapse the workspace - // to the normal chat + terminal-drawer layout instead of stranding the user. - if (terminalState.entryPoint === "chat") { - collapseTerminalWorkspace(); - return; - } - storeCloseWorkspaceChat(activeThreadId); - return; - } - closeTerminal(terminalState.activeTerminalId); - }, [ - activeThreadId, - closeTerminal, - collapseTerminalWorkspace, - storeCloseWorkspaceChat, - terminalState.activeTerminalId, - terminalState.entryPoint, - terminalState.workspaceActiveTab, - terminalState.workspaceLayout, - terminalWorkspaceOpen, - ]); - // The terminal's panel toggle mirrors the right dock's collapse control: it shows - // or hides the side panel only when this thread already has a pane to show. - const rightDockOpen = useRightDockStore((store) => selectRightDockState(threadId)(store).open); - const hasRightDockPanes = useRightDockStore( - (store) => selectRightDockState(threadId)(store).panes.length > 0, - ); - const setRightDockOpen = useRightDockStore((store) => store.setDockOpen); - const toggleRightDock = useCallback(() => { - setRightDockOpen(threadId, !rightDockOpen); - }, [rightDockOpen, setRightDockOpen, threadId]); - const terminalDrawerProps = useMemo( - () => ({ - threadId, - onTogglePanel: hasRightDockPanes ? toggleRightDock : undefined, - isPanelOpen: hasRightDockPanes ? rightDockOpen : undefined, - cwd: gitCwd ?? activeProject?.cwd ?? "", - runtimeEnv: threadTerminalRuntimeEnv, - height: terminalState.terminalHeight, - terminalIds: terminalState.terminalIds, - terminalLabelsById: terminalState.terminalLabelsById, - terminalTitleOverridesById: terminalState.terminalTitleOverridesById, - terminalCliKindsById: terminalState.terminalCliKindsById, - terminalAttentionStatesById: terminalState.terminalAttentionStatesById ?? {}, - runningTerminalIds: terminalState.runningTerminalIds, - activeTerminalId: terminalState.activeTerminalId, - terminalGroups: terminalState.terminalGroups, - activeTerminalGroupId: terminalState.activeTerminalGroupId, - focusRequestId: terminalFocusRequestId, - onSplitTerminal: splitTerminalRight, - onSplitTerminalDown: splitTerminalDown, - onNewTerminal: createNewTerminal, - onNewTerminalTab: createNewTerminalTab, - onMoveTerminalToGroup: moveTerminalToNewGroup, - splitShortcutLabel: splitTerminalShortcutLabel ?? undefined, - splitDownShortcutLabel: splitTerminalDownShortcutLabel ?? undefined, - newShortcutLabel: newTerminalShortcutLabel ?? undefined, - closeShortcutLabel: closeTerminalShortcutLabel ?? undefined, - workspaceCloseShortcutLabel: closeWorkspaceShortcutLabel ?? undefined, - onActiveTerminalChange: activateTerminal, - onCloseTerminal: closeTerminal, - onCloseTerminalGroup: (groupId: string) => { - if (!activeThreadId) return; - storeCloseTerminalGroup(activeThreadId, groupId); - }, - onHeightChange: setTerminalHeight, - onResizeTerminalSplit: (groupId: string, splitId: string, weights: number[]) => { - if (!activeThreadId) return; - storeResizeTerminalSplit(activeThreadId, groupId, splitId, weights); - }, - onTerminalMetadataChange: ( - terminalId: string, - metadata: { cliKind: "codex" | "claude" | null; label: string }, - ) => { - if (!activeThreadId) return; - storeSetTerminalMetadata(activeThreadId, terminalId, metadata); - }, - onTerminalActivityChange: ( - terminalId: string, - activity: { - hasRunningSubprocess: boolean; - agentState: "running" | "attention" | "review" | null; - }, - ) => { - if (!activeThreadId) return; - storeSetTerminalActivity(activeThreadId, terminalId, activity); - }, - onAddTerminalContext: addTerminalContextToDraft, - }), - [ - activeProject?.cwd, - activateTerminal, - addTerminalContextToDraft, - closeTerminal, - closeTerminalShortcutLabel, - closeWorkspaceShortcutLabel, - createNewTerminal, - createNewTerminalTab, - moveTerminalToNewGroup, - gitCwd, - activeThreadId, - newTerminalShortcutLabel, - setTerminalHeight, - splitTerminalRight, - splitTerminalDown, - splitTerminalShortcutLabel, - splitTerminalDownShortcutLabel, - storeCloseTerminalGroup, - storeResizeTerminalSplit, - storeSetTerminalActivity, - storeSetTerminalMetadata, - terminalFocusRequestId, - terminalState.activeTerminalGroupId, - terminalState.activeTerminalId, - terminalState.terminalAttentionStatesById, - terminalState.terminalCliKindsById, - terminalState.terminalGroups, - terminalState.terminalHeight, - terminalState.terminalIds, - terminalState.terminalLabelsById, - terminalState.terminalTitleOverridesById, - terminalState.runningTerminalIds, - threadId, - threadTerminalRuntimeEnv, - toggleRightDock, - rightDockOpen, - hasRightDockPanes, - ], - ); + // terminal state callbacks removed + // Desktop menu terminal action removed + // terminal activate/close callbacks removed + // rightDock and terminalDrawerProps removed const runProjectScript = useCallback( async ( script: ProjectScript, @@ -3991,76 +3298,20 @@ export default function ChatView({ return { ...current, [activeProject.id]: script.id }; }); } - const targetCwd = options?.cwd ?? gitCwd ?? activeProject.cwd; - const baseTerminalId = - terminalState.activeTerminalId || - terminalState.terminalIds[0] || - DEFAULT_THREAD_TERMINAL_ID; - const { shouldCreateNewTerminal, terminalId: targetTerminalId } = - resolveProjectScriptTerminalTarget({ - baseTerminalId, - createTerminalId: randomTerminalId, - hasRunningTerminal: terminalState.runningTerminalIds.length > 0, - preferNewTerminal: options?.preferNewTerminal, - terminalOpen: terminalState.terminalOpen, - }); - setTerminalOpen(true); - if (shouldCreateNewTerminal) { - storeNewTerminal(activeThreadId, targetTerminalId); - } else { - storeSetActiveTerminal(activeThreadId, targetTerminalId); - } - setTerminalFocusRequestId((value) => value + 1); - - try { - const { metadata } = await runProjectCommandInTerminal({ - api, - threadId: activeThreadId, - terminalId: targetTerminalId, - project: { - cwd: activeProject.cwd, - }, - cwd: targetCwd, - command: script.command, - worktreePath: options?.worktreePath ?? activeThread.worktreePath ?? null, - ...(options?.env ? { env: options.env } : {}), - }); - if (metadata) { - storeSetTerminalMetadata(activeThreadId, targetTerminalId, { - cliKind: metadata.cliKind, - label: metadata.label, - }); - } - return { terminalId: targetTerminalId }; - } catch (error) { - setThreadError( - activeThreadId, - error instanceof Error ? error.message : `Failed to run script "${script.name}".`, - ); - if (options?.throwOnError) { - throw error instanceof Error - ? error - : new Error(`Failed to run script "${script.name}".`); - } - return null; + setThreadError(activeThreadId, `Script "${script.name}" cannot run without a terminal.`); + if (options?.throwOnError) { + throw new Error(`Script "${script.name}" cannot run without a terminal.`); } + return null; }, [ activeProject, activeThread, activeThreadId, gitCwd, - setTerminalOpen, setThreadError, - storeNewTerminal, - storeSetActiveTerminal, - storeSetTerminalMetadata, setLastInvokedScriptByProjectId, - terminalState.activeTerminalId, - terminalState.terminalOpen, - terminalState.runningTerminalIds, - terminalState.terminalIds, ], ); const stopActiveThreadSession = useCallback(async () => { @@ -4694,14 +3945,14 @@ export default function ChatView({ }, [activeThread?.id]); useEffect(() => { - if (!activeThread?.id || terminalState.terminalOpen || isInactiveSplitPane) return; + if (!activeThread?.id || isInactiveSplitPane) return; const frame = window.requestAnimationFrame(() => { focusComposer(); }); return () => { window.cancelAnimationFrame(frame); }; - }, [activeThread?.id, focusComposer, isInactiveSplitPane, terminalState.terminalOpen]); + }, [activeThread?.id, focusComposer, isInactiveSplitPane]); useEffect(() => { composerImagesRef.current = composerImages; @@ -4715,10 +3966,6 @@ export default function ChatView({ composerAssistantSelectionsRef.current = composerAssistantSelections; }, [composerAssistantSelections]); - useEffect(() => { - composerTerminalContextsRef.current = composerTerminalContexts; - }, [composerTerminalContexts]); - useEffect(() => { composerFileCommentsRef.current = composerFileComments; }, [composerFileComments]); @@ -5078,28 +4325,6 @@ export default function ChatView({ serverAcknowledgedLocalDispatch, ]); - useEffect(() => { - if (!activeThreadId) return; - const previous = terminalOpenByThreadRef.current[activeThreadId] ?? false; - const current = Boolean(terminalState.terminalOpen); - - if (!previous && current) { - terminalOpenByThreadRef.current[activeThreadId] = current; - setTerminalFocusRequestId((value) => value + 1); - return; - } else if (previous && !current) { - terminalOpenByThreadRef.current[activeThreadId] = current; - const frame = window.requestAnimationFrame(() => { - focusComposer(); - }); - return () => { - window.cancelAnimationFrame(frame); - }; - } - - terminalOpenByThreadRef.current[activeThreadId] = current; - }, [activeThreadId, focusComposer, terminalState.terminalOpen]); - useEffect(() => { if (!activeThreadId) { activatedThreadIdRef.current = null; @@ -5109,29 +4334,7 @@ export default function ChatView({ return; } activatedThreadIdRef.current = activeThreadId; - if (terminalState.entryPoint !== "terminal") { - return; - } - storeOpenTerminalThreadPage(activeThreadId); - }, [activeThreadId, storeOpenTerminalThreadPage, terminalState.entryPoint]); - - useEffect(() => { - if (!terminalWorkspaceOpen) { - return; - } - - if (terminalState.workspaceActiveTab === "terminal") { - setTerminalFocusRequestId((value) => value + 1); - return; - } - - const frame = window.requestAnimationFrame(() => { - focusComposer(); - }); - return () => { - window.cancelAnimationFrame(frame); - }; - }, [focusComposer, terminalState.workspaceActiveTab, terminalWorkspaceOpen]); + }, [activeThreadId]); const onInterrupt = useCallback(async () => { const api = readNativeApi(); @@ -5168,17 +4371,8 @@ export default function ChatView({ return; } const composerPickerShortcutActive = - !isTerminalFocused() && - !isComposerApprovalState && - canHandleComposerPickerShortcut(event, composerFormRef.current); - const shortcutContext = { - terminalFocus: isTerminalFocused(), - terminalOpen: Boolean(terminalState.terminalOpen), - terminalWorkspaceOpen, - terminalWorkspaceTerminalOnly: terminalState.workspaceLayout === "terminal-only", - terminalWorkspaceTerminalTabActive, - terminalWorkspaceChatTabActive, - }; + !isComposerApprovalState && canHandleComposerPickerShortcut(event, composerFormRef.current); + const shortcutContext = {}; const command = resolveShortcutCommand(event, keybindings, { context: shortcutContext, @@ -5211,98 +4405,6 @@ export default function ChatView({ return; } - if (command === "terminal.toggle") { - event.preventDefault(); - event.stopPropagation(); - toggleTerminalVisibility(); - return; - } - - if (command === "terminal.split" || command === "terminal.splitRight") { - event.preventDefault(); - event.stopPropagation(); - if (!terminalState.terminalOpen) { - setTerminalOpen(true); - } - splitTerminalRight(); - return; - } - - if (command === "terminal.splitLeft") { - event.preventDefault(); - event.stopPropagation(); - if (!terminalState.terminalOpen) { - setTerminalOpen(true); - } - splitTerminalLeft(); - return; - } - - if (command === "terminal.splitDown") { - event.preventDefault(); - event.stopPropagation(); - if (!terminalState.terminalOpen) { - setTerminalOpen(true); - } - splitTerminalDown(); - return; - } - - if (command === "terminal.splitUp") { - event.preventDefault(); - event.stopPropagation(); - if (!terminalState.terminalOpen) { - setTerminalOpen(true); - } - splitTerminalUp(); - return; - } - - if (command === "terminal.close") { - event.preventDefault(); - event.stopPropagation(); - if (!terminalState.terminalOpen) return; - closeTerminal(terminalState.activeTerminalId); - return; - } - - if (command === "terminal.new") { - event.preventDefault(); - event.stopPropagation(); - createTerminalFromShortcut(); - return; - } - - if (command === "terminal.workspace.newFullWidth") { - event.preventDefault(); - event.stopPropagation(); - openNewFullWidthTerminal(); - return; - } - - if (command === "terminal.workspace.closeActive") { - event.preventDefault(); - event.stopPropagation(); - closeActiveWorkspaceView(); - return; - } - - if (command === "terminal.workspace.terminal") { - event.preventDefault(); - event.stopPropagation(); - if (!terminalWorkspaceOpen) return; - setTerminalWorkspaceTab("terminal"); - return; - } - - if (command === "terminal.workspace.chat") { - event.preventDefault(); - event.stopPropagation(); - if (!terminalWorkspaceOpen) return; - setTerminalWorkspaceTab("chat"); - return; - } - if (command === "diff.toggle") { event.preventDefault(); event.stopPropagation(); @@ -5310,14 +4412,6 @@ export default function ChatView({ return; } - if (command === "rightPanel.toggle") { - event.preventDefault(); - event.stopPropagation(); - if (!hasRightDockPanes) return; - toggleRightDock(); - return; - } - if (command === "chat.split") { event.preventDefault(); event.stopPropagation(); @@ -5339,24 +4433,9 @@ export default function ChatView({ return () => window.removeEventListener("keydown", handler, { capture: true }); }, [ activeProject, - terminalState.terminalOpen, - terminalState.activeTerminalId, - terminalState.workspaceLayout, activeThreadId, - closeTerminal, - closeActiveWorkspaceView, - createTerminalFromShortcut, - setTerminalOpen, - openNewFullWidthTerminal, runProjectScript, keybindings, - splitTerminalDown, - splitTerminalLeft, - splitTerminalRight, - splitTerminalUp, - terminalWorkspaceChatTabActive, - terminalWorkspaceOpen, - terminalWorkspaceTerminalTabActive, onToggleDiff, onInterrupt, onSplitSurface, @@ -5364,15 +4443,11 @@ export default function ChatView({ hasLiveTurn, handleModelPickerOpenChange, handleTraitsPickerOpenChange, - hasRightDockPanes, isComposerApprovalState, pendingUserInputs.length, - setTerminalWorkspaceTab, surfaceMode, scheduleComposerFocus, toggleComposerFocus, - toggleRightDock, - toggleTerminalVisibility, ]); // --- Composer attachment entry points ------------------------------------- @@ -5583,9 +4658,6 @@ export default function ChatView({ for (const comment of restoredFileComments) { addComposerFileCommentToDraft(comment); } - if (queuedTurn.terminalContexts.length > 0) { - addComposerTerminalContextsToDraft(queuedTurn.terminalContexts); - } if (queuedTurn.pastedTexts.length > 0) { addComposerPastedTextsToDraft(queuedTurn.pastedTexts); } @@ -5617,7 +4689,6 @@ export default function ChatView({ addComposerFileCommentToDraft, addComposerFilesToDraft, addComposerImagesToDraft, - addComposerTerminalContextsToDraft, addComposerPastedTextsToDraft, clearComposerDraftContent, scheduleComposerFocus, @@ -5718,8 +4789,6 @@ export default function ChatView({ queuedChatTurn?.assistantSelections ?? composerAssistantSelections; const composerImagesForSend = composerRegularImagesForSend; const composerFileCommentsForSend = queuedChatTurn?.fileComments ?? composerFileComments; - const composerTerminalContextsForSend = - queuedChatTurn?.terminalContexts ?? composerTerminalContexts; const composerPastedTextsForSend = queuedChatTurn?.pastedTexts ?? composerPastedTexts; const selectedComposerSkillsForSend = queuedChatTurn?.skills ?? selectedComposerSkillsRef.current; @@ -5736,8 +4805,6 @@ export default function ChatView({ const envModeForSend = queuedChatTurn?.envMode ?? envMode; const { trimmedPrompt: trimmed, - sendableTerminalContexts: sendableComposerTerminalContexts, - expiredTerminalContextCount, sendablePastedTexts: sendableComposerPastedTexts, hasSendableContent, } = deriveComposerSendState({ @@ -5746,7 +4813,6 @@ export default function ChatView({ fileCount: composerFilesForSend.length, assistantSelectionCount: composerAssistantSelectionsForSend.length, fileCommentCount: composerFileCommentsForSend.length, - terminalContexts: composerTerminalContextsForSend, pastedTexts: composerPastedTextsForSend, }); let trimmedPromptForSend = trimmed; @@ -5770,7 +4836,6 @@ export default function ChatView({ composerFilesForSend.length > 0 || composerAssistantSelectionsForSend.length > 0 || composerFileCommentsForSend.length > 0 || - sendableComposerTerminalContexts.length > 0 || sendableComposerPastedTexts.length > 0; // Queued chat turns already captured their intended mode. Live plan follow-ups // with attachments must use the normal send path so references are preserved. @@ -5817,7 +4882,6 @@ export default function ChatView({ composerFilesForSend.length === 0 && composerAssistantSelectionsForSend.length === 0 && composerFileCommentsForSend.length === 0 && - sendableComposerTerminalContexts.length === 0 && sendableComposerPastedTexts.length === 0 && // Provider mentions are structured turn metadata, and automation definitions persist text only. selectedComposerMentionsForSend.length === 0; @@ -5838,17 +4902,6 @@ export default function ChatView({ }) : undefined); if (!hasSendableContent) { - if (expiredTerminalContextCount > 0) { - const toastCopy = buildExpiredTerminalContextToastCopy( - expiredTerminalContextCount, - "empty", - ); - toastManager.add({ - type: "warning", - title: toastCopy.title, - description: toastCopy.description, - }); - } return false; } if (!activeProject) return false; @@ -5896,7 +4949,6 @@ export default function ChatView({ images: queuedImagesForPersistence, files: composerFilesForSend, assistantSelections: composerAssistantSelectionsForSend, - terminalContexts: sendableComposerTerminalContexts, fileComments: composerFileCommentsForSend, pastedTexts: sendableComposerPastedTexts, }), @@ -5905,7 +4957,6 @@ export default function ChatView({ files: composerFilesForSend, assistantSelections: composerAssistantSelectionsForSend, fileComments: composerFileCommentsForSend, - terminalContexts: sendableComposerTerminalContexts, pastedTexts: sendableComposerPastedTexts, skills: selectedComposerSkillsForSend, mentions: selectedComposerMentionsForSend, @@ -5937,8 +4988,7 @@ export default function ChatView({ titleSeed = `File: ${composerFilesForSend[0]?.name ?? "attachment"}`; } else if (composerAssistantSelectionsForSend.length > 0) { titleSeed = formatAssistantSelectionTitleSeed(composerAssistantSelectionsForSend.length); - } else if (sendableComposerTerminalContexts.length > 0) { - titleSeed = formatTerminalContextLabel(sendableComposerTerminalContexts[0]!); + } else if (false) { } else if (composerFileCommentsForSend.length > 0) { titleSeed = formatFileCommentTitleSeed(composerFileCommentsForSend.length); } else if (sendableComposerPastedTexts.length > 0) { @@ -6073,14 +5123,12 @@ export default function ChatView({ const composerFilesSnapshot = [...composerFilesForSend]; const composerAssistantSelectionsSnapshot = [...composerAssistantSelectionsForSend]; const composerFileCommentsSnapshot = [...composerFileCommentsForSend]; - const composerTerminalContextsSnapshot = [...sendableComposerTerminalContexts]; const composerPastedTextsSnapshot = [...sendableComposerPastedTexts]; const composerSkillsSnapshot = [...selectedComposerSkillsForSend]; const composerMentionsSnapshot = [...selectedComposerMentionsForSend]; const visibleMessageTextForSend = appendComposerMessageContext({ prompt: promptForSend, assistantSelections: composerAssistantSelectionsSnapshot, - terminalContexts: composerTerminalContextsSnapshot, fileComments: composerFileCommentsSnapshot, pastedTexts: composerPastedTextsSnapshot, }); @@ -6155,17 +5203,6 @@ export default function ChatView({ armTranscriptAutoFollow(threadIdForSend, true); setThreadError(threadIdForSend, null); - if (expiredTerminalContextCount > 0) { - const toastCopy = buildExpiredTerminalContextToastCopy( - expiredTerminalContextCount, - "omitted", - ); - toastManager.add({ - type: "warning", - title: toastCopy.title, - description: toastCopy.description, - }); - } // Queued turns are dispatched from their captured snapshot, so this send path // must not clear a separate live draft the user may already be editing. if (queuedChatTurn === null) { @@ -6313,13 +5350,7 @@ export default function ChatView({ if (nextThreadWorktreePath) { setupScriptOptions.cwd = nextThreadWorktreePath; } - const setupTerminal = await runProjectScript(setupScript, setupScriptOptions); - if (setupTerminal) { - await waitForSetupScriptTerminalActivity({ - threadId: threadIdForSend, - terminalId: setupTerminal.terminalId, - }); - } + await runProjectScript(setupScript, setupScriptOptions); } } @@ -6402,7 +5433,6 @@ export default function ChatView({ composerFilesRef.current.length === 0 && composerAssistantSelectionsRef.current.length === 0 && composerFileCommentsRef.current.length === 0 && - composerTerminalContextsRef.current.length === 0 && composerPastedTextsRef.current.length === 0 ) { setOptimisticUserMessages((existing) => { @@ -6431,7 +5461,6 @@ export default function ChatView({ for (const comment of composerFileCommentsSnapshot) { addComposerFileCommentToDraft(comment); } - addComposerTerminalContextsToDraft(composerTerminalContextsSnapshot); addComposerPastedTextsToDraft(composerPastedTextsSnapshot); updateSelectedComposerSkills(composerSkillsSnapshot); updateSelectedComposerMentions(composerMentionsSnapshot); @@ -7693,7 +6722,6 @@ export default function ChatView({ cursor: number; expandedCursor: number; selectionCollapsed: boolean; - terminalContextIds: string[]; } => { const editorSnapshot = composerEditorRef.current?.readSnapshot(); if (editorSnapshot) { @@ -7704,9 +6732,8 @@ export default function ChatView({ cursor: composerCursor, expandedCursor: expandCollapsedComposerCursor(promptRef.current, composerCursor), selectionCollapsed: true, - terminalContextIds: composerTerminalContexts.map((context) => context.id), }; - }, [composerCursor, composerTerminalContexts]); + }, [composerCursor]); const resolveActiveComposerTrigger = useCallback((): { snapshot: { @@ -7894,7 +6921,7 @@ export default function ChatView({ }); return; } - await handleNewThread(activeProject.id, { entryPoint: "chat" }); + await handleNewThread(activeProject.id); }, openForkTargetPicker: () => { setComposerCommandPicker("fork-target"); @@ -8073,7 +7100,6 @@ export default function ChatView({ nextCursor: number, expandedCursor: number, cursorAdjacentToMention: boolean, - terminalContextIds: string[], ) => { if (activePendingProgress?.activeQuestion && activePendingUserInput) { const interruptedNavigation = promptHistoryNavigationRef.current; @@ -8136,12 +7162,6 @@ export default function ChatView({ if (composerCommandPicker !== null && nextPrompt.trim().length > 0) { setComposerCommandPicker(null); } - if (!terminalContextIdListsEqual(composerTerminalContexts, terminalContextIds)) { - setComposerDraftTerminalContexts( - threadId, - syncTerminalContextsByIds(composerTerminalContexts, terminalContextIds), - ); - } setComposerCursor(nextCursor); setComposerTrigger( cursorAdjacentToMention ? null : detectComposerTrigger(nextPrompt, expandedCursor), @@ -8150,14 +7170,12 @@ export default function ChatView({ [ activePendingProgress?.activeQuestion, activePendingUserInput, - composerTerminalContexts, composerCommandPicker, onChangeActivePendingUserInputCustomAnswer, promptHistory, restoreComposerDraftPromptHistorySavedDraft, setPrompt, setComposerDraftPromptHistorySavedDraft, - setComposerDraftTerminalContexts, setComposerCommandPicker, setRestoredQueuedSourceProposedPlan, threadId, @@ -8362,28 +7380,6 @@ export default function ChatView({ search: (previous) => ({ ...stripDiffSearchParams(previous), view: "editor" }), }); }, [activeProjectIdForNewChat, handleNewThread]); - const onOpenEditorChat = useCallback( - (nextThreadId: ThreadId) => { - storeOpenChatThreadPage(nextThreadId); - onNavigateToThread(nextThreadId); - }, - [onNavigateToThread, storeOpenChatThreadPage], - ); - const onOpenEditorTerminal = useCallback(() => { - if (!activeThreadId) return; - setTerminalPresentationMode("workspace"); - setTerminalWorkspaceLayout("terminal-only"); - setTerminalWorkspaceTab("terminal"); - setTerminalFocusRequestId((value) => value + 1); - }, [ - activeThreadId, - setTerminalPresentationMode, - setTerminalWorkspaceLayout, - setTerminalWorkspaceTab, - ]); - const onCloseEditorTerminal = useCallback(() => { - void closeTerminal(terminalState.activeTerminalId); - }, [closeTerminal, terminalState.activeTerminalId]); const onRevertUserMessage = useCallback( (messageId: MessageId) => { const targetTurnCount = revertTurnCountByUserMessageId.get(messageId); @@ -8756,7 +7752,6 @@ export default function ChatView({ ) : ( + {composerPickerControls} {activeTaskList || sidebarProposedPlan || planSidebarOpen ? ( ) @@ -9103,7 +8088,6 @@ export default function ChatView({ 0, - onNewChat: onNewEditorChat, - onNewTerminal: onOpenEditorTerminal, - onOpenChat: onOpenEditorChat, - onOpenTerminal: onOpenEditorTerminal, - onCloseTerminal: onCloseEditorTerminal, - } - : null - } changeThreadAction={ surfaceMode === "split" && isFocusedPane && onChangeThreadInSplitPane ? { @@ -9178,7 +8145,6 @@ export default function ChatView({ onAddProjectScript={saveProjectScript} onUpdateProjectScript={updateProjectScript} onDeleteProjectScript={deleteProjectScript} - onToggleDiff={onToggleDiff} onCreateHandoff={onCreateHandoffThread} onNavigateToThread={onNavigateToThread} onRenameThread={() => setRenameDialogOpen(true)} @@ -9202,27 +8168,11 @@ export default function ChatView({ rateLimitStatus={visibleActiveRateLimitStatus} onDismiss={dismissActiveRateLimitBanner} /> - {terminalWorkspaceOpen && !isEditorRail ? ( - 0} - terminalCount={terminalState.terminalIds.length} - workspaceLayout={terminalState.workspaceLayout} - onSelectTab={setTerminalWorkspaceTab} - /> - ) : null} {/* Main content area with optional plan sidebar */}
{/* Chat column */}
-
+
{shouldRenderChatPaneContent && isCenteredEmptyLanding ? (
{composerSectionWithDisclosure}
- {secondaryChromeReady && isGitRepo ? ( + {secondaryChromeReady ? (
-
- +
+ {isGitRepo ? ( + + ) : null}
) : null} @@ -9359,28 +8309,6 @@ export default function ChatView({ /> ) : null}
- - {terminalWorkspaceOpen ? ( -
- -
- ) : null}
{/* end chat column */} @@ -9405,20 +8333,6 @@ export default function ChatView({
{/* end horizontal flex container */} - {(() => { - if (!terminalState.terminalOpen || terminalWorkspaceOpen) { - return null; - } - return ( - - ); - })()} - void; -}>({ - onRemoveTerminalContext: () => {}, -}); - // Node classes imported from ./composer-nodes -function terminalContextSignature(contexts: ReadonlyArray): string { - return contexts - .map((context) => - [ - context.id, - context.threadId, - context.terminalId, - context.terminalLabel, - context.lineStart, - context.lineEnd, - context.createdAt, - context.text, - ].join("\u001f"), - ) - .join("\u001e"); -} - function mentionReferencesSignature(mentions: ReadonlyArray): string { return mentions.map((mention) => `${mention.name}\u0000${mention.path}`).join("\u0001"); } @@ -224,7 +198,7 @@ function getAbsoluteOffsetForPoint(node: LexicalNode, pointOffset: number): numb current = nextParent; } - if (node instanceof ComposerLinkNode || node instanceof ComposerTerminalContextNode) { + if (node instanceof ComposerLinkNode) { return getAbsoluteOffsetForInlineTokenPoint(node, offset, pointOffset); } @@ -277,7 +251,7 @@ function getExpandedAbsoluteOffsetForPoint(node: LexicalNode, pointOffset: numbe current = nextParent; } - if (node instanceof ComposerLinkNode || node instanceof ComposerTerminalContextNode) { + if (node instanceof ComposerLinkNode) { return getExpandedAbsoluteOffsetForInlineTokenPoint(node, offset, pointOffset); } @@ -320,8 +294,7 @@ function findSelectionPointAtOffset( node instanceof ComposerSkillNode || node instanceof ComposerSlashCommandNode || node instanceof ComposerAgentMentionNode || - node instanceof ComposerLinkNode || - node instanceof ComposerTerminalContextNode + node instanceof ComposerLinkNode ) { return findSelectionPointForInlineToken(node, remainingRef); } @@ -440,7 +413,6 @@ function $appendTextWithLineBreaks(parent: ElementNode, text: string): void { function $setComposerEditorPrompt( prompt: string, - terminalContexts: ReadonlyArray, mentionReferences: ReadonlyArray = [], ): void { const root = $getRoot(); @@ -448,7 +420,7 @@ function $setComposerEditorPrompt( const paragraph = $createParagraphNode(); root.append(paragraph); - const segments = splitPromptIntoComposerSegments(prompt, terminalContexts, mentionReferences); + const segments = splitPromptIntoComposerSegments(prompt, mentionReferences); for (const segment of segments) { if (segment.type === "mention") { paragraph.append($createComposerMentionNode(segment.path, segment.kind)); @@ -463,12 +435,6 @@ function $setComposerEditorPrompt( paragraph.append($createComposerSlashCommandNode(segment.command)); continue; } - if (segment.type === "terminal-context") { - if (segment.context) { - paragraph.append($createComposerTerminalContextNode(segment.context)); - } - continue; - } if (segment.type === "agent-mention") { paragraph.append($createComposerAgentMentionNode(segment.alias, segment.color)); continue; @@ -481,16 +447,6 @@ function $setComposerEditorPrompt( } } -function collectTerminalContextIds(node: LexicalNode): string[] { - if (node instanceof ComposerTerminalContextNode) { - return [node.__context.id]; - } - if ($isElementNode(node)) { - return node.getChildren().flatMap((child) => collectTerminalContextIds(child)); - } - return []; -} - export interface ComposerPromptEditorHandle { blur: () => void; focus: () => void; @@ -502,19 +458,16 @@ export interface ComposerPromptEditorHandle { cursor: number; expandedCursor: number; selectionCollapsed: boolean; - terminalContextIds: string[]; }; } interface ComposerPromptEditorProps { value: string; cursor: number; - terminalContexts: ReadonlyArray; mentionReferences?: ReadonlyArray; disabled: boolean; placeholder: string; className?: string; - onRemoveTerminalContext: (contextId: string) => void; /** * Invoked when a sufficiently large text paste should collapse into an attachment * card instead of inserting raw text. When omitted, pastes insert as text. @@ -525,7 +478,6 @@ interface ComposerPromptEditorProps { nextCursor: number, expandedCursor: number, cursorAdjacentToMention: boolean, - terminalContextIds: string[], ) => void; onCommandKeyDown?: ( key: "ArrowDown" | "ArrowUp" | "Enter" | "Tab" | "Slash", @@ -697,7 +649,6 @@ function ComposerInlineTokenSelectionNormalizePlugin() { function ComposerInlineTokenBackspacePlugin() { const [editor] = useLexicalComposerContext(); - const { onRemoveTerminalContext } = useContext(ComposerTerminalContextActionsContext); useEffect(() => { return editor.registerCommand( @@ -716,12 +667,7 @@ function ComposerInlineTokenBackspacePlugin() { } const tokenStart = getAbsoluteOffsetForPoint(candidate, 0); candidate.remove(); - if (candidate instanceof ComposerTerminalContextNode) { - onRemoveTerminalContext(candidate.__context.id); - $setSelectionAtComposerOffset(selectionOffset); - } else { - $setSelectionAtComposerOffset(tokenStart); - } + $setSelectionAtComposerOffset(tokenStart); event?.preventDefault(); return true; }; @@ -757,7 +703,7 @@ function ComposerInlineTokenBackspacePlugin() { }, COMMAND_PRIORITY_HIGH, ); - }, [editor, onRemoveTerminalContext]); + }, [editor]); return null; } @@ -889,12 +835,10 @@ function ComposerBigPastePlugin(props: { onCollapsePastedText: (text: string) => function ComposerPromptEditorInner({ value, cursor, - terminalContexts, mentionReferences = [], disabled, placeholder, className, - onRemoveTerminalContext, onCollapsePastedText, onChange, onCommandKeyDown, @@ -904,8 +848,6 @@ function ComposerPromptEditorInner({ const [editor] = useLexicalComposerContext(); const onChangeRef = useRef(onChange); const initialCursor = clampCollapsedComposerCursor(value, cursor); - const terminalContextsSignature = terminalContextSignature(terminalContexts); - const terminalContextsSignatureRef = useRef(terminalContextsSignature); const mentionsSignature = mentionReferencesSignature(mentionReferences); const mentionsSignatureRef = useRef(mentionsSignature); const snapshotRef = useRef({ @@ -913,13 +855,8 @@ function ComposerPromptEditorInner({ cursor: initialCursor, expandedCursor: expandCollapsedComposerCursor(value, initialCursor), selectionCollapsed: true, - terminalContextIds: terminalContexts.map((context) => context.id), }); const isApplyingControlledUpdateRef = useRef(false); - const terminalContextActions = useMemo( - () => ({ onRemoveTerminalContext }), - [onRemoveTerminalContext], - ); useEffect(() => { onChangeRef.current = onChange; @@ -949,12 +886,10 @@ function ComposerPromptEditorInner({ useLayoutEffect(() => { const normalizedCursor = clampCollapsedComposerCursor(value, cursor); const previousSnapshot = snapshotRef.current; - const contextsChanged = terminalContextsSignatureRef.current !== terminalContextsSignature; const mentionsChanged = mentionsSignatureRef.current !== mentionsSignature; if ( previousSnapshot.value === value && previousSnapshot.cursor === normalizedCursor && - !contextsChanged && !mentionsChanged ) { return; @@ -965,23 +900,20 @@ function ComposerPromptEditorInner({ cursor: normalizedCursor, expandedCursor: expandCollapsedComposerCursor(value, normalizedCursor), selectionCollapsed: true, - terminalContextIds: terminalContexts.map((context) => context.id), }; - terminalContextsSignatureRef.current = terminalContextsSignature; mentionsSignatureRef.current = mentionsSignature; const rootElement = editor.getRootElement(); const isFocused = Boolean(rootElement && document.activeElement === rootElement); - if (previousSnapshot.value === value && !contextsChanged && !mentionsChanged && !isFocused) { + if (previousSnapshot.value === value && !mentionsChanged && !isFocused) { return; } isApplyingControlledUpdateRef.current = true; editor.update(() => { - const shouldRewriteEditorState = - previousSnapshot.value !== value || contextsChanged || mentionsChanged; + const shouldRewriteEditorState = previousSnapshot.value !== value || mentionsChanged; if (shouldRewriteEditorState) { - $setComposerEditorPrompt(value, terminalContexts, mentionReferences); + $setComposerEditorPrompt(value, mentionReferences); } if (shouldRewriteEditorState || isFocused) { $setSelectionAtComposerOffset(normalizedCursor); @@ -990,15 +922,7 @@ function ComposerPromptEditorInner({ queueMicrotask(() => { isApplyingControlledUpdateRef.current = false; }); - }, [ - cursor, - editor, - mentionReferences, - mentionsSignature, - terminalContexts, - terminalContextsSignature, - value, - ]); + }, [cursor, editor, mentionReferences, mentionsSignature, value]); const focusAt = useCallback( (nextCursor: number) => { @@ -1014,14 +938,12 @@ function ComposerPromptEditorInner({ cursor: boundedCursor, expandedCursor: expandCollapsedComposerCursor(snapshotRef.current.value, boundedCursor), selectionCollapsed: true, - terminalContextIds: snapshotRef.current.terminalContextIds, }; onChangeRef.current( snapshotRef.current.value, boundedCursor, snapshotRef.current.expandedCursor, false, - snapshotRef.current.terminalContextIds, ); }, [editor], @@ -1044,7 +966,6 @@ function ComposerPromptEditorInner({ cursor: number; expandedCursor: number; selectionCollapsed: boolean; - terminalContextIds: string[]; } => { let snapshot = snapshotRef.current; editor.getEditorState().read(() => { @@ -1064,13 +985,11 @@ function ComposerPromptEditorInner({ nextValue, $readExpandedSelectionOffsetFromEditorState(fallbackExpandedCursor), ); - const terminalContextIds = collectTerminalContextIds($getRoot()); snapshot = { value: nextValue, cursor: nextCursor, expandedCursor: nextExpandedCursor, selectionCollapsed, - terminalContextIds, }; }); snapshotRef.current = snapshot; @@ -1117,14 +1036,11 @@ function ComposerPromptEditorInner({ nextValue, $readExpandedSelectionOffsetFromEditorState(fallbackExpandedCursor), ); - const terminalContextIds = collectTerminalContextIds($getRoot()); const previousSnapshot = snapshotRef.current; if ( previousSnapshot.value === nextValue && previousSnapshot.cursor === nextCursor && - previousSnapshot.expandedCursor === nextExpandedCursor && - previousSnapshot.terminalContextIds.length === terminalContextIds.length && - previousSnapshot.terminalContextIds.every((id, index) => id === terminalContextIds[index]) + previousSnapshot.expandedCursor === nextExpandedCursor ) { return; } @@ -1136,69 +1052,58 @@ function ComposerPromptEditorInner({ cursor: nextCursor, expandedCursor: nextExpandedCursor, selectionCollapsed, - terminalContextIds, }; const cursorAdjacentToMention = isCollapsedCursorAdjacentToInlineToken(nextValue, nextCursor, "left") || isCollapsedCursorAdjacentToInlineToken(nextValue, nextCursor, "right"); - onChangeRef.current( - nextValue, - nextCursor, - nextExpandedCursor, - cursorAdjacentToMention, - terminalContextIds, - ); + onChangeRef.current(nextValue, nextCursor, nextExpandedCursor, cursorAdjacentToMention); }); }, []); return ( - -
- } - onPaste={onPaste} - /> - } - placeholder={ - terminalContexts.length > 0 ? null : ( -
- {placeholder} -
- ) - } - ErrorBoundary={LexicalErrorBoundary} - /> - - - - - - - - - {onCollapsePastedText ? ( - - ) : null} - -
-
+
+ } + onPaste={onPaste} + /> + } + placeholder={ +
+ {placeholder} +
+ } + ErrorBoundary={LexicalErrorBoundary} + /> + + + + + + + + + {onCollapsePastedText ? ( + + ) : null} + +
); } @@ -1209,12 +1114,10 @@ export const ComposerPromptEditor = forwardRef< { value, cursor, - terminalContexts, mentionReferences, disabled, placeholder, className, - onRemoveTerminalContext, onCollapsePastedText, onChange, onCommandKeyDown, @@ -1223,7 +1126,6 @@ export const ComposerPromptEditor = forwardRef< ref, ) { const initialValueRef = useRef(value); - const initialTerminalContextsRef = useRef(terminalContexts); // Normalize once at the wrapper boundary so the inner editor can treat mention refs as concrete. const normalizedMentionReferences = mentionReferences ?? []; const initialMentionReferencesRef = useRef(normalizedMentionReferences); @@ -1233,11 +1135,7 @@ export const ComposerPromptEditor = forwardRef< editable: true, nodes: [...COMPOSER_NODE_CLASSES], editorState: () => { - $setComposerEditorPrompt( - initialValueRef.current, - initialTerminalContextsRef.current, - initialMentionReferencesRef.current, - ); + $setComposerEditorPrompt(initialValueRef.current, initialMentionReferencesRef.current); }, onError: (error) => { throw error; @@ -1251,11 +1149,9 @@ export const ComposerPromptEditor = forwardRef< = {}): Thread { - return { - id: THREAD_ID, - codexThreadId: null, - projectId: PROJECT_ID, - title: "Thread 1", - modelSelection: { provider: "codex", model: "gpt-5.4-mini" }, - runtimeMode: "full-access", - session: null, - messages: [], - proposedPlans: [], - error: null, - createdAt: "2026-04-16T10:00:00.000Z", - updatedAt: "2026-04-16T10:00:00.000Z", - latestTurn: { - turnId: TurnId.makeUnsafe("turn-1"), - state: "completed", - requestedAt: "2026-04-16T10:00:00.000Z", - startedAt: "2026-04-16T10:00:01.000Z", - completedAt: "2026-04-16T10:00:02.000Z", - assistantMessageId: null, - sourceProposedPlan: undefined, - }, - lastVisitedAt: "2026-04-16T10:00:02.000Z", - branch: null, - worktreePath: null, - turnDiffSummaries: [], - activities: [], - ...overrides, - }; -} - -function makeDraftThread(overrides: Partial = {}): DraftThreadState { - return { - projectId: PROJECT_ID, - createdAt: "2026-04-16T10:00:00.000Z", - runtimeMode: "full-access", - entryPoint: "chat", - branch: null, - worktreePath: null, - envMode: "local", - ...overrides, - }; -} - -describe("resolveDiffPanelThread", () => { - it("keeps the server-backed thread when one exists", () => { - const serverThread = makeThread({ title: "Server thread" }); - - expect( - resolveDiffPanelThread({ - threadId: THREAD_ID, - serverThread, - draftThread: makeDraftThread({ branch: "feature/draft" }), - fallbackModelSelection: { provider: "codex", model: "gpt-5.4-mini" }, - }), - ).toBe(serverThread); - }); - - it("builds a local draft-backed thread when the server thread is missing", () => { - const resolved = resolveDiffPanelThread({ - threadId: THREAD_ID, - serverThread: undefined, - draftThread: makeDraftThread({ - branch: "feature/draft", - worktreePath: "/tmp/worktree", - envMode: "worktree", - }), - fallbackModelSelection: { provider: "codex", model: "gpt-5.4-mini" }, - }); - - expect(resolved).toMatchObject({ - id: THREAD_ID, - projectId: PROJECT_ID, - title: "New thread", - envMode: "worktree", - branch: "feature/draft", - worktreePath: "/tmp/worktree", - turnDiffSummaries: [], - }); - }); - - it("returns undefined when neither a server thread nor a draft thread exists", () => { - expect( - resolveDiffPanelThread({ - threadId: THREAD_ID, - serverThread: undefined, - draftThread: null, - fallbackModelSelection: null, - }), - ).toBeUndefined(); - }); -}); - -describe("diff panel view source helpers", () => { - it("defaults to repo view when no turn is selected", () => { - expect(resolveInitialDiffViewKind(null)).toBe("repo"); - }); - - it("defaults to turn view when a turn is selected", () => { - expect(resolveInitialDiffViewKind(TurnId.makeUnsafe("turn-1"))).toBe("turn"); - }); - - it("resolves repo and turn view sources", () => { - expect( - resolveDiffPanelViewSource({ - diffViewKind: "repo", - repoDiffScope: "unstaged", - selectedTurnId: null, - }), - ).toEqual({ kind: "repo", scope: "unstaged" }); - - expect( - resolveDiffPanelViewSource({ - diffViewKind: "turn", - repoDiffScope: "branch", - selectedTurnId: TurnId.makeUnsafe("turn-1"), - }), - ).toEqual({ kind: "turn", turnId: TurnId.makeUnsafe("turn-1") }); - }); - - it("gates diff queries when the pane is hidden or collapsed", () => { - expect(resolveDiffPanelQueriesEnabled({ diffOpen: true, queriesEnabled: true })).toBe(true); - expect(resolveDiffPanelQueriesEnabled({ diffOpen: true, queriesEnabled: false })).toBe(false); - expect(resolveDiffPanelQueriesEnabled({ diffOpen: false, queriesEnabled: true })).toBe(false); - expect( - resolveDiffPanelScopeCountQueriesEnabled({ queriesEnabled: true, scopePickerOpen: false }), - ).toBe(false); - expect( - resolveDiffPanelScopeCountQueriesEnabled({ queriesEnabled: true, scopePickerOpen: true }), - ).toBe(true); - }); - - it("only enables git status work for repo diffs with a cwd", () => { - expect( - resolveDiffPanelGitStatusQueriesEnabled({ - queriesEnabled: true, - activeCwd: "/repo", - diffViewKind: "repo", - }), - ).toBe(true); - expect( - resolveDiffPanelGitStatusQueriesEnabled({ - queriesEnabled: true, - activeCwd: "/repo", - diffViewKind: "turn", - }), - ).toBe(false); - expect( - resolveDiffPanelGitStatusQueriesEnabled({ - queriesEnabled: true, - activeCwd: null, - diffViewKind: "repo", - }), - ).toBe(false); - }); - - it("only surfaces scope file counts for the active scope until the picker opens", () => { - expect( - resolveDiffPanelScopeFileCounts({ - viewSource: { kind: "repo", scope: "unstaged" }, - activeScopeFileCount: 3, - scopePickerOpen: false, - pickerScopeCounts: { unstaged: 3, staged: 1 }, - }), - ).toEqual({ unstaged: 3 }); - expect( - resolveDiffPanelScopeFileCounts({ - viewSource: { kind: "repo", scope: "unstaged" }, - activeScopeFileCount: 3, - scopePickerOpen: true, - pickerScopeCounts: { unstaged: 3, staged: 1 }, - }), - ).toEqual({ unstaged: 3, staged: 1 }); - }); - - it("only polls repo diffs while a turn is live and the repo view is active", () => { - expect( - resolveDiffPanelRepoLiveRefresh({ - latestTurn: null, - session: null, - messages: [], - activities: [], - }), - ).toBe(false); - expect( - resolveDiffPanelRepoLiveRefresh({ - latestTurn: { - turnId: TurnId.makeUnsafe("turn-1"), - state: "running", - requestedAt: "2026-04-16T10:00:00.000Z", - startedAt: "2026-04-16T10:00:01.000Z", - completedAt: null, - assistantMessageId: null, - sourceProposedPlan: undefined, - }, - session: { - provider: "codex", - status: "running", - orchestrationStatus: "running", - activeTurnId: TurnId.makeUnsafe("turn-1"), - createdAt: "2026-04-16T10:00:00.000Z", - updatedAt: "2026-04-16T10:00:01.000Z", - }, - messages: [], - activities: [], - }), - ).toBe(true); - expect( - resolveDiffPanelRepoLiveRefetchIntervalMs({ - queriesEnabled: true, - liveRefreshEnabled: true, - diffViewKind: "repo", - shouldPollRepoDiff: true, - }), - ).toBe(DIFF_PANEL_REPO_LIVE_REFETCH_INTERVAL_MS); - expect( - resolveDiffPanelRepoLiveRefetchIntervalMs({ - queriesEnabled: true, - liveRefreshEnabled: true, - diffViewKind: "turn", - shouldPollRepoDiff: true, - }), - ).toBe(false); - }); - - it("resolves scope picker values for repo, all turns, and last turn", () => { - const latestTurnId = TurnId.makeUnsafe("turn-latest"); - const olderTurnId = TurnId.makeUnsafe("turn-older"); - - expect( - resolveDiffPanelScopePickerValue({ - viewSource: { kind: "repo", scope: "workingTree" }, - latestTurnId, - }), - ).toBe("workingTree"); - expect( - resolveDiffPanelScopePickerValue({ - viewSource: { kind: "repo", scope: "staged" }, - latestTurnId, - }), - ).toBe("staged"); - expect( - resolveDiffPanelScopePickerValue({ - viewSource: { kind: "turn", turnId: null }, - latestTurnId, - }), - ).toBe("allTurns"); - expect( - resolveDiffPanelScopePickerValue({ - viewSource: { kind: "turn", turnId: null }, - latestTurnId, - turnScopeIntent: "last", - }), - ).toBe("lastTurn"); - expect( - resolveDiffPanelScopePickerValue({ - viewSource: { kind: "turn", turnId: latestTurnId }, - latestTurnId, - }), - ).toBe("lastTurn"); - expect( - resolveDiffPanelScopePickerValue({ - viewSource: { kind: "turn", turnId: olderTurnId }, - latestTurnId, - }), - ).toBeNull(); - }); - - it("keeps the persisted default working-tree scope available in the picker", () => { - expect(DIFF_PANEL_PICKER_SCOPE_OPTIONS).toContain("workingTree"); - }); - - it("marks picker options selected only when they match the active scope", () => { - const latestTurnId = TurnId.makeUnsafe("turn-latest"); - - expect( - isDiffPanelPickerOptionSelected( - { kind: "turn", turnId: null }, - { id: "allTurns" }, - latestTurnId, - "all", - ), - ).toBe(true); - expect( - isDiffPanelPickerOptionSelected( - { kind: "turn", turnId: latestTurnId }, - { id: "lastTurn" }, - latestTurnId, - "last", - ), - ).toBe(true); - expect( - isDiffPanelPickerOptionSelected( - { kind: "turn", turnId: TurnId.makeUnsafe("turn-older") }, - { id: "lastTurn" }, - latestTurnId, - "last", - ), - ).toBe(false); - }); - - it("detects stale turn selections and resolves summaries without fallback", () => { - const summaries = [ - { - turnId: TurnId.makeUnsafe("turn-1"), - checkpointTurnCount: 1, - completedAt: "2026-04-16T10:00:02.000Z", - }, - ] as const; - - expect(resolveSelectedTurnSummary(TurnId.makeUnsafe("turn-1"), summaries)).toMatchObject({ - turnId: TurnId.makeUnsafe("turn-1"), - }); - expect( - resolveSelectedTurnSummary(TurnId.makeUnsafe("turn-missing"), summaries), - ).toBeUndefined(); - expect(isStaleDiffTurnSelection(TurnId.makeUnsafe("turn-missing"), summaries)).toBe(true); - expect(isStaleDiffTurnSelection(null, summaries)).toBe(false); - }); - - it("builds compact conversation cache scopes from the latest checkpoint count", () => { - expect(resolveConversationCacheScope(undefined)).toBeNull(); - expect(resolveConversationCacheScope(3)).toBe("conversation:to-3"); - }); - - it("filters renderable files by path query", () => { - const files = [ - { name: "apps/web/src/components/ChatView.tsx", hunks: [] }, - { name: "apps/web/src/components/DiffPanel.tsx", hunks: [] }, - ] as unknown as Parameters[0]; - - expect(filterRenderableFilesForSearch(files, "diffpanel")).toHaveLength(1); - expect(filterRenderableFilesForSearch(files, "")).toHaveLength(2); - }); -}); diff --git a/apps/web/src/components/DiffPanel.logic.ts b/apps/web/src/components/DiffPanel.logic.ts deleted file mode 100644 index db3e75eb5..000000000 --- a/apps/web/src/components/DiffPanel.logic.ts +++ /dev/null @@ -1,274 +0,0 @@ -// FILE: DiffPanel.logic.ts -// Purpose: Resolve the thread context the diff panel should use across server-backed and local draft chats. -// Exports: resolveDiffPanelThread, diff view source helpers -// Depends on: ChatView.logic draft-thread normalization. - -import { - DEFAULT_MODEL_BY_PROVIDER, - type ModelSelection, - type ThreadId, - type TurnId, -} from "@t3tools/contracts"; -import type { FileDiffMetadata } from "@pierre/diffs/react"; - -import type { DraftThreadState } from "../composerDraftStore"; -import type { RepoDiffScope } from "../repoDiffScopeStore"; -import { REPO_DIFF_SCOPE_LABELS } from "../repoDiffScopeStore"; -import { hasLiveTurnTailWork, isLatestTurnSettled } from "../session-logic"; -import { buildLocalDraftThread } from "./ChatView.logic"; -import { buildFileDiffRenderKey, resolveFileDiffPath } from "../lib/diffRendering"; -import type { ChatMessage, Thread } from "../types"; - -export type DiffViewKind = "repo" | "turn"; - -/** Distinguishes all-turns vs last-turn when no specific turn id is selected. */ -export type DiffPanelTurnScopeIntent = "all" | "last"; - -export type DiffPanelViewSource = - | { kind: "repo"; scope: RepoDiffScope } - | { kind: "turn"; turnId: TurnId | null }; - -export type DiffPanelScopePickerValue = RepoDiffScope | "allTurns" | "lastTurn"; - -export type DiffPanelPickerOption = - | { id: "scope"; scope: RepoDiffScope } - | { id: "allTurns" } - | { id: "lastTurn" }; - -export const DIFF_PANEL_PICKER_SCOPE_OPTIONS: ReadonlyArray = [ - "workingTree", - "unstaged", - "staged", - "branch", -]; - -// Reuse the chat-view draft fallback so diff surfaces keep working before the first server turn exists. -export function resolveDiffPanelThread(input: { - threadId: ThreadId | null | undefined; - serverThread: Thread | undefined; - draftThread: DraftThreadState | null | undefined; - fallbackModelSelection: ModelSelection | null | undefined; -}): Thread | undefined { - if (input.serverThread) { - return input.serverThread; - } - if (!input.threadId || !input.draftThread) { - return undefined; - } - - return buildLocalDraftThread( - input.threadId, - input.draftThread, - input.fallbackModelSelection ?? { - provider: "codex", - model: DEFAULT_MODEL_BY_PROVIDER.codex, - }, - null, - ); -} - -export function resolveInitialDiffViewKind(selectedTurnId: TurnId | null): DiffViewKind { - return selectedTurnId === null ? "repo" : "turn"; -} - -/** Relaxed cadence for the open review pane — git invalidation handles turn boundaries. */ -export const DIFF_PANEL_REPO_LIVE_REFETCH_INTERVAL_MS = 10_000; - -export function resolveDiffPanelRepoLiveRefresh(input: { - latestTurn: Thread["latestTurn"]; - session: Thread["session"]; - messages: ReadonlyArray>; - activities: Thread["activities"]; -}): boolean { - if (!input.latestTurn?.startedAt) { - return false; - } - - const hasLiveTail = hasLiveTurnTailWork({ - latestTurn: input.latestTurn, - messages: input.messages, - activities: input.activities, - session: input.session, - }); - - return !isLatestTurnSettled(input.latestTurn, input.session) || hasLiveTail; -} - -export function resolveDiffPanelRepoLiveRefetchIntervalMs(input: { - queriesEnabled: boolean; - liveRefreshEnabled: boolean; - diffViewKind: DiffViewKind; - shouldPollRepoDiff: boolean; -}): number | false { - if ( - !input.queriesEnabled || - !input.liveRefreshEnabled || - input.diffViewKind !== "repo" || - !input.shouldPollRepoDiff - ) { - return false; - } - return DIFF_PANEL_REPO_LIVE_REFETCH_INTERVAL_MS; -} - -/** Gate expensive git/diff fetches so a hidden or collapsed review pane stays idle. */ -export function resolveDiffPanelQueriesEnabled(input: { - diffOpen: boolean; - queriesEnabled?: boolean; -}): boolean { - return input.diffOpen && (input.queriesEnabled ?? true); -} - -export function resolveDiffPanelScopeCountQueriesEnabled(input: { - queriesEnabled: boolean; - scopePickerOpen: boolean; -}): boolean { - return input.queriesEnabled && input.scopePickerOpen; -} - -export function resolveDiffPanelGitStatusQueriesEnabled(input: { - queriesEnabled: boolean; - activeCwd: string | null; - diffViewKind: DiffViewKind; -}): boolean { - return input.queriesEnabled && input.activeCwd !== null && input.diffViewKind === "repo"; -} - -export function resolveDiffPanelScopeFileCounts(input: { - viewSource: DiffPanelViewSource; - activeScopeFileCount: number | undefined; - scopePickerOpen: boolean; - pickerScopeCounts: Partial>; -}): Partial> { - if (input.scopePickerOpen) { - return input.pickerScopeCounts; - } - if ( - input.viewSource.kind === "repo" && - typeof input.activeScopeFileCount === "number" && - input.activeScopeFileCount > 0 - ) { - return { [input.viewSource.scope]: input.activeScopeFileCount }; - } - return {}; -} - -export function resolveDiffPanelViewSource(input: { - diffViewKind: DiffViewKind; - repoDiffScope: RepoDiffScope; - selectedTurnId: TurnId | null; -}): DiffPanelViewSource { - if (input.diffViewKind === "turn") { - return { kind: "turn", turnId: input.selectedTurnId }; - } - return { kind: "repo", scope: input.repoDiffScope }; -} - -export function resolveDiffPanelPickerLabel( - source: DiffPanelViewSource, - turnScopeIntent?: DiffPanelTurnScopeIntent, -): string { - if (source.kind === "turn") { - if (source.turnId !== null) { - return "Turn diff"; - } - return turnScopeIntent === "last" ? "Last turn" : "All turns"; - } - return REPO_DIFF_SCOPE_LABELS[source.scope]; -} - -export function resolveSelectedTurnSummary( - selectedTurnId: TurnId | null, - orderedTurnDiffSummaries: ReadonlyArray, -): T | undefined { - if (!selectedTurnId) { - return undefined; - } - return orderedTurnDiffSummaries.find((summary) => summary.turnId === selectedTurnId); -} - -export function isStaleDiffTurnSelection( - selectedTurnId: TurnId | null, - orderedTurnDiffSummaries: ReadonlyArray<{ turnId: TurnId }>, -): boolean { - if (!selectedTurnId) { - return false; - } - return !orderedTurnDiffSummaries.some((summary) => summary.turnId === selectedTurnId); -} - -/** Radio value for the left diff-source picker; null when a specific older turn is active. */ -export function resolveDiffPanelScopePickerValue(input: { - viewSource: DiffPanelViewSource; - latestTurnId: TurnId | null; - turnScopeIntent?: DiffPanelTurnScopeIntent; -}): DiffPanelScopePickerValue | null { - if (input.viewSource.kind === "repo") { - return input.viewSource.scope; - } - if (input.viewSource.turnId === null) { - return input.turnScopeIntent === "last" ? "lastTurn" : "allTurns"; - } - if (input.viewSource.turnId === input.latestTurnId) { - return "lastTurn"; - } - return null; -} - -export function resolveConversationCacheScope( - conversationCheckpointTurnCount: number | undefined, -): string | null { - if (typeof conversationCheckpointTurnCount !== "number") { - return null; - } - return `conversation:to-${conversationCheckpointTurnCount}`; -} - -export function isDiffPanelPickerOptionSelected( - source: DiffPanelViewSource, - option: DiffPanelPickerOption, - latestTurnId: TurnId | null, - turnScopeIntent?: DiffPanelTurnScopeIntent, -): boolean { - const activeValue = resolveDiffPanelScopePickerValue({ - viewSource: source, - latestTurnId, - // Omit the key entirely when undefined: under exactOptionalPropertyTypes an - // explicit `undefined` is not assignable to the optional `turnScopeIntent`. - ...(turnScopeIntent !== undefined ? { turnScopeIntent } : {}), - }); - if (activeValue === null) { - return false; - } - if (option.id === "allTurns") { - return activeValue === "allTurns"; - } - if (option.id === "lastTurn") { - return activeValue === "lastTurn"; - } - return activeValue === option.scope; -} - -export function filterRenderableFilesForSearch( - files: ReadonlyArray, - query: string, -): FileDiffMetadata[] { - const normalizedQuery = query.trim().toLowerCase(); - if (!normalizedQuery) { - return [...files]; - } - return files.filter((fileDiff) => { - const filePath = resolveFileDiffPath(fileDiff).toLowerCase(); - return filePath.includes(normalizedQuery); - }); -} - -export function areAllRenderableFilesCollapsed( - files: ReadonlyArray, - collapsedFiles: ReadonlySet, -): boolean { - if (files.length === 0) { - return false; - } - return files.every((fileDiff) => collapsedFiles.has(buildFileDiffRenderKey(fileDiff))); -} diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx deleted file mode 100644 index 4d1c09216..000000000 --- a/apps/web/src/components/DiffPanel.tsx +++ /dev/null @@ -1,1208 +0,0 @@ -// FILE: DiffPanel.tsx -// Purpose: Coordinates diff-panel data sources, toolbar state, and patch body rendering. -// Layer: Diff panel container - -import { useQuery } from "@tanstack/react-query"; -import { useNavigate, useParams } from "@tanstack/react-router"; -import { ThreadId, type TurnId } from "@t3tools/contracts"; -import type { FileDiffMetadata } from "@pierre/diffs/react"; -import { Columns2Icon, CopyIcon, EllipsisIcon, FolderIcon, Rows3Icon, XIcon } from "~/lib/icons"; -import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; -import { - gitBranchesQueryOptions, - gitStatusQueryOptions, - gitWorkingTreeDiffQueryOptions, -} from "~/lib/gitReactQuery"; -import { - checkpointDiffQueryOptions, - resolveCheckpointDiffQueryDisplayState, -} from "~/lib/providerReactQuery"; -import { stripDiffSearchParams } from "../diffRouteSearch"; -import { useTheme } from "../hooks/useTheme"; -import { useDiffRouteSearch } from "../hooks/useDiffRouteSearch"; -import { - buildFileDiffRenderKey, - getRenderablePatch, - resolveDiffCopyText, - sortFileDiffsByPath, - summarizePatchTotals, - summarizeRenderablePatchStats, -} from "../lib/diffRendering"; -import { - appendChatFileReference, - appendComposerPromptText, - buildDiffSelectionReference, - buildWhyChangedPrompt, -} from "../lib/chatReferences"; -import { resolveDiffEnvironmentState } from "../lib/threadEnvironment"; -import { disclosureWidthClassName } from "../lib/disclosureMotion"; -import { useCopyToClipboard } from "../hooks/useCopyToClipboard"; -import { type RepoDiffScope, useRepoDiffScopeStore } from "../repoDiffScopeStore"; -import { useStore } from "../store"; -import { createProjectSelector } from "../storeSelectors"; -import { inferCheckpointTurnCountByTurnId } from "../session-logic"; -import { type TimestampFormat, useAppSettings } from "../appSettings"; -import { useComposerDraftStore } from "../composerDraftStore"; -import { DOCK_HEADER_ICON_BUTTON_CLASS, type DiffRenderMode } from "./chat/chatHeaderControls"; -import { - areAllRenderableFilesCollapsed, - DIFF_PANEL_PICKER_SCOPE_OPTIONS, - isStaleDiffTurnSelection, - resolveConversationCacheScope, - resolveDiffPanelGitStatusQueriesEnabled, - resolveDiffPanelQueriesEnabled, - resolveDiffPanelScopeCountQueriesEnabled, - resolveDiffPanelRepoLiveRefetchIntervalMs, - resolveDiffPanelScopeFileCounts, - resolveDiffPanelScopePickerValue, - resolveDiffPanelThread, - resolveDiffPanelViewSource, - resolveInitialDiffViewKind, - resolveSelectedTurnSummary, - type DiffPanelTurnScopeIntent, - type DiffViewKind, -} from "./DiffPanel.logic"; -import { DiffPanelPatchViewport } from "./DiffPanelPatchViewport"; -import { DiffPanelToolbar } from "./DiffPanelToolbar"; -import { ReviewFileTreePanel } from "./ReviewFileTreePanel"; -import { ComposerPickerMenuPopup } from "./chat/ComposerPickerMenuPopup"; -import { closestThroughShadow } from "./chat/chatSelectionActions"; -import { TranscriptSelectionAction } from "./chat/TranscriptSelectionAction"; -import { useCodeSelectionAction } from "./chat/useCodeSelectionAction"; -import { - createDiffPanelRepoLiveRefreshSelector, - createDiffPanelThreadCatalogSelector, - toDiffPanelThreadCatalog, - type DiffPanelThreadCatalog, -} from "./diffPanelSelectors"; -import { DiffPanelLoadingState, DiffPanelShell, type DiffPanelMode } from "./DiffPanelShell"; -import { IconButton } from "./ui/icon-button"; -import { - Menu, - MenuCheckboxItem, - MenuGroup, - MenuGroupLabel, - MenuItem, - MenuRadioGroup, - MenuRadioItem, - MenuTrigger, -} from "./ui/menu"; -import { REPO_DIFF_SCOPE_LABELS } from "../repoDiffScopeStore"; -import { PanelStateMessage } from "./chat/PanelStateMessage"; -import type { ChatPanelState } from "../chatPanelState"; -import { formatShortTimestamp } from "../timestampFormat"; -import type { TurnDiffSummary } from "../types"; - -const EDITOR_DIFF_OPTIONS_MENU_ICON_CLASS_NAME = "size-3.5 shrink-0 text-muted-foreground"; - -function EditorDiffOptionsCountBadge(props: { count: number | undefined }) { - if (typeof props.count !== "number" || props.count <= 0) { - return null; - } - return ( - - {props.count} - - ); -} - -function EditorDiffOptionsMenu(props: { - scopePickerValue: string | null; - scopeFileCounts: Partial>; - selectedTurnId: TurnId | null; - orderedTurnDiffSummaries: ReadonlyArray; - inferredCheckpointTurnCountByTurnId: Record; - timestampFormat: TimestampFormat; - renderableFiles: ReadonlyArray; - diffWordWrap: boolean; - diffIgnoreWhitespace: boolean; - diffCopyText: string | null; - isDiffCopied: boolean; - allFilesCollapsed: boolean; - diffRenderMode: DiffRenderMode; - onSelectRepoScope: (scope: RepoDiffScope) => void; - onSelectAllTurns: () => void; - onSelectLastTurn: () => void; - onSelectTurn: (turnId: TurnId | null) => void; - onDiffRenderModeChange: (mode: DiffRenderMode) => void; - onDiffWordWrapChange: (enabled: boolean) => void; - onDiffIgnoreWhitespaceChange: (enabled: boolean) => void; - onCopyDiff: () => void; - onToggleCollapseAll: () => void; -}) { - const [optionsOpen, setOptionsOpen] = useState(false); - - return ( - - { - setOptionsOpen(true); - }} - onKeyDown={(event) => { - if (event.key === "Enter" || event.key === " ") { - setOptionsOpen(true); - } - }} - onPointerDown={() => { - setOptionsOpen(true); - }} - > - - - } - /> - - - Source - { - if (value === "allTurns") { - props.onSelectAllTurns(); - return; - } - if (value === "lastTurn") { - props.onSelectLastTurn(); - return; - } - if ( - value === "workingTree" || - value === "unstaged" || - value === "staged" || - value === "branch" - ) { - props.onSelectRepoScope(value); - } - }} - > - {DIFF_PANEL_PICKER_SCOPE_OPTIONS.map((scope) => ( - - {REPO_DIFF_SCOPE_LABELS[scope]} - - - ))} - - All turns - - - Last turn - - - - - {props.orderedTurnDiffSummaries.length > 0 ? ( - - Turns - { - props.onSelectTurn(value === "all-turns" ? null : (value as TurnId)); - }} - > - - All turns - - {props.orderedTurnDiffSummaries.map((summary) => { - const turnNumber = - summary.checkpointTurnCount ?? - props.inferredCheckpointTurnCountByTurnId[summary.turnId] ?? - "?"; - return ( - - Turn {turnNumber} - - {formatShortTimestamp(summary.completedAt, props.timestampFormat)} - - - ); - })} - - - ) : null} - - - View - { - if (value === "stacked" || value === "split") { - props.onDiffRenderModeChange(value); - } - }} - > - - - Stacked diff - - - - Split diff - - - { - props.onDiffIgnoreWhitespaceChange(checked === true); - }} - > - Ignore whitespace-only changes - - { - props.onDiffWordWrapChange(checked === true); - }} - > - Wrap long lines - - {props.diffCopyText ? ( - { - props.onCopyDiff(); - }} - > - - {props.isDiffCopied ? "Copied diff" : "Copy diff"} - - ) : null} - {props.renderableFiles.length > 0 ? ( - { - props.onToggleCollapseAll(); - }} - > - - {props.allFilesCollapsed ? "Expand all files" : "Collapse all files"} - - ) : null} - - - - ); -} - -function EditorDiffControls(props: { - scopePickerValue: string | null; - scopeFileCounts: Partial>; - selectedTurnId: TurnId | null; - orderedTurnDiffSummaries: ReadonlyArray; - inferredCheckpointTurnCountByTurnId: Record; - timestampFormat: TimestampFormat; - renderableFiles: ReadonlyArray; - diffRenderMode: DiffRenderMode; - diffWordWrap: boolean; - diffIgnoreWhitespace: boolean; - diffCopyText: string | null; - isDiffCopied: boolean; - allFilesCollapsed: boolean; - onSelectRepoScope: (scope: RepoDiffScope) => void; - onSelectAllTurns: () => void; - onSelectLastTurn: () => void; - onSelectTurn: (turnId: TurnId | null) => void; - onDiffRenderModeChange: (mode: DiffRenderMode) => void; - onDiffWordWrapChange: (enabled: boolean) => void; - onDiffIgnoreWhitespaceChange: (enabled: boolean) => void; - onCopyDiff: () => void; - onToggleCollapseAll: () => void; -}) { - return ( -
- -
- ); -} - -interface DiffPanelProps { - mode?: DiffPanelMode; - threadId?: ThreadId | null; - panelState?: Pick; - onUpdatePanelState?: ( - patch: Partial>, - ) => void; - onClosePanel?: () => void; - liveRefreshEnabled?: boolean; - /** When false, skip git/diff fetches (e.g. right dock collapsed or pane hidden). */ - queriesEnabled?: boolean; - hideHeader?: boolean; - onRenderableFilesChange?: (files: ReadonlyArray, isLoading: boolean) => void; - onEditorDiffOptionsChange?: (control: ReactNode | null) => void; -} - -export { DiffWorkerPoolProvider } from "./DiffWorkerPoolProvider"; - -export default function DiffPanel({ - mode = "inline", - threadId: controlledThreadId, - panelState, - onUpdatePanelState, - onClosePanel, - liveRefreshEnabled = true, - queriesEnabled = true, - hideHeader = false, - onRenderableFilesChange, - onEditorDiffOptionsChange, -}: DiffPanelProps) { - const navigate = useNavigate(); - const { resolvedTheme } = useTheme(); - const { settings } = useAppSettings(); - const [diffRenderMode, setDiffRenderMode] = useState("split"); - const [diffWordWrap, setDiffWordWrap] = useState(settings.diffWordWrap); - const [diffIgnoreWhitespace, setDiffIgnoreWhitespace] = useState(true); - const [scopePickerOpen, setScopePickerOpen] = useState(false); - const handleScopePickerOpenChange = useCallback((open: boolean) => { - setScopePickerOpen((previous) => (previous === open ? previous : open)); - }, []); - const repoDiffScope = useRepoDiffScopeStore((store) => store.scope); - const setRepoDiffScope = useRepoDiffScopeStore((store) => store.setScope); - const [collapsedFiles, setCollapsedFiles] = useState>(() => new Set()); - const [fileTreeOpen, setFileTreeOpen] = useState(false); - // Lazy-mount the review file tree on first open so a closed diff panel never - // pays to filter/build/render the side tree (the common case). Keep it mounted - // afterward so the open/close animation plays and the filter + expand state - // persist across toggles. - const [fileTreeMounted, setFileTreeMounted] = useState(false); - const toggleFileTree = useCallback(() => { - setFileTreeOpen((previous) => !previous); - setFileTreeMounted(true); - }, []); - const closeFileTree = useCallback(() => { - setFileTreeOpen(false); - }, []); - const patchViewportRef = useRef(null); - const previousDiffOpenRef = useRef(false); - const routeThreadId = useParams({ - strict: false, - select: (params) => (params.threadId ? ThreadId.makeUnsafe(params.threadId) : null), - }); - const diffSearch = useDiffRouteSearch(); - const diffOpen = panelState ? panelState.panel === "diff" : diffSearch.diff === "1"; - const diffQueriesEnabled = useMemo( - () => - resolveDiffPanelQueriesEnabled({ - diffOpen, - queriesEnabled, - }), - [diffOpen, queriesEnabled], - ); - const scopeCountQueriesEnabled = useMemo( - () => - resolveDiffPanelScopeCountQueriesEnabled({ - queriesEnabled: diffQueriesEnabled, - scopePickerOpen, - }), - [diffQueriesEnabled, scopePickerOpen], - ); - const activeThreadId = controlledThreadId ?? routeThreadId; - const serverThreadCatalog = useStore( - useMemo(() => createDiffPanelThreadCatalogSelector(activeThreadId), [activeThreadId]), - ); - const shouldPollRepoDiff = useStore( - useMemo(() => createDiffPanelRepoLiveRefreshSelector(activeThreadId), [activeThreadId]), - ); - const draftThread = useComposerDraftStore((store) => - activeThreadId ? (store.draftThreadsByThreadId[activeThreadId] ?? null) : null, - ); - const fallbackDraftProjectId = draftThread?.projectId ?? null; - const fallbackDraftProject = useStore( - useMemo(() => createProjectSelector(fallbackDraftProjectId), [fallbackDraftProjectId]), - ); - // Keep draft-backed thread context available before the first server turn exists. - const activeThreadContext = useMemo((): DiffPanelThreadCatalog | undefined => { - if (serverThreadCatalog) { - return serverThreadCatalog; - } - const draftBackedThread = resolveDiffPanelThread({ - threadId: activeThreadId, - serverThread: undefined, - draftThread, - fallbackModelSelection: fallbackDraftProject?.defaultModelSelection ?? null, - }); - return draftBackedThread ? toDiffPanelThreadCatalog(draftBackedThread) : undefined; - }, [ - activeThreadId, - draftThread, - fallbackDraftProject?.defaultModelSelection, - serverThreadCatalog, - ]); - const activeProjectId = activeThreadContext?.projectId ?? draftThread?.projectId ?? null; - const activeProject = useStore( - useMemo(() => createProjectSelector(activeProjectId), [activeProjectId]), - ); - const resolvedThreadEnvMode = - serverThreadCatalog?.envMode ?? draftThread?.envMode ?? activeThreadContext?.envMode; - const resolvedThreadWorktreePath = - serverThreadCatalog?.worktreePath ?? - draftThread?.worktreePath ?? - activeThreadContext?.worktreePath ?? - null; - const diffEnvironmentState = resolveDiffEnvironmentState({ - projectCwd: activeProject?.cwd ?? null, - envMode: resolvedThreadEnvMode, - worktreePath: resolvedThreadWorktreePath, - }); - const diffEnvironmentPending = diffEnvironmentState.pending; - const activeCwd = diffEnvironmentState.cwd; - const selectedTurnId = panelState - ? (panelState.diffTurnId ?? null) - : (diffSearch.diffTurnId ?? null); - const [diffViewKind, setDiffViewKind] = useState(() => - resolveInitialDiffViewKind(selectedTurnId), - ); - const [turnScopeIntent, setTurnScopeIntent] = useState(() => - selectedTurnId === null ? "all" : "last", - ); - const gitStatusQueriesEnabled = useMemo( - () => - resolveDiffPanelGitStatusQueriesEnabled({ - queriesEnabled: diffQueriesEnabled, - activeCwd, - diffViewKind, - }), - [activeCwd, diffQueriesEnabled, diffViewKind], - ); - const gitBranchesQuery = useQuery({ - ...gitBranchesQueryOptions(activeCwd ?? null), - enabled: diffQueriesEnabled && activeCwd !== null, - }); - const gitStatusQuery = useQuery({ - ...gitStatusQueryOptions(activeCwd ?? null), - enabled: gitStatusQueriesEnabled, - }); - const gitRepoStatus = gitBranchesQuery.isSuccess ? gitBranchesQuery.data.isRepo : undefined; - const gitRepoStatusError = - gitBranchesQuery.error instanceof Error - ? gitBranchesQuery.error.message - : gitBranchesQuery.error - ? "Failed to check git repository." - : null; - const isGitRepo = gitRepoStatus === true; - const turnDiffSummaries = activeThreadContext?.turnDiffSummaries ?? []; - const inferredCheckpointTurnCountByTurnId = useMemo( - () => inferCheckpointTurnCountByTurnId(turnDiffSummaries), - [turnDiffSummaries], - ); - const repoDiffLiveRefreshIntervalMs = useMemo( - () => - resolveDiffPanelRepoLiveRefetchIntervalMs({ - queriesEnabled: diffQueriesEnabled, - liveRefreshEnabled, - diffViewKind, - shouldPollRepoDiff, - }), - [diffQueriesEnabled, diffViewKind, liveRefreshEnabled, shouldPollRepoDiff], - ); - const orderedTurnDiffSummaries = useMemo( - () => - [...turnDiffSummaries].toSorted((left, right) => { - const leftTurnCount = - left.checkpointTurnCount ?? inferredCheckpointTurnCountByTurnId[left.turnId] ?? 0; - const rightTurnCount = - right.checkpointTurnCount ?? inferredCheckpointTurnCountByTurnId[right.turnId] ?? 0; - if (leftTurnCount !== rightTurnCount) { - return rightTurnCount - leftTurnCount; - } - return right.completedAt.localeCompare(left.completedAt); - }), - [inferredCheckpointTurnCountByTurnId, turnDiffSummaries], - ); - - const selectedFilePath = panelState - ? (panelState.diffFilePath ?? null) - : (diffSearch.diffFilePath ?? null); - const selectedTurn = useMemo( - () => resolveSelectedTurnSummary(selectedTurnId, orderedTurnDiffSummaries), - [orderedTurnDiffSummaries, selectedTurnId], - ); - const selectedCheckpointTurnCount = - selectedTurn && - (selectedTurn.checkpointTurnCount ?? inferredCheckpointTurnCountByTurnId[selectedTurn.turnId]); - const selectedCheckpointRange = useMemo( - () => - typeof selectedCheckpointTurnCount === "number" - ? { - fromTurnCount: Math.max(0, selectedCheckpointTurnCount - 1), - toTurnCount: selectedCheckpointTurnCount, - } - : null, - [selectedCheckpointTurnCount], - ); - const conversationCheckpointTurnCount = useMemo(() => { - const turnCounts = orderedTurnDiffSummaries - .map( - (summary) => - summary.checkpointTurnCount ?? inferredCheckpointTurnCountByTurnId[summary.turnId], - ) - .filter((value): value is number => typeof value === "number"); - if (turnCounts.length === 0) { - return undefined; - } - const latest = Math.max(...turnCounts); - return latest > 0 ? latest : undefined; - }, [inferredCheckpointTurnCountByTurnId, orderedTurnDiffSummaries]); - const conversationCheckpointRange = useMemo( - () => - !selectedTurn && - turnScopeIntent !== "last" && - typeof conversationCheckpointTurnCount === "number" - ? { - fromTurnCount: 0, - toTurnCount: conversationCheckpointTurnCount, - } - : null, - [conversationCheckpointTurnCount, selectedTurn, turnScopeIntent], - ); - const activeCheckpointRange = selectedTurn - ? selectedCheckpointRange - : conversationCheckpointRange; - const conversationCacheScope = useMemo( - () => - selectedTurn || orderedTurnDiffSummaries.length === 0 - ? null - : resolveConversationCacheScope(conversationCheckpointTurnCount), - [conversationCheckpointTurnCount, orderedTurnDiffSummaries.length, selectedTurn], - ); - const activeCheckpointDiffQuery = useQuery( - checkpointDiffQueryOptions({ - threadId: activeThreadId, - fromTurnCount: activeCheckpointRange?.fromTurnCount ?? null, - toTurnCount: activeCheckpointRange?.toTurnCount ?? null, - ignoreWhitespace: diffIgnoreWhitespace, - cacheScope: selectedTurn ? `turn:${selectedTurn.turnId}` : conversationCacheScope, - enabled: - diffQueriesEnabled && isGitRepo && !diffEnvironmentPending && diffViewKind === "turn", - }), - ); - const selectedTurnCheckpointDiff = selectedTurn - ? activeCheckpointDiffQuery.data?.diff - : undefined; - const conversationCheckpointDiff = selectedTurn - ? undefined - : activeCheckpointDiffQuery.data?.diff; - const checkpointDiffDisplay = resolveCheckpointDiffQueryDisplayState({ - isLoading: activeCheckpointDiffQuery.isLoading, - isFetching: activeCheckpointDiffQuery.isFetching, - data: activeCheckpointDiffQuery.data, - error: activeCheckpointDiffQuery.error, - }); - const isLoadingCheckpointDiff = checkpointDiffDisplay.isLoading; - const checkpointDiffError = checkpointDiffDisplay.error; - - const selectedPatch = selectedTurn ? selectedTurnCheckpointDiff : conversationCheckpointDiff; - const hasResolvedPatch = typeof selectedPatch === "string"; - const hasNoNetChanges = hasResolvedPatch && selectedPatch.trim().length === 0; - const unstagedDiffQuery = useQuery( - gitWorkingTreeDiffQueryOptions({ - cwd: activeCwd ?? null, - scope: "unstaged", - enabled: scopeCountQueriesEnabled && !diffEnvironmentPending, - }), - ); - const stagedDiffQuery = useQuery( - gitWorkingTreeDiffQueryOptions({ - cwd: activeCwd ?? null, - scope: "staged", - enabled: scopeCountQueriesEnabled && !diffEnvironmentPending, - }), - ); - const branchDiffQuery = useQuery( - gitWorkingTreeDiffQueryOptions({ - cwd: activeCwd ?? null, - scope: "branch", - enabled: scopeCountQueriesEnabled && !diffEnvironmentPending, - }), - ); - const repoDiffQuery = useQuery( - gitWorkingTreeDiffQueryOptions({ - cwd: activeCwd ?? null, - scope: repoDiffScope, - enabled: diffQueriesEnabled && !diffEnvironmentPending && diffViewKind === "repo", - refetchInterval: repoDiffLiveRefreshIntervalMs, - }), - ); - const repoPatch = repoDiffQuery.data?.patch; - const hasResolvedRepoPatch = typeof repoPatch === "string"; - const hasNoRepoChanges = hasResolvedRepoPatch && repoPatch.trim().length === 0; - const repoDiffError = - repoDiffQuery.error instanceof Error - ? repoDiffQuery.error.message - : repoDiffQuery.error - ? "Failed to load repo diff." - : null; - const branchHasCommittedChanges = (gitStatusQuery.data?.aheadCount ?? 0) > 0; - - useEffect(() => { - if ( - diffOpen && - diffViewKind === "repo" && - repoDiffScope === "workingTree" && - hasResolvedRepoPatch && - hasNoRepoChanges && - branchHasCommittedChanges - ) { - setRepoDiffScope("branch"); - } - }, [ - branchHasCommittedChanges, - diffOpen, - diffViewKind, - hasNoRepoChanges, - hasResolvedRepoPatch, - repoDiffScope, - setRepoDiffScope, - ]); - - const viewSource = useMemo( - () => - resolveDiffPanelViewSource({ - diffViewKind, - repoDiffScope, - selectedTurnId, - }), - [diffViewKind, repoDiffScope, selectedTurnId], - ); - const activeReviewPatch = diffViewKind === "repo" ? repoPatch : selectedPatch; - const activeReviewError = diffViewKind === "repo" ? repoDiffError : checkpointDiffError; - const activeReviewIsLoading = - diffViewKind === "repo" ? repoDiffQuery.isLoading : isLoadingCheckpointDiff; - const activeReviewHasNoChanges = diffViewKind === "repo" ? hasNoRepoChanges : hasNoNetChanges; - const { copyToClipboard: copyDiffToClipboard, isCopied: isDiffCopied } = useCopyToClipboard(); - const diffCopyText = useMemo(() => resolveDiffCopyText(activeReviewPatch), [activeReviewPatch]); - // The parsed patch is structural and theme-agnostic — theming is applied - // separately via the themed row key and buildDiffPanelUnsafeCSS (cached per - // theme). Keeping `resolvedTheme` out of the parse cache scope and these deps - // avoids re-parsing the whole patch on every light/dark toggle. - const renderablePatch = useMemo(() => getRenderablePatch(activeReviewPatch), [activeReviewPatch]); - const renderableFiles = useMemo(() => { - if (!renderablePatch || renderablePatch.kind !== "files") { - return []; - } - return sortFileDiffsByPath(renderablePatch.files); - }, [renderablePatch]); - useEffect(() => { - onRenderableFilesChange?.(renderableFiles, activeReviewIsLoading); - }, [activeReviewIsLoading, onRenderableFilesChange, renderableFiles]); - const activePatchStat = useMemo( - () => summarizeRenderablePatchStats(renderablePatch), - [renderablePatch], - ); - const workingTreeDiffQuery = useQuery( - gitWorkingTreeDiffQueryOptions({ - cwd: activeCwd ?? null, - scope: "workingTree", - enabled: scopeCountQueriesEnabled && !diffEnvironmentPending, - }), - ); - const pickerScopeFileCounts = useMemo(() => { - const counts: Partial> = {}; - const workingTreeCount = summarizePatchTotals(workingTreeDiffQuery.data?.patch)?.fileCount; - const unstagedCount = summarizePatchTotals(unstagedDiffQuery.data?.patch)?.fileCount; - const stagedCount = summarizePatchTotals(stagedDiffQuery.data?.patch)?.fileCount; - const branchCount = summarizePatchTotals(branchDiffQuery.data?.patch)?.fileCount; - if (typeof workingTreeCount === "number") counts.workingTree = workingTreeCount; - if (typeof unstagedCount === "number") counts.unstaged = unstagedCount; - if (typeof stagedCount === "number") counts.staged = stagedCount; - if (typeof branchCount === "number") counts.branch = branchCount; - return counts; - }, [ - branchDiffQuery.data?.patch, - stagedDiffQuery.data?.patch, - unstagedDiffQuery.data?.patch, - workingTreeDiffQuery.data?.patch, - ]); - const scopeFileCounts = useMemo( - () => - resolveDiffPanelScopeFileCounts({ - viewSource, - activeScopeFileCount: activePatchStat?.fileCount, - scopePickerOpen, - pickerScopeCounts: pickerScopeFileCounts, - }), - [activePatchStat?.fileCount, pickerScopeFileCounts, scopePickerOpen, viewSource], - ); - const allFilesCollapsed = useMemo( - () => areAllRenderableFilesCollapsed(renderableFiles, collapsedFiles), - [collapsedFiles, renderableFiles], - ); - useEffect(() => { - if (diffOpen && !previousDiffOpenRef.current) { - setDiffWordWrap(settings.diffWordWrap); - setDiffViewKind(resolveInitialDiffViewKind(selectedTurnId)); - } - previousDiffOpenRef.current = diffOpen; - }, [diffOpen, selectedTurnId, settings.diffWordWrap]); - - useEffect(() => { - if (selectedTurnId !== null) { - setDiffViewKind((current) => (current === "turn" ? current : "turn")); - } - }, [selectedTurnId]); - - useEffect(() => { - if (!selectedFilePath || !patchViewportRef.current) { - return; - } - const target = Array.from( - patchViewportRef.current.querySelectorAll("[data-diff-file-path]"), - ).find((element) => element.dataset.diffFilePath === selectedFilePath); - target?.scrollIntoView({ block: "nearest" }); - }, [selectedFilePath, renderableFiles]); - - const toggleFileCollapsed = useCallback((fileKey: string) => { - setCollapsedFiles((prev) => { - const next = new Set(prev); - if (next.has(fileKey)) next.delete(fileKey); - else next.add(fileKey); - return next; - }); - }, []); - - // Per-file header actions that talk to the active thread's composer draft. - const diffFileChatActions = useMemo( - () => - activeThreadId - ? { - onReferenceInChat: (filePath: string) => { - appendChatFileReference(activeThreadId, { path: filePath }); - }, - onAskWhyChanged: (filePath: string) => { - appendComposerPromptText(activeThreadId, buildWhyChangedPrompt(filePath)); - }, - } - : undefined, - [activeThreadId], - ); - - // Highlight diff code -> floating "Add to chat" -> mention + quoted snippet. - // The diff body renders inside the @pierre/diffs shadow root, so selection - // ancestors are resolved through shadow boundaries. - const readDiffSelection = useCallback((container: HTMLElement) => { - const selection = window.getSelection(); - if (!selection || selection.rangeCount === 0 || selection.isCollapsed) { - return null; - } - const anchorRow = closestThroughShadow(selection.anchorNode, "[data-diff-file-path]"); - const focusRow = closestThroughShadow(selection.focusNode, "[data-diff-file-path]"); - if (!anchorRow || anchorRow !== focusRow || !container.contains(anchorRow)) { - return null; - } - const filePath = anchorRow.getAttribute("data-diff-file-path") ?? ""; - const text = selection - .toString() - .replace(/\r\n/g, "\n") - .replace(/^\n+|\n+$/g, "") - .trim(); - if (filePath.length === 0 || text.length === 0) { - return null; - } - return { filePath, text }; - }, []); - const commitDiffSelection = useCallback( - (payload: { filePath: string; text: string }) => { - if (activeThreadId) { - appendComposerPromptText( - activeThreadId, - buildDiffSelectionReference(payload.filePath, payload.text), - ); - } - }, - [activeThreadId], - ); - const diffSelectionAction = useCodeSelectionAction({ - enabled: activeThreadId !== null, - readSelection: readDiffSelection, - onCommit: commitDiffSelection, - }); - - const updateDiffSelection = useCallback( - (input: { turnId: TurnId | null; filePath?: string | null }) => { - if (!activeThreadContext) return; - if (onUpdatePanelState) { - onUpdatePanelState({ - panel: "diff", - diffTurnId: input.turnId, - diffFilePath: input.filePath ?? null, - }); - return; - } - void navigate({ - to: "/$threadId", - params: { threadId: activeThreadContext.id }, - search: (previous) => { - const rest = stripDiffSearchParams(previous); - return { - ...rest, - panel: "diff", - diff: "1", - ...(input.turnId ? { diffTurnId: input.turnId } : {}), - ...(input.filePath ? { diffFilePath: input.filePath } : {}), - }; - }, - }); - }, - [activeThreadContext, navigate, onUpdatePanelState], - ); - useEffect(() => { - if (!diffOpen || !activeThreadContext) { - return; - } - if (!isStaleDiffTurnSelection(selectedTurnId, orderedTurnDiffSummaries)) { - return; - } - updateDiffSelection({ turnId: null, filePath: null }); - }, [ - activeThreadContext, - diffOpen, - orderedTurnDiffSummaries, - selectedTurnId, - updateDiffSelection, - ]); - const selectTurn = useCallback( - (turnId: TurnId | null) => { - setDiffViewKind("turn"); - setTurnScopeIntent(turnId === null ? "all" : "last"); - updateDiffSelection({ turnId, filePath: null }); - }, - [updateDiffSelection], - ); - const selectRepoScope = useCallback( - (scope: typeof repoDiffScope) => { - setDiffViewKind("repo"); - setRepoDiffScope(scope); - if (selectedTurnId !== null) { - updateDiffSelection({ turnId: null, filePath: null }); - } - }, - [selectedTurnId, setRepoDiffScope, updateDiffSelection], - ); - const selectAllTurns = useCallback(() => { - setTurnScopeIntent("all"); - selectTurn(null); - }, [selectTurn]); - const selectLastTurn = useCallback(() => { - const latestTurn = orderedTurnDiffSummaries[0]; - setTurnScopeIntent("last"); - setDiffViewKind("turn"); - if (!latestTurn) { - if (selectedTurnId !== null) { - updateDiffSelection({ turnId: null, filePath: null }); - } - return; - } - selectTurn(latestTurn.turnId); - }, [orderedTurnDiffSummaries, selectTurn, selectedTurnId, updateDiffSelection]); - const toggleCollapseAll = useCallback(() => { - setCollapsedFiles((previous) => { - if (areAllRenderableFilesCollapsed(renderableFiles, previous)) { - return new Set(); - } - return new Set(renderableFiles.map((fileDiff) => buildFileDiffRenderKey(fileDiff))); - }); - }, [renderableFiles]); - const selectFile = useCallback( - (filePath: string) => { - updateDiffSelection({ turnId: selectedTurnId, filePath }); - }, - [selectedTurnId, updateDiffSelection], - ); - const showDiffToolbar = Boolean(activeThreadContext && isGitRepo && !diffEnvironmentPending); - const copyDiff = useCallback(() => { - if (diffCopyText) { - copyDiffToClipboard(diffCopyText, undefined); - } - }, [copyDiffToClipboard, diffCopyText]); - const latestTurnId = orderedTurnDiffSummaries[0]?.turnId ?? null; - const scopePickerValue = useMemo( - () => - resolveDiffPanelScopePickerValue({ - viewSource, - latestTurnId, - turnScopeIntent, - }), - [latestTurnId, turnScopeIntent, viewSource], - ); - const editorDiffOptionsControl = useMemo( - () => - hideHeader ? ( - - ) : null, - [ - allFilesCollapsed, - copyDiff, - diffCopyText, - diffIgnoreWhitespace, - diffRenderMode, - diffWordWrap, - hideHeader, - inferredCheckpointTurnCountByTurnId, - isDiffCopied, - orderedTurnDiffSummaries, - renderableFiles, - scopeFileCounts, - scopePickerValue, - selectAllTurns, - selectLastTurn, - selectRepoScope, - selectTurn, - selectedTurnId, - settings.timestampFormat, - toggleCollapseAll, - ], - ); - useEffect(() => { - onEditorDiffOptionsChange?.(editorDiffOptionsControl); - }, [editorDiffOptionsControl, onEditorDiffOptionsChange]); - useEffect( - () => () => { - onEditorDiffOptionsChange?.(null); - }, - [onEditorDiffOptionsChange], - ); - - const shellHeader = useMemo( - () => - hideHeader ? null : showDiffToolbar ? ( - - ) : onClosePanel ? ( -
- { - event.stopPropagation(); - onClosePanel(); - }} - > - - -
- ) : null, - [ - activeCwd, - activePatchStat, - activeThreadId, - allFilesCollapsed, - copyDiff, - diffCopyText, - diffIgnoreWhitespace, - diffRenderMode, - diffWordWrap, - fileTreeOpen, - hideHeader, - inferredCheckpointTurnCountByTurnId, - isDiffCopied, - handleScopePickerOpenChange, - onClosePanel, - orderedTurnDiffSummaries, - scopePickerOpen, - renderableFiles, - resolvedTheme, - scopeFileCounts, - selectAllTurns, - selectFile, - selectLastTurn, - selectRepoScope, - selectTurn, - selectedFilePath, - selectedTurnId, - settings.timestampFormat, - showDiffToolbar, - toggleCollapseAll, - toggleFileTree, - turnScopeIntent, - viewSource, - ], - ); - - return ( - - {!activeThreadContext ? ( - - Select a thread to inspect turn diffs. - - ) : gitRepoStatus === false ? ( - - Turn diffs are unavailable because this project is not a git repository. - - ) : gitRepoStatusError ? ( - - {gitRepoStatusError} - - ) : gitRepoStatus === undefined && diffQueriesEnabled && activeCwd ? ( - - ) : diffEnvironmentPending ? ( - - This chat environment is still being prepared. Diffs will be available once the worktree - is ready. - - ) : ( -
-
- - {diffSelectionAction.pendingAction ? ( - - ) : null} -
- {hideHeader ? null : ( -
- {/* Empty until first open: the wrapper stays mounted (free) so the - width reveal animates, but the tree only filters/builds once the - user actually opens it. */} - {fileTreeMounted ? ( - - ) : null} -
- )} -
- )} -
- ); -} diff --git a/apps/web/src/components/DiffPanelFileJumpMenu.tsx b/apps/web/src/components/DiffPanelFileJumpMenu.tsx deleted file mode 100644 index c00f416c6..000000000 --- a/apps/web/src/components/DiffPanelFileJumpMenu.tsx +++ /dev/null @@ -1,151 +0,0 @@ -// FILE: DiffPanelFileJumpMenu.tsx -// Purpose: Searchable "jump to file" picker for the diff panel toolbar. Reuses the -// composer picker shell and FileEntryIcon (central-icons-reversed) so file -// rows match the command menu and git pane. -// Layer: Diff panel UI - -import type { FileDiffMetadata } from "@pierre/diffs/react"; -import { memo, useMemo, useState } from "react"; - -import { SearchIcon } from "~/lib/icons"; -import { cn } from "~/lib/utils"; -import { filterRenderableFilesForSearch } from "./DiffPanel.logic"; -import { ComposerPickerMenuPopup } from "./chat/ComposerPickerMenuPopup"; -import { PickerPanelShell } from "./chat/PickerPanelShell"; -import { FileEntryIcon } from "./chat/FileEntryIcon"; -import { DiffStat } from "./chat/DiffStatLabel"; -import { IconButton } from "./ui/icon-button"; -import { Menu, MenuItem, MenuTrigger } from "./ui/menu"; -import { - resolveFileDiffPath, - splitRepoRelativePath, - summarizeFileDiffStats, -} from "../lib/diffRendering"; - -const DIFF_FILE_JUMP_ICON_SLOT_CLASS_NAME = - "flex size-4 shrink-0 items-center justify-center text-muted-foreground/60"; - -const DIFF_FILE_JUMP_FILE_ICON_CLASS_NAME = - "size-3.5 text-[var(--color-text-foreground)] opacity-70 dark:opacity-80"; - -const DiffFileJumpRow = memo(function DiffFileJumpRow(props: { - fileDiff: FileDiffMetadata; - resolvedTheme: "light" | "dark"; - isSelected: boolean; - onSelect: (filePath: string) => void; -}) { - const filePath = resolveFileDiffPath(props.fileDiff); - const { dir, name } = splitRepoRelativePath(filePath); - const stat = useMemo(() => summarizeFileDiffStats([props.fileDiff]), [props.fileDiff]); - - return ( - { - props.onSelect(filePath); - }} - > - - - -
-
- {name} - {dir ? ( - {dir} - ) : null} -
- -
-
- ); -}); - -export function DiffPanelFileJumpMenu(props: { - renderableFiles: ReadonlyArray; - selectedFilePath: string | null; - resolvedTheme: "light" | "dark"; - onSelectFile: (filePath: string) => void; -}) { - const [fileSearchQuery, setFileSearchQuery] = useState(""); - - const filteredFiles = useMemo( - () => filterRenderableFilesForSearch(props.renderableFiles, fileSearchQuery), - [fileSearchQuery, props.renderableFiles], - ); - - return ( - { - if (!open) { - setFileSearchQuery(""); - } - }} - > - - - - } - /> - - - {props.renderableFiles.length === 0 ? ( -

No files in this diff.

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

No matching files.

- ) : ( - filteredFiles.map((fileDiff) => { - const filePath = resolveFileDiffPath(fileDiff); - return ( - { - props.onSelectFile(path); - setFileSearchQuery(""); - }} - /> - ); - }) - )} -
-
-
- ); -} diff --git a/apps/web/src/components/DiffPanelFileList.tsx b/apps/web/src/components/DiffPanelFileList.tsx index 085952def..63680bc41 100644 --- a/apps/web/src/components/DiffPanelFileList.tsx +++ b/apps/web/src/components/DiffPanelFileList.tsx @@ -99,7 +99,6 @@ function DiffFileCollapseChevron(props: { collapsed: boolean }) { const DiffPanelFileRow = memo(function DiffPanelFileRow(props: { fileDiff: FileDiffMetadata; - resolvedTheme: "light" | "dark"; diffRenderMode: DiffRenderMode; diffWordWrap: boolean; workspaceRoot: string | null; @@ -154,7 +153,6 @@ const DiffPanelFileRow = memo(function DiffPanelFileRow(props: { > , right: ReadonlySet; - resolvedTheme: "light" | "dark"; diffRenderMode: DiffRenderMode; diffWordWrap: boolean; workspaceRoot: string | null; @@ -213,12 +210,10 @@ export const DiffPanelFileList = memo( {props.renderableFiles.map((fileDiff) => { const fileKey = buildFileDiffRenderKey(fileDiff); - const themedFileKey = `${fileKey}:${props.resolvedTheme}`; return ( { return ( previous.renderableFiles === next.renderableFiles && - previous.resolvedTheme === next.resolvedTheme && previous.diffRenderMode === next.diffRenderMode && previous.diffWordWrap === next.diffWordWrap && previous.workspaceRoot === next.workspaceRoot && diff --git a/apps/web/src/components/DiffPanelPatchViewport.tsx b/apps/web/src/components/DiffPanelPatchViewport.tsx index 818045a38..ef1500bb5 100644 --- a/apps/web/src/components/DiffPanelPatchViewport.tsx +++ b/apps/web/src/components/DiffPanelPatchViewport.tsx @@ -17,7 +17,6 @@ export const DiffPanelPatchViewport = memo( function DiffPanelPatchViewport(props: { renderablePatch: RenderablePatch | null; renderableFiles: ReadonlyArray; - resolvedTheme: "light" | "dark"; diffRenderMode: DiffRenderMode; diffWordWrap: boolean; workspaceRoot: string | null; @@ -76,7 +75,6 @@ export const DiffPanelPatchViewport = memo(
; -} - -interface DiffPanelToolbarProps { - activeCwd: string | null; - activeThreadId: ThreadId | null; - viewSource: DiffPanelViewSource; - turnScopeIntent: DiffPanelTurnScopeIntent; - scopeFileCounts: Partial>; - activeStats: { additions: number; deletions: number } | null; - orderedTurnDiffSummaries: ReadonlyArray; - inferredCheckpointTurnCountByTurnId: Record; - selectedTurnId: TurnId | null; - timestampFormat: TimestampFormat; - renderableFiles: ReadonlyArray; - selectedFilePath: string | null; - fileTreeOpen: boolean; - resolvedTheme: "light" | "dark"; - diffRenderMode: DiffRenderMode; - diffWordWrap: boolean; - diffIgnoreWhitespace: boolean; - diffCopyText: string | null; - isDiffCopied: boolean; - allFilesCollapsed: boolean; - onSelectRepoScope: (scope: RepoDiffScope) => void; - onSelectAllTurns: () => void; - onSelectLastTurn: () => void; - onSelectTurn: (turnId: TurnId | null) => void; - onSelectFile: (filePath: string) => void; - onToggleFileTree: () => void; - onDiffRenderModeChange: (mode: DiffRenderMode) => void; - onDiffWordWrapChange: (enabled: boolean) => void; - onDiffIgnoreWhitespaceChange: (enabled: boolean) => void; - onCopyDiff: () => void; - onToggleCollapseAll: () => void; - scopePickerOpen?: boolean; - onScopePickerOpenChange?: (open: boolean) => void; - onClosePanel?: () => void; -} - -function ScopeCountBadge(props: { count: number | undefined }) { - if (typeof props.count !== "number" || props.count <= 0) { - return null; - } - return ( - - {props.count} - - ); -} - -function resolveScopeMenuIcon(scope: RepoDiffScope | "lastTurn") { - switch (scope) { - case "unstaged": - return ; - case "staged": - return ; - case "branch": - return ; - case "lastTurn": - return ( - - - - ); - default: - return ; - } -} - -function resolveTurnNumber( - summary: TurnDiffSummary, - inferredCheckpointTurnCountByTurnId: Record, -): string { - return String( - summary.checkpointTurnCount ?? inferredCheckpointTurnCountByTurnId[summary.turnId] ?? "?", - ); -} - -export const DiffPanelToolbar = memo(function DiffPanelToolbar(props: DiffPanelToolbarProps) { - const [visibleTurnCount, setVisibleTurnCount] = useState(INITIAL_VISIBLE_TURN_COUNT); - const scopePickerLabel = useMemo( - () => resolveDiffPanelPickerLabel(props.viewSource, props.turnScopeIntent), - [props.turnScopeIntent, props.viewSource], - ); - - const scopePickerIcon = useMemo((): ReactNode => { - if (props.viewSource.kind === "turn") { - return ; - } - return ; - }, [props.viewSource.kind]); - - const scopePickerCount = - props.viewSource.kind === "repo" ? props.scopeFileCounts[props.viewSource.scope] : undefined; - - const selectedTurnSummary = useMemo( - () => - props.selectedTurnId - ? props.orderedTurnDiffSummaries.find((summary) => summary.turnId === props.selectedTurnId) - : undefined, - [props.orderedTurnDiffSummaries, props.selectedTurnId], - ); - const turnsMenuLabel = - props.viewSource.kind === "turn" && props.selectedTurnId === null - ? "All turns" - : props.viewSource.kind === "turn" && props.selectedTurnId - ? `Turn ${ - selectedTurnSummary - ? resolveTurnNumber(selectedTurnSummary, props.inferredCheckpointTurnCountByTurnId) - : (props.inferredCheckpointTurnCountByTurnId[props.selectedTurnId] ?? "?") - }` - : "Turns"; - - const latestTurnId = props.orderedTurnDiffSummaries[0]?.turnId ?? null; - const scopePickerValue = resolveDiffPanelScopePickerValue({ - viewSource: props.viewSource, - latestTurnId, - turnScopeIntent: props.turnScopeIntent, - }); - const selectedTurnIndex = props.selectedTurnId - ? props.orderedTurnDiffSummaries.findIndex((summary) => summary.turnId === props.selectedTurnId) - : -1; - const effectiveVisibleTurnCount = Math.max( - visibleTurnCount, - selectedTurnIndex >= 0 ? selectedTurnIndex + 1 : 0, - ); - const visibleTurnSummaries = props.orderedTurnDiffSummaries.slice(0, effectiveVisibleTurnCount); - const hiddenTurnCount = Math.max( - 0, - props.orderedTurnDiffSummaries.length - visibleTurnSummaries.length, - ); - const nextVisibleTurnCount = Math.min( - props.orderedTurnDiffSummaries.length, - effectiveVisibleTurnCount + TURN_SHOW_MORE_INCREMENT, - ); - - return ( -
- - - } - > - {scopePickerLabel}} - trailing={ - <> - - - - } - /> - - - - Diff source - { - if (value === "allTurns") { - props.onSelectAllTurns(); - return; - } - if (value === "lastTurn") { - props.onSelectLastTurn(); - return; - } - if ( - value === "workingTree" || - value === "unstaged" || - value === "staged" || - value === "branch" - ) { - props.onSelectRepoScope(value); - } - }} - > - {DIFF_PANEL_PICKER_SCOPE_OPTIONS.map((scope) => ( - - {resolveScopeMenuIcon(scope)} - {REPO_DIFF_SCOPE_LABELS[scope]} - - - ))} - - - All turns - - - {resolveScopeMenuIcon("lastTurn")} - Last turn - - - - - - - {props.activeStats ? ( - - ) : null} - -
-
- - - - - } - /> - - - View -
- {(["stacked", "split"] as const).map((mode) => { - const selected = props.diffRenderMode === mode; - return ( - - ); - })} -
- { - props.onDiffIgnoreWhitespaceChange(checked === true); - }} - > - Ignore whitespace-only changes - - { - props.onDiffWordWrapChange(checked === true); - }} - > - Wrap long lines - - {props.diffCopyText ? ( - { - props.onCopyDiff(); - }} - > - - {props.isDiffCopied ? "Copied diff" : "Copy diff"} - - ) : null} - {props.renderableFiles.length > 0 ? ( - { - props.onToggleCollapseAll(); - }} - > - - - {props.allFilesCollapsed ? "Expand all files" : "Collapse all files"} - - - ) : null} -
-
-
- - - - - - -
- - - - {props.activeCwd ? ( - - ) : null} - - - - } - > - } - label={{turnsMenuLabel}} - trailing={} - /> - - - - Turns - { - if (value === "all-turns") { - props.onSelectTurn(null); - return; - } - props.onSelectTurn(value as TurnId); - }} - > - - - All turns - - {visibleTurnSummaries.map((summary) => ( - - - - Turn {resolveTurnNumber(summary, props.inferredCheckpointTurnCountByTurnId)} - - - {formatShortTimestamp(summary.completedAt, props.timestampFormat)} - - - ))} - - {hiddenTurnCount > 0 ? ( - - ) : null} - - - - - {props.onClosePanel ? ( - <> - - { - event.stopPropagation(); - props.onClosePanel?.(); - }} - > - - - - ) : null} -
-
- ); -}); diff --git a/apps/web/src/components/DiffWorkerPoolProvider.tsx b/apps/web/src/components/DiffWorkerPoolProvider.tsx index 5babd4248..554fdcdc4 100644 --- a/apps/web/src/components/DiffWorkerPoolProvider.tsx +++ b/apps/web/src/components/DiffWorkerPoolProvider.tsx @@ -1,8 +1,7 @@ import { WorkerPoolContextProvider, useWorkerPool } from "@pierre/diffs/react"; import DiffsWorker from "@pierre/diffs/worker/worker.js?worker"; import { useEffect, useMemo, type ReactNode } from "react"; -import { useTheme } from "../hooks/useTheme"; -import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering"; +import { DIFF_THEME_NAME, type DiffThemeName } from "../lib/diffRendering"; function DiffWorkerThemeSync({ themeName }: { themeName: DiffThemeName }) { const workerPool = useWorkerPool(); @@ -29,8 +28,7 @@ function DiffWorkerThemeSync({ themeName }: { themeName: DiffThemeName }) { } export function DiffWorkerPoolProvider({ children }: { children?: ReactNode }) { - const { resolvedTheme } = useTheme(); - const diffThemeName = resolveDiffThemeName(resolvedTheme); + const diffThemeName = DIFF_THEME_NAME; const workerPoolSize = useMemo(() => { const cores = typeof navigator === "undefined" ? 4 : Math.max(1, navigator.hardwareConcurrency || 4); diff --git a/apps/web/src/components/EditorWorkspaceView.test.tsx b/apps/web/src/components/EditorWorkspaceView.test.tsx deleted file mode 100644 index 0d828e7f6..000000000 --- a/apps/web/src/components/EditorWorkspaceView.test.tsx +++ /dev/null @@ -1,488 +0,0 @@ -// FILE: EditorWorkspaceView.test.tsx -// Purpose: Guards the editor-style shell layout around file/diff sidebars. -// Layer: Component rendering tests -// Depends on: EditorWorkspaceView and React server rendering. - -import type { FileDiffMetadata } from "@pierre/diffs/react"; -import { ProjectId } from "@t3tools/contracts"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { renderToStaticMarkup } from "react-dom/server"; -import { describe, expect, it, vi } from "vitest"; - -import { EditorWorkspaceView } from "./EditorWorkspaceView"; -import { WorkspaceSearchSidebar } from "./chat/workspaceExplorer"; -import { projectQueryKeys } from "../lib/projectReactQuery"; -import { SidebarProvider } from "./ui/sidebar"; - -vi.mock("../hooks/useTheme", () => ({ - useTheme: () => ({ resolvedTheme: "dark" }), -})); - -vi.mock("~/hooks/useDesktopTopBarGutter", () => ({ - useDesktopTopBarTrafficLightGutterClassName: () => "traffic-light-gutter", - useDesktopTopBarWindowControlsGutterClassName: () => "windows-caption-gutter", -})); - -function createFileDiff(path: string, additions: number, deletions: number): FileDiffMetadata { - return { - cacheKey: path, - name: path, - prevName: path, - hunks: [ - { - additionLines: additions, - deletionLines: deletions, - }, - ], - } as FileDiffMetadata; -} - -describe("EditorWorkspaceView", () => { - it("renders a project switcher arrow beside the project title", () => { - const markup = renderToStaticMarkup( - Diff panel
} - chatPanel={
Chat panel
} - onSelectFile={vi.fn()} - onSelectDiffFile={vi.fn()} - onToggleDirectory={vi.fn()} - onCenterModeChange={vi.fn()} - onExitEditorView={vi.fn()} - onSelectProject={vi.fn()} - />, - ); - - expect(markup).toContain('aria-label="Switch project"'); - expect(markup).toContain("project"); - }); - - it("reserves a top-bar gutter for Windows caption controls", () => { - const markup = renderToStaticMarkup( - Diff panel
} - chatPanel={
Chat panel
} - onSelectFile={vi.fn()} - onSelectDiffFile={vi.fn()} - onToggleDirectory={vi.fn()} - onCenterModeChange={vi.fn()} - onExitEditorView={vi.fn()} - />, - ); - - expect(markup).toContain("windows-caption-gutter"); - }); - - it("renders diff options beside the changed-file line totals", () => { - const markup = renderToStaticMarkup( - - - Options - - } - diffPanel={
Diff panel
} - chatPanel={
Chat panel
} - onSelectFile={vi.fn()} - onSelectDiffFile={vi.fn()} - onToggleDirectory={vi.fn()} - onCenterModeChange={vi.fn()} - onExitEditorView={vi.fn()} - /> -
, - ); - - expect(markup).toContain("Changed files"); - expect(markup).toContain("+3"); - expect(markup).toContain("-1"); - expect(markup).toContain('aria-label="Diff options"'); - - // Options sit in the "Changed files" header row; the +/- totals render in - // the stats row below it. - const changedFilesIndex = markup.indexOf("Changed files"); - const optionsIndex = markup.indexOf('aria-label="Diff options"', changedFilesIndex); - const additionsIndex = markup.indexOf(">+3<", optionsIndex); - const deletionsIndex = markup.indexOf(">-1<", additionsIndex); - - expect(optionsIndex).toBeGreaterThan(changedFilesIndex); - expect(additionsIndex).toBeGreaterThan(optionsIndex); - expect(deletionsIndex).toBeGreaterThan(additionsIndex); - }); - - it("shows skeleton rows instead of the empty message while the diff loads", () => { - const markup = renderToStaticMarkup( - - Diff panel
} - chatPanel={
Chat panel
} - onSelectFile={vi.fn()} - onSelectDiffFile={vi.fn()} - onToggleDirectory={vi.fn()} - onCenterModeChange={vi.fn()} - onExitEditorView={vi.fn()} - /> - , - ); - - expect(markup).toContain('aria-label="Loading changed files..."'); - expect(markup).not.toContain("No files in this diff."); - }); - - it("keeps the diff panel mounted but hidden while browsing files", () => { - const queryClient = new QueryClient(); - const markup = renderToStaticMarkup( - - - Diff panel body} - chatPanel={
Chat panel
} - onSelectFile={vi.fn()} - onSelectDiffFile={vi.fn()} - onToggleDirectory={vi.fn()} - onCenterModeChange={vi.fn()} - onExitEditorView={vi.fn()} - /> -
-
, - ); - - expect(markup).toContain("Diff panel body"); - const diffWrapperIndex = markup.indexOf("Diff panel body"); - const hiddenIndex = markup.lastIndexOf("hidden", diffWrapperIndex); - expect(hiddenIndex).toBeGreaterThan(-1); - }); - - it("renders image files through the local image preview instead of text preview", () => { - const queryClient = new QueryClient(); - const markup = renderToStaticMarkup( - - Diff panel} - chatPanel={
Chat panel
} - onSelectFile={vi.fn()} - onSelectDiffFile={vi.fn()} - onToggleDirectory={vi.fn()} - onCenterModeChange={vi.fn()} - onExitEditorView={vi.fn()} - /> -
, - ); - - expect(markup).toContain("local-image-preview"); - expect(markup).toContain( - "/api/local-image?path=assets%2Fscreenshot.png&cwd=%2FUsers%2Ftester%2Fproject", - ); - expect(markup).not.toContain("editor-file-viewer__plain"); - expect(markup).not.toContain("editor-file-viewer__highlight"); - }); - - it("routes PDFs through the shared preview surface instead of a bespoke viewer", () => { - const queryClient = new QueryClient(); - const markup = renderToStaticMarkup( - - Diff panel} - chatPanel={
Chat panel
} - onSelectFile={vi.fn()} - onSelectDiffFile={vi.fn()} - onToggleDirectory={vi.fn()} - onCenterModeChange={vi.fn()} - onExitEditorView={vi.fn()} - /> -
, - ); - - // PDFs have no in-app viewer: they take the ordinary file-read path, whose - // shared header (breadcrumb + "Open in editor") stays available so the file - // can be handed to an external app once the read reports it as binary. - expect(markup).toContain('title="docs/spec.pdf"'); - expect(markup).toContain('aria-label="Open in editor"'); - expect(markup).not.toContain(" { - const queryClient = new QueryClient(); - const markup = renderToStaticMarkup( - - Diff panel} - chatPanel={
Chat panel
} - onSelectFile={vi.fn()} - onSelectDiffFile={vi.fn()} - onToggleDirectory={vi.fn()} - onCenterModeChange={vi.fn()} - onExitEditorView={vi.fn()} - /> -
, - ); - - // The absolute path still resolves through the local-file preview grant, so - // the pane waits on the grant rather than dead-ending on "no workspace". - expect(markup).toContain('aria-label="Loading file..."'); - expect(markup).not.toContain("No workspace is attached"); - expect(markup).not.toContain("Loading PDF"); - }); - - it("renders scratch-workspace image previews without an attached workspace", () => { - const queryClient = new QueryClient(); - const markup = renderToStaticMarkup( - - Diff panel} - chatPanel={
Chat panel
} - onSelectFile={vi.fn()} - onSelectDiffFile={vi.fn()} - onToggleDirectory={vi.fn()} - onCenterModeChange={vi.fn()} - onExitEditorView={vi.fn()} - /> -
, - ); - - expect(markup).toContain("local-image-preview"); - expect(markup).toContain( - "/api/local-image?path=%2Ftmp%2Fchitauri-codex-workspaces%2Fthread-1%2Fshot.png", - ); - expect(markup).not.toContain("No workspace is attached"); - expect(markup).not.toContain("cwd="); - }); - - it("renders absolute local image previews without an attached workspace", () => { - const queryClient = new QueryClient(); - const markup = renderToStaticMarkup( - - Diff panel} - chatPanel={
Chat panel
} - onSelectFile={vi.fn()} - onSelectDiffFile={vi.fn()} - onToggleDirectory={vi.fn()} - onCenterModeChange={vi.fn()} - onExitEditorView={vi.fn()} - /> -
, - ); - - expect(markup).toContain('aria-label="Loading file..."'); - expect(markup).not.toContain("/api/local-image?path=%2FUsers%2Ftester%2FDownloads%2Fshot.png"); - expect(markup).not.toContain("No workspace is attached"); - expect(markup).not.toContain("cwd="); - }); - - it("shows the file-preview path breadcrumb and overflow menu for Markdown files", () => { - const queryClient = new QueryClient(); - const markup = renderToStaticMarkup( - - Diff panel} - chatPanel={
Chat panel
} - onSelectFile={vi.fn()} - onSelectDiffFile={vi.fn()} - onToggleDirectory={vi.fn()} - onCenterModeChange={vi.fn()} - onExitEditorView={vi.fn()} - /> -
, - ); - - // The header renders a path breadcrumb (project › …dirs › file). - expect(markup).toContain('aria-label="File path"'); - expect(markup).toContain("README.md"); - // Markdown files surface their source/rendered toggle in the header next - // to the Open-in picker, whose editor menu trigger is always rendered. - expect(markup).toContain('aria-label="Markdown view"'); - expect(markup).toContain('aria-label="Editor options"'); - }); - - it("renders a search item in the activity bar below files and diff", () => { - const queryClient = new QueryClient(); - const markup = renderToStaticMarkup( - - - Diff panel} - chatPanel={
Chat panel
} - onSelectFile={vi.fn()} - onSelectDiffFile={vi.fn()} - onToggleDirectory={vi.fn()} - onCenterModeChange={vi.fn()} - onExitEditorView={vi.fn()} - /> -
-
, - ); - - const filesIndex = markup.indexOf('aria-label="Hide files sidebar"'); - const diffIndex = markup.indexOf('aria-label="Diff"'); - const searchIndex = markup.indexOf('aria-label="Search files"'); - expect(filesIndex).toBeGreaterThan(-1); - expect(diffIndex).toBeGreaterThan(filesIndex); - expect(searchIndex).toBeGreaterThan(diffIndex); - }); - - it("prompts for a query in the search sidebar before searching", () => { - const queryClient = new QueryClient(); - const markup = renderToStaticMarkup( - - - , - ); - - expect(markup).toContain('placeholder="Search files..."'); - expect(markup).toContain("Search files by name or path."); - }); - - it("lists only matching files from the workspace search results", () => { - const queryClient = new QueryClient(); - queryClient.setQueryData( - projectQueryKeys.searchEntries("/Users/tester/project", "editor", 80, "file"), - { - entries: [{ path: "apps/web/src/components/EditorWorkspaceView.tsx", kind: "file" }], - truncated: false, - }, - ); - const markup = renderToStaticMarkup( - - - , - ); - - expect(markup).toContain('title="apps/web/src/components/EditorWorkspaceView.tsx"'); - expect(markup).toContain("EditorWorkspaceView.tsx"); - expect(markup).not.toContain("No matching files."); - }); - - it("shows pointer cursor on activity buttons that switch files and diff", () => { - const queryClient = new QueryClient(); - const markup = renderToStaticMarkup( - - - Diff panel} - chatPanel={
Chat panel
} - onSelectFile={vi.fn()} - onSelectDiffFile={vi.fn()} - onToggleDirectory={vi.fn()} - onCenterModeChange={vi.fn()} - onExitEditorView={vi.fn()} - /> -
-
, - ); - - // Files is the active mode with a visible sidebar, so its button reads as - // a sidebar collapse toggle; Diff stays a plain mode switch. - expect(markup).toContain('aria-label="Hide files sidebar"'); - expect(markup).toContain('aria-label="Diff"'); - expect(markup.match(/cursor-pointer/g)?.length).toBeGreaterThanOrEqual(2); - }); -}); diff --git a/apps/web/src/components/EditorWorkspaceView.tsx b/apps/web/src/components/EditorWorkspaceView.tsx deleted file mode 100644 index 990121bfc..000000000 --- a/apps/web/src/components/EditorWorkspaceView.tsx +++ /dev/null @@ -1,700 +0,0 @@ -// FILE: EditorWorkspaceView.tsx -// Purpose: Read-only editor-style thread surface with file explorer, workspace -// file search, file/diff preview, and chat. -// Layer: Chat route presentation - -import type { ProjectId } from "@t3tools/contracts"; -import type { FileDiffMetadata } from "@pierre/diffs/react"; -import { - type CSSProperties, - type KeyboardEvent as ReactKeyboardEvent, - type PointerEvent as ReactPointerEvent, - type ReactNode, - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from "react"; - -import { - ChangesIcon, - ChatBubbleIcon, - ChevronDownIcon, - DiffIcon, - FoldersIcon, - PanelRightCloseIcon, - SearchIcon, -} from "~/lib/icons"; -import { - useDesktopTopBarTrafficLightGutterClassName, - useDesktopTopBarWindowControlsGutterClassName, -} from "~/hooks/useDesktopTopBarGutter"; -import { - buildFileDiffRenderKey, - resolveFileDiffPath, - splitRepoRelativePath, - summarizeFileDiffStats, -} from "~/lib/diffRendering"; -import { showFileReferenceContextMenu } from "~/lib/fileReferenceContextMenu"; -import type { ChatFileReference } from "~/lib/chatReferences"; -import type { FileCommentSelection } from "~/lib/fileComments"; -import { cn } from "~/lib/utils"; -import { useTheme } from "~/hooks/useTheme"; -import { Skeleton } from "./ui/skeleton"; -import { - ChatHeaderButton, - ChatHeaderIconButton, - CHAT_SURFACE_HEADER_DIVIDER_CLASS_NAME, - CHAT_SURFACE_HEADER_HEIGHT_CLASS, -} from "./chat/chatHeaderControls"; -import { EXPLORER_ROW_PROPS, useExplorerListNavigation } from "./chat/explorerListNavigation"; -import { FileEntryIcon } from "./chat/FileEntryIcon"; -import { fileRowClassName } from "./chat/fileRowStyles"; -import { DiffStat } from "./chat/DiffStatLabel"; -import { PanelStateMessage } from "./chat/PanelStateMessage"; -import { - ExplorerActivityBarButton, - setFileReferenceDragData, - WorkspaceFilesSidebar, - WorkspaceSearchSidebar, -} from "./chat/workspaceExplorer"; -import { ProjectMenuPicker, type ProjectMenuPickerOption } from "./ProjectMenuPicker"; -import { WorkspaceFilePreview } from "./WorkspaceFilePreview"; - -type EditorCenterMode = "file" | "diff"; -type EditorActivityBarItem = EditorCenterMode | "search"; - -const EDITOR_CHAT_PANE_STORAGE_KEY = "chitauri.editor.chatPaneWidth"; -const EDITOR_SIDEBAR_VISIBLE_STORAGE_KEY = "chitauri.editor.sidebarVisible"; -const EDITOR_CHAT_PANE_VISIBLE_STORAGE_KEY = "chitauri.editor.chatPaneVisible"; -const EDITOR_CHAT_PANE_DEFAULT_WIDTH = 384; -const EDITOR_CHAT_PANE_MIN_WIDTH = 320; -const EDITOR_CHAT_PANE_MAX_WIDTH = 600; -const EDITOR_CHAT_PANE_KEYBOARD_STEP = 24; - -interface EditorWorkspaceViewProps { - workspaceRoot: string | null; - projectName: string | null; - currentProjectId?: ProjectId | null; - projectOptions?: ReadonlyArray; - selectedFilePath: string | null; - expandedDirectories: ReadonlySet; - centerMode: EditorCenterMode; - diffFiles: ReadonlyArray; - diffFilesLoading?: boolean; - selectedDiffFilePath: string | null; - diffOptionsControl?: ReactNode; - diffPanel: ReactNode; - chatPanel: ReactNode; - onSelectFile: (path: string) => void; - onSelectDiffFile: (path: string) => void; - onToggleDirectory: (path: string) => void; - onCenterModeChange: (mode: EditorCenterMode) => void; - onExitEditorView: () => void; - onReferenceInChat?: (reference: ChatFileReference) => void; - onAskWhyInChat?: (reference: ChatFileReference) => void; - onCommentInChat?: (comment: FileCommentSelection) => void; - onSelectProject?: (projectId: ProjectId) => void; -} - -function clampEditorChatPaneWidth(width: number): number { - return Math.min( - EDITOR_CHAT_PANE_MAX_WIDTH, - Math.max(EDITOR_CHAT_PANE_MIN_WIDTH, Math.round(width)), - ); -} - -function readStoredEditorChatPaneWidth(): number { - if (typeof window === "undefined") { - return EDITOR_CHAT_PANE_DEFAULT_WIDTH; - } - - try { - const rawValue = window.localStorage.getItem(EDITOR_CHAT_PANE_STORAGE_KEY); - const parsed = rawValue === null ? Number.NaN : Number.parseFloat(rawValue); - return Number.isFinite(parsed) - ? clampEditorChatPaneWidth(parsed) - : EDITOR_CHAT_PANE_DEFAULT_WIDTH; - } catch { - return EDITOR_CHAT_PANE_DEFAULT_WIDTH; - } -} - -function storeEditorChatPaneWidth(width: number): void { - if (typeof window === "undefined") { - return; - } - - try { - window.localStorage.setItem( - EDITOR_CHAT_PANE_STORAGE_KEY, - String(clampEditorChatPaneWidth(width)), - ); - } catch { - // Best-effort preference persistence only. - } -} - -function readStoredEditorVisibility(key: string): boolean { - if (typeof window === "undefined") { - return true; - } - try { - return window.localStorage.getItem(key) !== "false"; - } catch { - return true; - } -} - -function storeEditorVisibility(key: string, visible: boolean): void { - if (typeof window === "undefined") { - return; - } - try { - window.localStorage.setItem(key, String(visible)); - } catch { - // Best-effort preference persistence only. - } -} - -interface EditorChatPaneResizeState { - pointerId: number; - startX: number; - startWidth: number; - pendingWidth: number; - rafId: number | null; - restoreBodyCursor: string; - restoreBodyUserSelect: string; - onPointerMove: (event: PointerEvent) => void; - onPointerEnd: (event: PointerEvent) => void; -} - -function DiffFileRow(props: { - fileDiff: FileDiffMetadata; - selected: boolean; - resolvedTheme: "light" | "dark"; - onSelectFile: (path: string) => void; - onFileContextMenu: (filePath: string, position: { x: number; y: number }) => void; -}) { - const filePath = resolveFileDiffPath(props.fileDiff); - const { dir, name } = splitRepoRelativePath(filePath); - const stat = useMemo(() => summarizeFileDiffStats([props.fileDiff]), [props.fileDiff]); - - return ( - - ); -} - -const DIFF_FILE_SKELETON_ROW_WIDTHS = ["w-10/12", "w-7/12", "w-9/12", "w-6/12", "w-8/12"]; - -function DiffFilesLoadingRows() { - return ( -
- {DIFF_FILE_SKELETON_ROW_WIDTHS.map((width) => ( -
- - - -
- ))} -
- ); -} - -function DiffFilesSidebar(props: { - files: ReadonlyArray; - isLoading: boolean; - selectedFilePath: string | null; - optionsControl?: ReactNode; - onSelectFile: (path: string) => void; - onReferenceInChat: ((reference: ChatFileReference) => void) | undefined; - onAskWhyInChat: ((reference: ChatFileReference) => void) | undefined; -}) { - const { resolvedTheme } = useTheme(); - const { onAskWhyInChat, onReferenceInChat } = props; - const handleListKeyDown = useExplorerListNavigation(); - const totals = useMemo(() => summarizeFileDiffStats(props.files), [props.files]); - const hasDiffStats = totals.additions > 0 || totals.deletions > 0; - const showLoadingRows = props.isLoading && props.files.length === 0; - const handleFileContextMenu = useCallback( - (filePath: string, position: { x: number; y: number }) => { - void showFileReferenceContextMenu({ - path: filePath, - position, - onReferenceInChat, - onAskWhyInChat, - }); - }, - [onAskWhyInChat, onReferenceInChat], - ); - - return ( - - ); -} - -function EditorActivityBar(props: { - centerMode: EditorCenterMode; - searchActive: boolean; - sidebarVisible: boolean; - onSelectItem: (item: EditorActivityBarItem) => void; -}) { - const filesActive = props.sidebarVisible && !props.searchActive && props.centerMode === "file"; - const diffActive = props.sidebarVisible && !props.searchActive && props.centerMode === "diff"; - const searchActive = props.sidebarVisible && props.searchActive; - return ( - - ); -} - -export function EditorWorkspaceView(props: EditorWorkspaceViewProps) { - // The editor header sits flush against the window's left edge whenever the - // global sidebar is collapsed, so it has to clear the macOS traffic lights the - // same way every other chat-surface header does. - const trafficLightGutterClassName = useDesktopTopBarTrafficLightGutterClassName(); - const [chatPaneWidth, setChatPaneWidth] = useState(readStoredEditorChatPaneWidth); - const chatPaneResizeStateRef = useRef(null); - // Both side surfaces can be hidden so the main content takes the full width: - // re-clicking the active activity-bar item collapses the sidebar (VS Code - // style), and the header chat toggle hides the chat pane (kept mounted so - // the chat runtime survives). - const [sidebarVisible, setSidebarVisible] = useState(() => - readStoredEditorVisibility(EDITOR_SIDEBAR_VISIBLE_STORAGE_KEY), - ); - const [chatPaneVisible, setChatPaneVisible] = useState(() => - readStoredEditorVisibility(EDITOR_CHAT_PANE_VISIBLE_STORAGE_KEY), - ); - // The search pane replaces the explorer/diff sidebar without touching the - // center mode, so picking a result simply opens it in the file preview. The - // query lives here so it survives toggling between sidebar panes. - const [searchPaneActive, setSearchPaneActive] = useState(false); - const [searchQuery, setSearchQuery] = useState(""); - const desktopTopBarWindowControlsGutterClassName = - useDesktopTopBarWindowControlsGutterClassName(); - const { centerMode, onCenterModeChange } = props; - const handleActivityBarSelectItem = useCallback( - (item: EditorActivityBarItem) => { - const itemActive = - sidebarVisible && - (item === "search" ? searchPaneActive : !searchPaneActive && centerMode === item); - if (itemActive) { - setSidebarVisible(false); - storeEditorVisibility(EDITOR_SIDEBAR_VISIBLE_STORAGE_KEY, false); - return; - } - if (!sidebarVisible) { - setSidebarVisible(true); - storeEditorVisibility(EDITOR_SIDEBAR_VISIBLE_STORAGE_KEY, true); - } - if (item === "search") { - setSearchPaneActive(true); - return; - } - setSearchPaneActive(false); - onCenterModeChange(item); - }, - [centerMode, onCenterModeChange, searchPaneActive, sidebarVisible], - ); - const toggleChatPaneVisible = useCallback(() => { - setChatPaneVisible((previous) => { - const next = !previous; - storeEditorVisibility(EDITOR_CHAT_PANE_VISIBLE_STORAGE_KEY, next); - return next; - }); - }, []); - - const stopChatPaneResize = useCallback(() => { - const resizeState = chatPaneResizeStateRef.current; - if (!resizeState || typeof window === "undefined") { - return; - } - - if (resizeState.rafId !== null) { - window.cancelAnimationFrame(resizeState.rafId); - resizeState.rafId = null; - } - - window.removeEventListener("pointermove", resizeState.onPointerMove); - window.removeEventListener("pointerup", resizeState.onPointerEnd); - window.removeEventListener("pointercancel", resizeState.onPointerEnd); - document.body.style.cursor = resizeState.restoreBodyCursor; - document.body.style.userSelect = resizeState.restoreBodyUserSelect; - setChatPaneWidth(resizeState.pendingWidth); - storeEditorChatPaneWidth(resizeState.pendingWidth); - chatPaneResizeStateRef.current = null; - }, []); - - useEffect(() => stopChatPaneResize, [stopChatPaneResize]); - - const handleChatPaneResizePointerDown = useCallback( - (event: ReactPointerEvent) => { - if (event.button !== 0 || typeof window === "undefined") { - return; - } - - event.preventDefault(); - event.stopPropagation(); - stopChatPaneResize(); - - const resizeState: EditorChatPaneResizeState = { - pointerId: event.pointerId, - startX: event.clientX, - startWidth: chatPaneWidth, - pendingWidth: chatPaneWidth, - rafId: null, - restoreBodyCursor: document.body.style.cursor, - restoreBodyUserSelect: document.body.style.userSelect, - onPointerMove: () => undefined, - onPointerEnd: () => undefined, - }; - - resizeState.onPointerMove = (moveEvent) => { - if (moveEvent.pointerId !== resizeState.pointerId) { - return; - } - - resizeState.pendingWidth = clampEditorChatPaneWidth( - resizeState.startWidth + resizeState.startX - moveEvent.clientX, - ); - - if (resizeState.rafId !== null) { - return; - } - - resizeState.rafId = window.requestAnimationFrame(() => { - resizeState.rafId = null; - setChatPaneWidth(resizeState.pendingWidth); - }); - }; - - resizeState.onPointerEnd = (endEvent) => { - if (endEvent.pointerId !== resizeState.pointerId) { - return; - } - stopChatPaneResize(); - }; - - chatPaneResizeStateRef.current = resizeState; - document.body.style.cursor = "col-resize"; - document.body.style.userSelect = "none"; - window.addEventListener("pointermove", resizeState.onPointerMove); - window.addEventListener("pointerup", resizeState.onPointerEnd); - window.addEventListener("pointercancel", resizeState.onPointerEnd); - }, - [chatPaneWidth, stopChatPaneResize], - ); - - const handleChatPaneResizeDoubleClick = useCallback(() => { - setChatPaneWidth(EDITOR_CHAT_PANE_DEFAULT_WIDTH); - storeEditorChatPaneWidth(EDITOR_CHAT_PANE_DEFAULT_WIDTH); - }, []); - - const handleChatPaneResizeKeyDown = useCallback( - (event: ReactKeyboardEvent) => { - let nextWidth: number | null = null; - - if (event.key === "ArrowLeft") { - nextWidth = chatPaneWidth + EDITOR_CHAT_PANE_KEYBOARD_STEP; - } else if (event.key === "ArrowRight") { - nextWidth = chatPaneWidth - EDITOR_CHAT_PANE_KEYBOARD_STEP; - } else if (event.key === "Home") { - nextWidth = EDITOR_CHAT_PANE_MIN_WIDTH; - } else if (event.key === "End") { - nextWidth = EDITOR_CHAT_PANE_MAX_WIDTH; - } - - if (nextWidth === null) { - return; - } - - event.preventDefault(); - const clampedWidth = clampEditorChatPaneWidth(nextWidth); - setChatPaneWidth(clampedWidth); - storeEditorChatPaneWidth(clampedWidth); - }, - [chatPaneWidth], - ); - - return ( -
-
-
-
- - {props.projectName ?? "Workspace"} - - - {props.workspaceRoot ?? "No workspace"} - -
- {props.onSelectProject && (props.projectOptions?.length ?? 0) > 0 ? ( - - - - } - /> - ) : null} -
- - - {chatPaneVisible ? "Hide chat panel" : "Show chat panel"} - - - - Chat - -
-
- -
- {!sidebarVisible ? null : searchPaneActive ? ( - - ) : props.centerMode === "diff" ? ( - - ) : ( - - )} -
- {/* Keep the diff panel mounted while browsing files: unmounting it - drops the parsed patch, diff worker pool, and query subscriptions, - which made every Files -> Diff switch a cold multi-second reload. */} -
- {props.diffPanel} -
- {props.centerMode === "file" ? ( -
- -
- ) : null} -
-
-
- {/* Hidden (not unmounted) so the chat runtime and composer focus - state survive toggling the pane. */} - -
-
-
- ); -} - -export default EditorWorkspaceView; diff --git a/apps/web/src/components/EventRouter.browser.tsx b/apps/web/src/components/EventRouter.browser.tsx index f8850c98b..6c14756c3 100644 --- a/apps/web/src/components/EventRouter.browser.tsx +++ b/apps/web/src/components/EventRouter.browser.tsx @@ -254,7 +254,6 @@ const worker = setupWorker( if ( method === WS_METHODS.subscribeServerProviderStatuses || method === WS_METHODS.subscribeServerSettings || - method === WS_METHODS.subscribeTerminalEvents || method === WS_METHODS.subscribeOrchestrationDomainEvents ) { return; @@ -887,7 +886,6 @@ describe("EventRouter scoped orchestration sync", () => { projectId: PROJECT_ID, createdAt: NOW_ISO, runtimeMode: "full-access", - entryPoint: "chat", branch: null, worktreePath: null, envMode: "local", diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 17296b5a1..bf02a2fb1 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -95,7 +95,7 @@ import { invalidateGitQueries, } from "~/lib/gitReactQuery"; import { cn, newCommandId, randomUUID } from "~/lib/utils"; -import { resolvePathLinkTarget } from "~/terminal-links"; +import { resolvePathLinkTarget } from "~/lib/pathLinks"; import { readNativeApi } from "~/nativeApi"; import { createThreadSelector } from "~/storeSelectors"; import { useStore } from "~/store"; diff --git a/apps/web/src/components/KeybindingsToast.browser.tsx b/apps/web/src/components/KeybindingsToast.browser.tsx index c8e4bdd5d..b99f6c72f 100644 --- a/apps/web/src/components/KeybindingsToast.browser.tsx +++ b/apps/web/src/components/KeybindingsToast.browser.tsx @@ -240,7 +240,6 @@ const worker = setupWorker( if ( method === WS_METHODS.subscribeServerProviderStatuses || method === WS_METHODS.subscribeServerSettings || - method === WS_METHODS.subscribeTerminalEvents || method === WS_METHODS.subscribeOrchestrationDomainEvents ) { return; diff --git a/apps/web/src/components/PlanSidebar.tsx b/apps/web/src/components/PlanSidebar.tsx index 4df7e202d..24f296256 100644 --- a/apps/web/src/components/PlanSidebar.tsx +++ b/apps/web/src/components/PlanSidebar.tsx @@ -156,7 +156,7 @@ const PlanSidebar = memo(function PlanSidebar({ -
+
-
+
{ - it("renders the filter input and the nested, compressed changed files", () => { - const markup = renderToStaticMarkup( - , - ); - - expect(markup).toContain('placeholder="Filter files..."'); - // Top-level directory plus the compressed single-child chains. - expect(markup).toContain("apps"); - expect(markup).toContain("server/src"); - expect(markup).toContain("web/src"); - // Leaf file names render inside the expanded tree. - expect(markup).toContain("codex.ts"); - expect(markup).toContain("ChatView.tsx"); - }); - - it("renders a same-named file and directory replacement as distinct rows", () => { - // Deleting `foo` while adding `foo/bar.ts` produces sibling nodes that share - // the path "foo"; the panel must render both without colliding React keys. - const markup = renderToStaticMarkup( - , - ); - - expect(markup).toContain("foo"); - expect(markup).toContain("bar.ts"); - }); - - it("shows a coherent empty state when there are no files", () => { - const markup = renderToStaticMarkup( - , - ); - - expect(markup).toContain("No files in this diff."); - }); - - it("shows a loading state while the diff is loading with no files yet", () => { - const markup = renderToStaticMarkup( - , - ); - - expect(markup).toContain("Loading changed files..."); - expect(markup).not.toContain("No files in this diff."); - }); -}); diff --git a/apps/web/src/components/ReviewFileTreePanel.tsx b/apps/web/src/components/ReviewFileTreePanel.tsx deleted file mode 100644 index 5aa47dc6c..000000000 --- a/apps/web/src/components/ReviewFileTreePanel.tsx +++ /dev/null @@ -1,267 +0,0 @@ -// FILE: ReviewFileTreePanel.tsx -// Purpose: Compact, searchable file-tree side panel for the review/diff panel. -// Renders the changed files of the active diff as a nested, collapsible -// tree and navigates the diff on click. Reuses the diff path helpers, -// file-row chrome, file icons, and disclosure motion shared with the -// editor explorer instead of duplicating them. -// Layer: Diff panel UI - -import type { FileDiffMetadata } from "@pierre/diffs/react"; -import { - forwardRef, - memo, - useCallback, - useMemo, - useState, - type ComponentPropsWithoutRef, - type KeyboardEvent as ReactKeyboardEvent, - type ReactNode, -} from "react"; - -import { buildFileDiffTree, type FileDiffTreeNode } from "~/lib/fileDiffTree"; -import { XIcon } from "~/lib/icons"; -import { cn } from "~/lib/utils"; - -import { filterRenderableFilesForSearch } from "./DiffPanel.logic"; -import { FileEntryIcon } from "./chat/FileEntryIcon"; -import { fileRowClassName, fileRowIndentStyle } from "./chat/fileRowStyles"; -import { PanelStateMessage } from "./chat/PanelStateMessage"; -import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "./ui/collapsible"; -import { DisclosureChevron } from "./ui/DisclosureChevron"; -import { IconButton } from "./ui/icon-button"; -import { SearchInput } from "./ui/search-input"; -import { Skeleton } from "./ui/skeleton"; - -// Forwards its ref and spreads incoming props so directory rows can act as the -// Collapsible trigger (Base UI injects onClick/aria/data + ref onto this element). -const ReviewTreeRow = forwardRef< - HTMLButtonElement, - { - depth: number; - selected: boolean; - leading: ReactNode; - label: string; - labelClassName?: string; - } & ComponentPropsWithoutRef<"button"> ->(function ReviewTreeRow( - { depth, selected, leading, label, labelClassName, className, ...rest }, - ref, -) { - return ( - {label} - - ); -}); - -const ReviewFileTreeNodes = memo(function ReviewFileTreeNodes(props: { - nodes: ReadonlyArray; - depth: number; - selectedFilePath: string | null; - resolvedTheme: "light" | "dark"; - isPathCollapsed: (path: string) => boolean; - onToggleDirectory: (path: string) => void; - onSelectFile: (path: string) => void; -}) { - return ( - <> - {props.nodes.map((node) => { - if (node.kind === "file") { - return ( - // Namespace keys by kind: a diff that replaces a file with a - // same-named directory (delete `foo`, add `foo/bar`) yields sibling - // file and directory nodes sharing the same path. - - } - label={node.name} - onClick={() => props.onSelectFile(node.path)} - /> - ); - } - const open = !props.isPathCollapsed(node.path); - return ( - props.onToggleDirectory(node.path)} - > - } - label={node.name} - labelClassName="font-medium text-foreground/80" - /> - } - /> - - - - - ); - })} - - ); -}); - -const REVIEW_TREE_SKELETON_ROW_WIDTHS = ["w-9/12", "w-6/12", "w-8/12", "w-5/12", "w-7/12"]; - -function ReviewFileTreeLoadingRows() { - return ( -
- {REVIEW_TREE_SKELETON_ROW_WIDTHS.map((width, index) => ( -
- - -
- ))} -
- ); -} - -export const ReviewFileTreePanel = memo(function ReviewFileTreePanel(props: { - files: ReadonlyArray; - selectedFilePath: string | null; - resolvedTheme: "light" | "dark"; - isLoading?: boolean; - className?: string; - onSelectFile: (filePath: string) => void; - onClose?: () => void; -}) { - const [query, setQuery] = useState(""); - // Default fully expanded (the diff file set is known upfront and usually - // small), so we track which directories the user has *collapsed* rather than - // an expanded allow-list. While searching, collapse state is ignored so every - // match stays visible. - const [collapsedPaths, setCollapsedPaths] = useState>(() => new Set()); - - const filteredFiles = useMemo( - () => filterRenderableFilesForSearch(props.files, query), - [props.files, query], - ); - const tree = useMemo(() => buildFileDiffTree(filteredFiles), [filteredFiles]); - - const isSearching = query.trim().length > 0; - const isPathCollapsed = useCallback( - (path: string) => !isSearching && collapsedPaths.has(path), - [collapsedPaths, isSearching], - ); - const handleToggleDirectory = useCallback((path: string) => { - setCollapsedPaths((previous) => { - const next = new Set(previous); - if (next.has(path)) { - next.delete(path); - } else { - next.add(path); - } - return next; - }); - }, []); - const handleSearchKeyDown = useCallback( - (event: ReactKeyboardEvent) => { - if (event.key === "Escape" && query.length > 0) { - event.stopPropagation(); - setQuery(""); - } - }, - [query.length], - ); - - const hasFiles = props.files.length > 0; - const showLoadingRows = (props.isLoading ?? false) && !hasFiles; - - return ( - - ); -}); diff --git a/apps/web/src/components/ShortcutsDialog.tsx b/apps/web/src/components/ShortcutsDialog.tsx index 8c694edf2..092322e83 100644 --- a/apps/web/src/components/ShortcutsDialog.tsx +++ b/apps/web/src/components/ShortcutsDialog.tsx @@ -30,7 +30,7 @@ export default function ShortcutsDialog(props: { keybindings: ResolvedKeybindingsConfig; projectScripts: ReadonlyArray; platform: string; - context: ShortcutSheetContext; + context?: ShortcutSheetContext; }) { const [query, setQuery] = useState(""); const inputRef = useRef(null); @@ -57,7 +57,7 @@ export default function ShortcutsDialog(props: { keybindings: props.keybindings, projectScripts: props.projectScripts, platform: props.platform, - context: props.context, + context: props.context ?? {}, }), [props.keybindings, props.projectScripts, props.platform, props.context], ); diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index fcf5a3500..446db4161 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -22,7 +22,6 @@ import { PlayIcon, SettingsIcon, StopFilledIcon, - TerminalIcon, Trash2, TriangleAlertIcon, WorktreeIcon, @@ -145,8 +144,7 @@ import { useComposerDraftStore } from "../composerDraftStore"; import { resolveThreadEnvironmentPresentation } from "../lib/threadEnvironment"; import { dispatchThreadRename } from "../lib/threadRename"; import { quotePosixShellArgument } from "../lib/shellQuote"; -import { DEFAULT_THREAD_TERMINAL_ID, type SidebarThreadSummary, type Thread } from "../types"; -import { shouldRenderTerminalWorkspace } from "./ChatView.logic"; +import { type SidebarThreadSummary, type Thread } from "../types"; import { CHAT_SURFACE_HEADER_HEIGHT_CLASS } from "./chat/chatHeaderControls"; import { ProviderIcon } from "./ProviderIcon"; import { SidebarLeadingControls } from "./SidebarHeaderNavigationControls"; @@ -163,7 +161,6 @@ import { ThreadPinToggleButton } from "./ThreadPinToggleButton"; import { ThreadRunningSpinner } from "./ThreadRunningSpinner"; import { RenameDialog } from "./RenameDialog"; import { RenameThreadDialog } from "./RenameThreadDialog"; -import { disposeTerminalRuntimeThread } from "./terminal/terminalRuntimeHandle"; import { SidebarSearchPalette, type ImportProviderKind, @@ -173,7 +170,6 @@ import { import { useHandleNewChat } from "../hooks/useHandleNewChat"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; import { useThreadHandoff } from "../hooks/useThreadHandoff"; -import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; import { useProjectRunStore, type ProjectRunState } from "../projectRunStore"; import { selectPrimaryProjectRunCommand, @@ -294,7 +290,6 @@ import { resolveAvailableHandoffTargetProviders, resolveThreadHandoffBadgeLabel, } from "../lib/threadHandoff"; -import { isTerminalFocused } from "../lib/terminalFocus"; import { normalizeSettingsSection } from "../settingsNavigation"; import { sidebarHoverRevealHideClassName, @@ -466,7 +461,6 @@ function threadJumpLabelMapsEqual( function buildThreadJumpLabelMap(input: { keybindings: ResolvedKeybindingsConfig; platform: string; - terminalOpen: boolean; threadJumpCommandByThreadId: ReadonlyMap< ThreadId, NonNullable> @@ -478,10 +472,6 @@ function buildThreadJumpLabelMap(input: { const shortcutLabelOptions = { platform: input.platform, - context: { - terminalFocus: false, - terminalOpen: input.terminalOpen, - }, } as const; const mapping = new Map(); for (const [threadId, command] of input.threadJumpCommandByThreadId) { @@ -611,26 +601,15 @@ function resolveThreadRowMetaChips(input: { return chips; } -function ProviderAvatarWithTerminal({ +function ProviderAvatar({ provider, handoffSourceProvider, handoffTooltip, - terminalStatus, - terminalCount, }: { provider: ProviderKind; handoffSourceProvider?: ProviderKind | null; handoffTooltip?: string | null; - terminalStatus: TerminalStatusIndicator | null; - terminalCount: number; }) { - const showBadge = terminalCount > 1 || terminalStatus !== null; - const badgeTooltip = - terminalCount > 1 - ? `${terminalCount} ${pluralize(terminalCount, "terminal")} open` - : (terminalStatus?.label ?? "Terminal open"); - const badgeColorClass = terminalStatus?.colorClass ?? "text-muted-foreground/55"; - const hasHandoff = Boolean(handoffSourceProvider); const containerClass = hasHandoff ? "relative inline-flex h-3 w-4.5 shrink-0 items-center" @@ -661,37 +640,7 @@ function ProviderAvatarWithTerminal({ avatarNode ); - return ( - - {wrappedAvatar} - {showBadge ? ( - - - {terminalCount > 1 ? ( - - {terminalCount} - - ) : ( - - )} - - } - /> - {badgeTooltip} - - ) : null} -
- ); + return {wrappedAvatar}; } function renderSubagentLabel(input: { @@ -768,12 +717,6 @@ function SidebarSubagentLabel(props: { }); } -interface TerminalStatusIndicator { - label: "Terminal input needed" | "Terminal task completed" | "Terminal process running"; - colorClass: string; - pulse: boolean; -} - interface PrStatusIndicator { label: PrStatePresentation["label"]; colorClass: string; @@ -818,35 +761,6 @@ function toThreadPr( }; } -function terminalStatusFromThreadState(input: { - runningTerminalIds: string[]; - terminalAttentionStatesById: Record; -}): TerminalStatusIndicator | null { - const terminalAttentionStates = Object.values(input.terminalAttentionStatesById ?? {}); - if (terminalAttentionStates.includes("attention")) { - return { - label: "Terminal input needed", - colorClass: "text-info", - pulse: false, - }; - } - if ((input.runningTerminalIds?.length ?? 0) > 0) { - return { - label: "Terminal process running", - colorClass: "text-success", - pulse: true, - }; - } - if (terminalAttentionStates.includes("review")) { - return { - label: "Terminal task completed", - colorClass: "text-success", - pulse: false, - }; - } - return null; -} - function prStatusIndicator(pr: ThreadPr): PrStatusIndicator | null { if (!pr) return null; const presentation = resolvePrStatePresentation(pr); @@ -1067,13 +981,9 @@ export default function Sidebar() { const reorderProjects = useStore((store) => store.reorderProjects); const renameProjectLocally = useStore((store) => store.renameProjectLocally); const clearComposerDraftForThread = useComposerDraftStore((store) => store.clearDraftThread); - const terminalStateByThreadId = useTerminalStateStore((state) => state.terminalStateByThreadId); const projectRunsByProjectId = useProjectRunStore((state) => state.runsByProjectId); const storeUpsertProjectRun = useProjectRunStore((state) => state.upsertRun); const storeRemoveProjectRun = useProjectRunStore((state) => state.removeRun); - const clearTerminalState = useTerminalStateStore((state) => state.clearTerminalState); - const openChatThreadPage = useTerminalStateStore((state) => state.openChatThreadPage); - const openTerminalThreadPage = useTerminalStateStore((state) => state.openTerminalThreadPage); const clearProjectDraftThreads = useComposerDraftStore((store) => store.clearProjectDraftThreads); const clearProjectDraftThreadById = useComposerDraftStore( (store) => store.clearProjectDraftThreadById, @@ -1350,14 +1260,6 @@ export default function Sidebar() { sidebarThreadSummaryById, ], ); - const routeTerminalState = routeThreadId - ? selectThreadTerminalState(terminalStateByThreadId, routeThreadId) - : null; - const terminalOpen = routeTerminalState?.terminalOpen ?? false; - const terminalWorkspaceOpen = shouldRenderTerminalWorkspace({ - presentationMode: routeTerminalState?.presentationMode ?? "drawer", - terminalOpen, - }); const pinnedThreadIds = useMemo( () => derivePinnedThreadIdsForSidebar({ @@ -2301,7 +2203,7 @@ export default function Sidebar() { ); /** - * Delete a single thread: stop session, close terminal, dispatch delete, + * Delete a single thread: stop session, dispatch delete, * clean up drafts/state, and optionally remove orphaned worktree. * Callers handle thread-level confirmation; this still prompts for worktree removal. */ @@ -2358,13 +2260,6 @@ export default function Sidebar() { .catch(() => undefined); } - try { - disposeTerminalRuntimeThread(threadId); - await api.terminal.close({ threadId, deleteHistory: true }); - } catch { - // Terminal may already be closed - } - const allDeletedIds = deletedIds ?? new Set(); const shouldNavigateToFallback = routeThreadId === threadId; const fallbackThreadId = getFallbackThreadIdAfterDelete({ @@ -2388,7 +2283,6 @@ export default function Sidebar() { unpinThread(threadId); clearComposerDraftForThread(threadId); clearProjectDraftThreadById(thread.projectId, thread.id); - clearTerminalState(threadId); if (shouldNavigateToFallback) { if (fallbackThreadId) { @@ -2431,7 +2325,6 @@ export default function Sidebar() { appSettings.sidebarThreadSortOrder, clearComposerDraftForThread, clearProjectDraftThreadById, - clearTerminalState, handleNewChat, navigate, projectById, @@ -2954,9 +2847,6 @@ export default function Sidebar() { { id: "mark-unread", label: "Mark unread" }, ...handoffItems, { id: "copy-path", label: "Copy Path", separatorBefore: true }, - ...(threadWorkspacePath - ? [{ id: "open-path-in-terminal", label: "Open Path in Terminal" }] - : []), { id: "copy-thread-id", label: "Copy Thread ID" }, ...(options?.extraItems ?? []), { id: "archive", label: "Archive", separatorBefore: true }, @@ -3002,91 +2892,6 @@ export default function Sidebar() { copyPathToClipboard(threadWorkspacePath); return; } - if (clicked === "open-path-in-terminal") { - if (!threadWorkspacePath) { - toastManager.add({ - type: "error", - title: "Path unavailable", - description: "This thread does not have a workspace path to open.", - }); - return; - } - await navigate({ to: "/$threadId", params: { threadId } }); - const terminalStore = useTerminalStateStore.getState(); - const currentTerminalState = selectThreadTerminalState( - terminalStore.terminalStateByThreadId, - threadId, - ); - - // Reuse the active terminal when one is already open and idle so that - // repeatedly invoking "Open Path in Terminal" doesn't pile up tabs. - // Only spawn a fresh tab when there is no terminal yet, the active id - // is stale (no longer in the layout), or the active terminal is busy - // running a subprocess. - const candidateBaseTerminalId = - currentTerminalState.activeTerminalId || - currentTerminalState.terminalIds[0] || - DEFAULT_THREAD_TERMINAL_ID; - const baseTerminalAvailable = - currentTerminalState.terminalOpen && - currentTerminalState.terminalIds.includes(candidateBaseTerminalId) && - !currentTerminalState.runningTerminalIds.includes(candidateBaseTerminalId); - const shouldCreateNewTerminal = !baseTerminalAvailable; - const targetTerminalId = shouldCreateNewTerminal - ? `terminal-${randomUUID()}` - : candidateBaseTerminalId; - - const previousTerminalOpen = currentTerminalState.terminalOpen; - const previousPresentationMode = currentTerminalState.presentationMode; - const previousActiveTerminalId = currentTerminalState.activeTerminalId; - - terminalStore.setTerminalPresentationMode(threadId, "drawer"); - terminalStore.setTerminalOpen(threadId, true); - if (shouldCreateNewTerminal) { - terminalStore.newTerminal(threadId, targetTerminalId); - } else { - terminalStore.setActiveTerminal(threadId, targetTerminalId); - } - - const cdCommand = `cd ${quotePosixShellArgument(threadWorkspacePath)}\r`; - try { - if (shouldCreateNewTerminal) { - // A brand new PTY needs an explicit cwd so that the shell's first - // prompt already shows the workspace path. The follow-up `cd` write - // makes the navigation visible in the scrollback (it's effectively - // a no-op since the shell is already there, but it matches the - // user-typed-it experience). - await api.terminal.open({ - threadId, - terminalId: targetTerminalId, - cwd: threadWorkspacePath, - }); - } - // Existing PTYs keep their launch cwd/env on reattach; writing `cd` - // navigates in place without replacing shell state. - await api.terminal.write({ - threadId, - terminalId: targetTerminalId, - data: cdCommand, - }); - } catch (error) { - if (shouldCreateNewTerminal) { - terminalStore.closeTerminal(threadId, targetTerminalId); - } - terminalStore.setTerminalPresentationMode(threadId, previousPresentationMode); - terminalStore.setTerminalOpen(threadId, previousTerminalOpen); - if (previousActiveTerminalId) { - terminalStore.setActiveTerminal(threadId, previousActiveTerminalId); - } - toastManager.add({ - type: "error", - title: "Unable to open terminal", - description: - error instanceof Error ? error.message : "The terminal could not be opened.", - }); - } - return; - } if (clicked === "copy-thread-id") { copyThreadIdToClipboard(threadId); return; @@ -3228,8 +3033,6 @@ export default function Sidebar() { const { activateThreadFromSidebarIntent } = useThreadActivationController({ clearSelection, navigate, - openChatThreadPage, - openTerminalThreadPage, prewarmThreadDetailForIntent, rememberLastThreadRouteNow, routeThreadId, @@ -3237,7 +3040,6 @@ export default function Sidebar() { setOptimisticActiveThreadId, setSelectionAnchor, sidebarThreadSummaryById, - terminalStateByThreadId, }); const handleStartProjectRun = useCallback( @@ -3254,7 +3056,7 @@ export default function Sidebar() { // The dialog lets the user edit the default command before launching, so an // explicit override wins over the resolved default while reusing its cwd. const command = commandOverride?.trim() || runCommand.command; - // Dev servers run from the project root; mirror the env the terminal runner + // Dev servers run from the project root; mirror the env the runner // would otherwise inject so scripts resolve project paths identically. const env = projectScriptRuntimeEnv({ project: { cwd: project.cwd }, @@ -4275,14 +4077,7 @@ export default function Sidebar() { () => [...threadJumpCommandByThreadId.keys()], [threadJumpCommandByThreadId], ); - const getCurrentSidebarShortcutContext = useCallback( - () => ({ - terminalFocus: isTerminalFocused(), - terminalOpen, - terminalWorkspaceOpen, - }), - [terminalOpen, terminalWorkspaceOpen], - ); + const getCurrentSidebarShortcutContext = useCallback(() => ({}), []); const [threadJumpLabelByThreadId, setThreadJumpLabelByThreadId] = useState>(EMPTY_THREAD_JUMP_LABELS); const threadJumpLabelsRef = useRef>(EMPTY_THREAD_JUMP_LABELS); @@ -4384,6 +4179,39 @@ export default function Sidebar() { ); } + /** + * The row's single identity glyph, rendered *before* the title. + * + * One slot, one glyph: a pull-request badge outranks the provider avatar, + * because when a thread has a PR that is the more specific fact about it and + * two icons on one row read as noise. + */ + function renderThreadIdentityGlyph(input: { + prStatus: PrStatusIndicator | null; + provider: ProviderKind; + handoffSourceProvider: ProviderKind | null; + handoffTooltip: string | null; + showProviderAvatar: boolean; + }) { + if (input.prStatus) { + return ( + + ); + } + if (!input.showProviderAvatar) return null; + return ( + + ); + } + function renderThreadRowTrailingCluster(input: { isSubagentThread: boolean; threadJumpLabel: string | null; @@ -4467,21 +4295,12 @@ export default function Sidebar() { } function renderPinnedThreadRow(thread: SidebarThreadSummary) { - const threadTerminalState = selectThreadTerminalState(terminalStateByThreadId, thread.id); - const threadEntryPoint = threadTerminalState.entryPoint; - const terminalStatus = terminalStatusFromThreadState({ - runningTerminalIds: threadTerminalState.runningTerminalIds, - terminalAttentionStatesById: threadTerminalState.terminalAttentionStatesById, - }); - const terminalCount = threadTerminalState.terminalIds.length; const isActive = visualActiveSidebarThreadId === thread.id; const rightMetaChips = resolveThreadRowMetaChips({ thread, includeHandoffBadge: true, handoffShownInAvatar: - threadEntryPoint !== "terminal" && - !isGenericChatThreadTitle(thread.title) && - Boolean(thread.handoff?.sourceProvider), + !isGenericChatThreadTitle(thread.title) && Boolean(thread.handoff?.sourceProvider), }); const threadStatus = resolveThreadStatusForSidebar(thread); const isSubagentThread = Boolean(thread.parentThreadId); @@ -4511,13 +4330,6 @@ export default function Sidebar() { data-thread-hover-anchor={hoverAnchorId} className="group/thread-row relative w-full" > - {leadingPrStatus ? ( - - ) : null}
- {threadEntryPoint === "terminal" ? ( - - ) : showThreadProviderAvatar ? ( - - ) : null} -
-
- - {isSubagentThread ? ( - - ) : ( - thread.title - )} - -
+
+ {renderThreadIdentityGlyph({ + prStatus: leadingPrStatus, + provider: thread.modelSelection.provider ?? thread.session?.provider, + handoffSourceProvider: thread.handoff?.sourceProvider ?? null, + handoffTooltip: handoffBadgeLabel, + showProviderAvatar: showThreadProviderAvatar, + })} + + {isSubagentThread ? ( + + ) : ( + thread.title + )} +
{renderThreadRowTrailingCluster({ @@ -4630,19 +4435,12 @@ export default function Sidebar() { topLevel = false, treeConnected = false, ) { - const threadTerminalState = selectThreadTerminalState(terminalStateByThreadId, thread.id); - const threadEntryPoint = threadTerminalState.entryPoint; const isActive = visualActiveSidebarThreadId === thread.id; const isPinned = pinnedThreadIdSet.has(thread.id); const isSelected = selectedThreadIds.has(thread.id); const isHighlighted = isActive || isSelected; const threadStatus = resolveThreadStatusForSidebar(thread); const prStatus = prStatusIndicator(prByThreadId.get(thread.id) ?? null); - const terminalStatus = terminalStatusFromThreadState({ - runningTerminalIds: threadTerminalState.runningTerminalIds, - terminalAttentionStatesById: threadTerminalState.terminalAttentionStatesById, - }); - const terminalCount = threadTerminalState.terminalIds.length; const secondaryMetaClass = isHighlighted ? "text-foreground/54 dark:text-foreground/64" : "text-muted-foreground/34"; @@ -4650,9 +4448,7 @@ export default function Sidebar() { thread, includeHandoffBadge: true, handoffShownInAvatar: - threadEntryPoint !== "terminal" && - !isGenericChatThreadTitle(thread.title) && - Boolean(thread.handoff?.sourceProvider), + !isGenericChatThreadTitle(thread.title) && Boolean(thread.handoff?.sourceProvider), }); const isSubagentThread = Boolean(thread.parentThreadId); const isProjectTreeThread = !topLevel && !isSubagentThread; @@ -4697,20 +4493,12 @@ export default function Sidebar() { className={cn( "group/thread-row w-full", treeConnected && - "before:absolute before:top-4 before:-left-2 before:h-px before:w-2 before:bg-foreground/[0.08]", + "before:absolute before:top-4 before:-left-2 before:h-px before:w-2 before:bg-foreground/[0.05]", )} data-thread-item > - {leadingPrStatus ? ( - - ) : null} } - data-thread-entry-point={threadEntryPoint} size="sm" isActive={isActive} className={cn( @@ -4718,8 +4506,8 @@ export default function Sidebar() { isActive, isSelected, }), - isProjectTreeThread && "!h-8 !min-h-8 py-0", - leadingPrStatus ? "pl-8" : topLevel && !isSubagentThread ? "pl-2" : null, + isProjectTreeThread && "!h-7 !min-h-7 py-0", + topLevel && !isSubagentThread ? "pl-1.5" : null, isSubagentThread ? "pr-7.5" : resolveThreadRowTrailingReserveClass({ @@ -4791,67 +4579,46 @@ export default function Sidebar() { style={{ backgroundColor: subagentPresentation?.accentColor }} /> - ) : threadEntryPoint === "terminal" ? ( - isProjectTreeThread ? ( - - - - ) : ( - - ) - ) : showThreadProviderAvatar ? ( - isProjectTreeThread ? ( - - - - ) : ( - - ) ) : null}
-
- - {isSubagentThread ? ( - - ) : ( - thread.title - )} - -
+ {isSubagentThread + ? null + : renderThreadIdentityGlyph({ + prStatus: leadingPrStatus, + provider: thread.modelSelection.provider ?? thread.session?.provider, + handoffSourceProvider: thread.handoff?.sourceProvider ?? null, + handoffTooltip: handoffBadgeLabel, + showProviderAvatar: showThreadProviderAvatar, + })} + + {isSubagentThread ? ( + + ) : ( + thread.title + )} +
{canToggleSubagents ? ( @@ -5039,21 +4806,6 @@ export default function Sidebar() { - { - event.preventDefault(); - event.stopPropagation(); - void handleNewThread(project.id, { entryPoint: "terminal" }); - }} - /> - Settings )} {showDesktopUpdateButton ? ( diff --git a/apps/web/src/components/SidebarSearchPalette.logic.test.ts b/apps/web/src/components/SidebarSearchPalette.logic.test.ts index bdc18d7b1..e8389ea72 100644 --- a/apps/web/src/components/SidebarSearchPalette.logic.test.ts +++ b/apps/web/src/components/SidebarSearchPalette.logic.test.ts @@ -3,11 +3,9 @@ import { assert, describe, it } from "vitest"; import { matchSidebarSearchActions, matchSidebarSearchProjects, - matchSidebarSearchThemes, matchSidebarSearchThreads, type SidebarSearchAction, type SidebarSearchProject, - type SidebarSearchTheme, type SidebarSearchThread, } from "./SidebarSearchPalette.logic"; @@ -54,47 +52,6 @@ const projects: SidebarSearchProject[] = [ }, ]; -const themes: SidebarSearchTheme[] = [ - { - id: "theme-mode-system", - type: "mode", - label: "System", - description: "Match your OS appearance setting.", - keywords: ["appearance", "theme", "mode", "os"], - mode: "system", - isActive: true, - }, - { - id: "theme-mode-dark", - type: "mode", - label: "Dark", - description: "Always use the dark theme.", - keywords: ["appearance", "theme", "mode", "night"], - mode: "dark", - isActive: false, - }, - { - id: "theme-codex-dark", - type: "code-theme", - label: "Codex", - description: "Apply to the current dark theme slot.", - keywords: ["appearance", "theme", "dark"], - codeThemeId: "codex", - variant: "dark", - isActive: true, - }, - { - id: "theme-linear-dark", - type: "code-theme", - label: "Linear", - description: "Apply to the current dark theme slot.", - keywords: ["appearance", "theme", "dark"], - codeThemeId: "linear", - variant: "dark", - isActive: false, - }, -]; - const threads: SidebarSearchThread[] = [ { id: "thread-alpha-composer", @@ -166,24 +123,6 @@ describe("SidebarSearchPalette.logic", () => { assert.equal(result[0]?.shortcutLabel, "⇧⌘U"); }); - it("keeps theme entries in source order for an empty query", () => { - const result = matchSidebarSearchThemes(themes, ""); - - assert.deepEqual( - result.map((theme) => theme.id), - ["theme-mode-system", "theme-mode-dark", "theme-codex-dark", "theme-linear-dark"], - ); - }); - - it("matches themes by query relevance", () => { - const result = matchSidebarSearchThemes(themes, "dark"); - - assert.deepEqual( - result.map((theme) => theme.id), - ["theme-mode-dark", "theme-codex-dark", "theme-linear-dark"], - ); - }); - it("matches projects by repo name before cwd fragments", () => { const result = matchSidebarSearchProjects(projects, "alpha"); diff --git a/apps/web/src/components/SidebarSearchPalette.logic.ts b/apps/web/src/components/SidebarSearchPalette.logic.ts index ade6f1585..7635d77be 100644 --- a/apps/web/src/components/SidebarSearchPalette.logic.ts +++ b/apps/web/src/components/SidebarSearchPalette.logic.ts @@ -1,9 +1,8 @@ -// Purpose: Scores sidebar palette results for actions, themes, projects, and chat threads. +// Purpose: Scores sidebar palette results for actions, projects, and chat threads. // Keeps search local and deterministic so the palette can rank title hits above // message-content hits while still surfacing a useful snippet for chat matches. import type { ProviderKind } from "@t3tools/contracts"; import { basenameOfPath } from "../file-icons"; -import type { ThemeMode, ThemeVariant } from "../theme/theme.logic"; export interface SidebarSearchAction { id: string; @@ -13,18 +12,6 @@ export interface SidebarSearchAction { shortcutLabel?: string | null; } -export interface SidebarSearchTheme { - id: string; - type: "mode" | "code-theme"; - label: string; - description: string; - keywords?: readonly string[]; - mode?: ThemeMode; - codeThemeId?: string; - variant?: ThemeVariant; - isActive: boolean; -} - export interface SidebarSearchProject { id: string; name: string; @@ -193,22 +180,6 @@ function scoreAction(action: SidebarSearchAction, query: string): number | null return null; } -function scoreTheme(theme: SidebarSearchTheme, query: string): number | null { - if (!query) return 0; - - const label = normalizeText(theme.label); - const description = normalizeText(theme.description); - const keywords = (theme.keywords ?? []).map(normalizeText); - - if (label === query) return 145; - if (keywords.some((keyword) => keyword === query)) return 135; - if (label.startsWith(query)) return 125; - if (label.includes(query)) return 110; - if (keywords.some((keyword) => keyword.includes(query))) return 95; - if (description.includes(query)) return 75; - return null; -} - function scoreProject(project: SidebarSearchProject, query: string): number | null { if (!query) return null; @@ -250,32 +221,6 @@ export function matchSidebarSearchActions( .map((candidate) => candidate.action); } -export function matchSidebarSearchThemes( - themes: readonly SidebarSearchTheme[], - query: string, -): SidebarSearchTheme[] { - const normalizedQuery = normalizeText(query); - if (!normalizedQuery) { - return [...themes]; - } - - return themes - .map((theme, index) => ({ - theme, - index, - score: scoreTheme(theme, normalizedQuery), - })) - .filter((candidate) => candidate.score !== null) - .toSorted((left, right) => { - if (left.score !== right.score) return (right.score ?? 0) - (left.score ?? 0); - if (left.theme.isActive !== right.theme.isActive) { - return left.theme.isActive ? -1 : 1; - } - return left.index - right.index; - }) - .map((candidate) => candidate.theme); -} - export function matchSidebarSearchProjects( projects: readonly SidebarSearchProject[], query: string, diff --git a/apps/web/src/components/SidebarSearchPalette.tsx b/apps/web/src/components/SidebarSearchPalette.tsx index 87f6e617c..e7e111f35 100644 --- a/apps/web/src/components/SidebarSearchPalette.tsx +++ b/apps/web/src/components/SidebarSearchPalette.tsx @@ -4,15 +4,7 @@ * Keeps the sidebar search UX aligned with the shared command primitives so * keyboard navigation and shortcut labels behave like the rest of the app. */ -import { - CheckIcon, - DeviceLaptopIcon, - MoonIcon, - NewThreadIcon, - SearchIcon, - SettingsIcon, - SunIcon, -} from "~/lib/icons"; +import { CheckIcon, NewThreadIcon, SearchIcon, SettingsIcon } from "~/lib/icons"; import { type FilesystemBrowseResult, type ImportableDesktopThread, @@ -59,7 +51,6 @@ import { matchSidebarSearchProjects, matchSidebarSearchThreads, } from "./SidebarSearchPalette.logic"; -import { useTheme } from "../hooks/useTheme"; import { Command, CommandDialog, @@ -171,110 +162,6 @@ function PaletteIcon(props: { icon: IconComponent }) { ); } -type ThemeCommandItem = { - description: string; - id: string; - isActive: boolean; - label: string; - mode: "system" | "light" | "dark"; -}; - -function queryTokens(query: string): string[] { - return query - .trim() - .toLowerCase() - .split(/\s+/) - .filter((token) => token.length > 0); -} - -function hasTokenEqual(query: string, token: string): boolean { - return queryTokens(query).includes(token); -} - -function createThemeCommandItem( - mode: ThemeCommandItem["mode"], - activeMode: ThemeCommandItem["mode"], -): ThemeCommandItem { - if (mode === "system") { - return { - id: "theme-command:system", - label: "Follow system appearance", - description: "Match your OS appearance setting.", - mode, - isActive: activeMode === mode, - }; - } - - return { - id: `theme-command:${mode}`, - label: `Switch to ${mode} theme`, - description: mode === "light" ? "Always use the light theme." : "Always use the dark theme.", - mode, - isActive: activeMode === mode, - }; -} - -// Treat any token of length >= 2 that is a prefix of `keyword` as a match, -// so typing `th` / `the` already starts surfacing theme actions. -function hasTokenPrefixOf(query: string, keyword: string): boolean { - return queryTokens(query).some((token) => token.length >= 2 && keyword.startsWith(token)); -} - -// Keep the palette quiet by default, then expose focused appearance actions -// once the user is clearly asking about theme modes. -function buildThemeCommandItems(input: { - query: string; - resolvedTheme: "light" | "dark"; - theme: "system" | "light" | "dark"; -}): ThemeCommandItem[] { - const normalizedQuery = input.query.trim().toLowerCase(); - if (!normalizedQuery) { - return []; - } - - if ( - hasTokenEqual(normalizedQuery, "system") || - hasTokenEqual(normalizedQuery, "auto") || - hasTokenEqual(normalizedQuery, "automatic") || - hasTokenEqual(normalizedQuery, "os") - ) { - return [createThemeCommandItem("system", input.theme)]; - } - - if (hasTokenEqual(normalizedQuery, "light")) { - return [ - createThemeCommandItem("light", input.theme), - createThemeCommandItem("system", input.theme), - ]; - } - - if (hasTokenEqual(normalizedQuery, "dark")) { - return [ - createThemeCommandItem("dark", input.theme), - createThemeCommandItem("system", input.theme), - ]; - } - - if ( - hasTokenPrefixOf(normalizedQuery, "theme") || - hasTokenPrefixOf(normalizedQuery, "appearance") - ) { - const nextMode = input.resolvedTheme === "dark" ? "light" : "dark"; - return [ - createThemeCommandItem(nextMode, input.theme), - createThemeCommandItem("system", input.theme), - ]; - } - - return []; -} - -const THEME_MODE_ICONS: Record<"system" | "light" | "dark", IconComponent> = { - system: DeviceLaptopIcon, - light: SunIcon, - dark: MoonIcon, -}; - function ProviderIcon(props: { provider: ProviderKind }) { return (
@@ -350,7 +237,6 @@ function HighlightedText(props: { text: string; query: string; className?: strin } export function SidebarSearchPalette(props: SidebarSearchPaletteProps) { - const { resolvedTheme, setTheme, theme } = useTheme(); const [query, setQuery] = useState(props.initialBrowseQuery ?? ""); const [highlightedItemValue, setHighlightedItemValue] = useState(null); const [importProvider, setImportProvider] = useState( @@ -474,16 +360,6 @@ export function SidebarSearchPalette(props: SidebarSearchPaletteProps) { () => (isBrowsing ? [] : matchSidebarSearchActions(props.actions, query)), [isBrowsing, props.actions, query], ); - const themeCommandItems = useMemo( - () => - buildThemeCommandItems({ - query, - resolvedTheme, - theme, - }), - [query, resolvedTheme, theme], - ); - const showThemeSection = !isBrowsing && query.trim().length > 0 && themeCommandItems.length > 0; const matchedProjects = useMemo( () => (isBrowsing ? [] : matchSidebarSearchProjects(props.projects, query)), [isBrowsing, props.projects, query], @@ -493,10 +369,7 @@ export function SidebarSearchPalette(props: SidebarSearchPaletteProps) { [isBrowsing, props.threads, query], ); const hasSearchResults = - matchedActions.length > 0 || - themeCommandItems.length > 0 || - matchedProjects.length > 0 || - matchedThreads.length > 0; + matchedActions.length > 0 || matchedProjects.length > 0 || matchedThreads.length > 0; const importFieldLabel = importProvider === "codex" ? "Thread ID" : "Session ID"; const importPlaceholder = importProvider === "claudeAgent" @@ -1059,7 +932,7 @@ export function SidebarSearchPalette(props: SidebarSearchPaletteProps) { {!isBrowsing && matchedActions.length > 0 && - (matchedThreads.length > 0 || matchedProjects.length > 0 || showThemeSection) ? ( + (matchedThreads.length > 0 || matchedProjects.length > 0) ? ( ) : null} @@ -1129,9 +1002,7 @@ export function SidebarSearchPalette(props: SidebarSearchPaletteProps) { ) : null} - {!isBrowsing && - matchedThreads.length > 0 && - (matchedProjects.length > 0 || showThemeSection) ? ( + {!isBrowsing && matchedThreads.length > 0 && matchedProjects.length > 0 ? ( ) : null} @@ -1167,46 +1038,6 @@ export function SidebarSearchPalette(props: SidebarSearchPaletteProps) { ) : null} - {showThemeSection && matchedProjects.length > 0 ? : null} - - {showThemeSection ? ( - <> - {themeCommandItems.length > 0 ? ( - - Configure - {themeCommandItems.map((themeCommandItem) => ( - { - event.preventDefault(); - }} - onClick={() => { - if (themeCommandItem.isActive) return; - props.onOpenChange(false); - setTheme(themeCommandItem.mode); - }} - > - - - {themeCommandItem.label} - - - {themeCommandItem.isActive ? ( - - ) : null} - - - ))} - - ) : null} - - ) : null} - {!isBrowsing && !hasSearchResults ? (
@@ -1236,7 +1067,7 @@ export function SidebarSearchPalette(props: SidebarSearchPaletteProps) { ) : ( <> - Jump to threads, projects, actions, or appearance. + Jump to threads, projects, or actions. Enter to open )} diff --git a/apps/web/src/components/TerminalScrollToBottom.tsx b/apps/web/src/components/TerminalScrollToBottom.tsx deleted file mode 100644 index 4cdd1e68e..000000000 --- a/apps/web/src/components/TerminalScrollToBottom.tsx +++ /dev/null @@ -1,77 +0,0 @@ -// FILE: TerminalScrollToBottom.tsx -// Purpose: Shows a floating terminal action when output has scrolled away from the bottom. -// Layer: Terminal presentation component -// Exports: TerminalScrollToBottom - -import type { Terminal } from "@xterm/xterm"; -import { useCallback, useEffect, useRef, useState } from "react"; -import { IconButton } from "~/components/ui/icon-button"; -import { ArrowDownIcon } from "~/lib/icons"; -import { cn } from "~/lib/utils"; - -interface TerminalScrollToBottomProps { - terminal: Terminal | null; -} - -export function TerminalScrollToBottom({ terminal }: TerminalScrollToBottomProps) { - const [isVisible, setIsVisible] = useState(false); - const visibilityRafRef = useRef(null); - - const checkPosition = useCallback(() => { - if (!terminal) return; - const buf = terminal.buffer.active; - const nextVisible = buf.viewportY < buf.baseY; - setIsVisible((current) => (current === nextVisible ? current : nextVisible)); - }, [terminal]); - - const scheduleVisibilityCheck = useCallback(() => { - if (visibilityRafRef.current !== null) { - return; - } - visibilityRafRef.current = window.requestAnimationFrame(() => { - visibilityRafRef.current = null; - checkPosition(); - }); - }, [checkPosition]); - - useEffect(() => { - if (!terminal) { - setIsVisible(false); - return; - } - scheduleVisibilityCheck(); - const d1 = terminal.onWriteParsed(scheduleVisibilityCheck); - const d2 = terminal.onScroll(scheduleVisibilityCheck); - return () => { - if (visibilityRafRef.current !== null) { - window.cancelAnimationFrame(visibilityRafRef.current); - visibilityRafRef.current = null; - } - d1.dispose(); - d2.dispose(); - }; - }, [terminal, scheduleVisibilityCheck]); - - const handleClick = () => terminal?.scrollToBottom(); - - return ( -
- - - -
- ); -} diff --git a/apps/web/src/components/TerminalSearch.tsx b/apps/web/src/components/TerminalSearch.tsx deleted file mode 100644 index 6b73dcd66..000000000 --- a/apps/web/src/components/TerminalSearch.tsx +++ /dev/null @@ -1,195 +0,0 @@ -// FILE: TerminalSearch.tsx -// Purpose: Provides the in-terminal find bar and navigation controls. -// Layer: Terminal presentation component -// Exports: TerminalSearch - -import type { SearchAddon, ISearchOptions } from "@xterm/addon-search"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { IconButton } from "~/components/ui/icon-button"; -import { OVERLAY_SURFACE_CLASS_NAME } from "~/components/ui/surface"; -import { ChevronDownIcon, ChevronUpIcon, XIcon } from "~/lib/icons"; -import { cn } from "~/lib/utils"; - -interface TerminalSearchProps { - searchAddon: SearchAddon | null; - isOpen: boolean; - onClose: () => void; -} - -const SEARCH_DECORATIONS = { - matchBackground: "#515c6a", - matchBorder: "#74879f", - matchOverviewRuler: "#d186167e", - activeMatchBackground: "#515c6a", - activeMatchBorder: "#ffd33d", - activeMatchColorOverviewRuler: "#ffd33d", -} satisfies NonNullable; -const SEARCH_DEBOUNCE_MS = 90; - -export function TerminalSearch({ searchAddon, isOpen, onClose }: TerminalSearchProps) { - const inputRef = useRef(null); - const searchTimerRef = useRef(null); - const [query, setQuery] = useState(""); - const [hasResults, setHasResults] = useState(null); - const [caseSensitive, setCaseSensitive] = useState(false); - - const searchOptions = useMemo( - (): ISearchOptions => ({ - caseSensitive, - regex: false, - decorations: SEARCH_DECORATIONS as NonNullable, - }), - [caseSensitive], - ); - - useEffect(() => { - if (isOpen && inputRef.current) { - inputRef.current.focus(); - inputRef.current.select(); - } - }, [isOpen]); - - useEffect(() => { - if (!isOpen && searchAddon) { - searchAddon.clearDecorations(); - } - }, [isOpen, searchAddon]); - - const handleSearch = useCallback( - (direction: "next" | "previous") => { - if (!searchAddon || !query) return; - if (searchTimerRef.current !== null) { - window.clearTimeout(searchTimerRef.current); - searchTimerRef.current = null; - } - const found = - direction === "next" - ? searchAddon.findNext(query, searchOptions) - : searchAddon.findPrevious(query, searchOptions); - setHasResults(found); - }, - [searchAddon, query, searchOptions], - ); - - const clearSearchTimer = useCallback(() => { - if (searchTimerRef.current === null) return; - window.clearTimeout(searchTimerRef.current); - searchTimerRef.current = null; - }, []); - - const scheduleSearch = useCallback( - (nextQuery: string) => { - clearSearchTimer(); - if (!searchAddon || !nextQuery) { - setHasResults(null); - searchAddon?.clearDecorations(); - return; - } - - searchTimerRef.current = window.setTimeout(() => { - searchTimerRef.current = null; - setHasResults(searchAddon.findNext(nextQuery, searchOptions)); - }, SEARCH_DEBOUNCE_MS); - }, - [clearSearchTimer, searchAddon, searchOptions], - ); - - const handleInputChange = (e: React.ChangeEvent) => { - const newQuery = e.target.value; - setQuery(newQuery); - scheduleSearch(newQuery); - }; - - // Re-run search when case sensitivity or search addon changes - // (but not on query change — handleInputChange handles that). - const prevCaseSensitiveRef = useRef(caseSensitive); - const prevSearchAddonRef = useRef(searchAddon); - useEffect(() => { - const caseSensitivityChanged = prevCaseSensitiveRef.current !== caseSensitive; - const searchAddonChanged = prevSearchAddonRef.current !== searchAddon; - if (!caseSensitivityChanged && !searchAddonChanged) return; - - prevCaseSensitiveRef.current = caseSensitive; - prevSearchAddonRef.current = searchAddon; - if (searchAddon && query) { - scheduleSearch(query); - } - }, [searchAddon, query, scheduleSearch, caseSensitive]); - - useEffect(() => () => clearSearchTimer(), [clearSearchTimer]); - - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === "Escape") { - e.preventDefault(); - onClose(); - } else if (e.key === "Enter") { - e.preventDefault(); - handleSearch(e.shiftKey ? "previous" : "next"); - } - }; - - const handleClose = () => { - setQuery(""); - setHasResults(null); - onClose(); - }; - - if (!isOpen) return null; - - return ( -
- - {hasResults === false && query && ( - No results - )} -
- setCaseSensitive((v) => !v)} - label="Match case" - className={cn( - "size-6 rounded-sm border-transparent bg-transparent shadow-none sm:size-6", - caseSensitive - ? "bg-primary/20 text-foreground" - : "text-muted-foreground hover:bg-muted-foreground/20 hover:text-foreground", - )} - > - Aa - - handleSearch("previous")} - className="size-6 rounded-sm border-transparent bg-transparent text-muted-foreground shadow-none hover:bg-muted-foreground/20 hover:text-foreground sm:size-6" - label="Previous match (Shift+Enter)" - > - - - handleSearch("next")} - className="size-6 rounded-sm border-transparent bg-transparent text-muted-foreground shadow-none hover:bg-muted-foreground/20 hover:text-foreground sm:size-6" - label="Next match (Enter)" - > - - - - - -
-
- ); -} diff --git a/apps/web/src/components/TerminalWorkspaceTabs.test.tsx b/apps/web/src/components/TerminalWorkspaceTabs.test.tsx deleted file mode 100644 index f2fa2953e..000000000 --- a/apps/web/src/components/TerminalWorkspaceTabs.test.tsx +++ /dev/null @@ -1,42 +0,0 @@ -// FILE: TerminalWorkspaceTabs.test.tsx -// Purpose: Guards the workspace-level terminal/chat tab visibility rules. -// Layer: Component rendering tests -// Depends on: TerminalWorkspaceTabs and React server rendering. - -import { renderToStaticMarkup } from "react-dom/server"; -import { describe, expect, it, vi } from "vitest"; - -import TerminalWorkspaceTabs from "./TerminalWorkspaceTabs"; - -describe("TerminalWorkspaceTabs", () => { - it("hides the workspace switcher in terminal-only mode", () => { - const markup = renderToStaticMarkup( - , - ); - - expect(markup).toBe(""); - }); - - it("shows the chat switcher when the workspace still includes chat", () => { - const markup = renderToStaticMarkup( - , - ); - - expect(markup).toContain("Terminal"); - expect(markup).toContain("Chat"); - }); -}); diff --git a/apps/web/src/components/TerminalWorkspaceTabs.tsx b/apps/web/src/components/TerminalWorkspaceTabs.tsx deleted file mode 100644 index 90a581be2..000000000 --- a/apps/web/src/components/TerminalWorkspaceTabs.tsx +++ /dev/null @@ -1,82 +0,0 @@ -// FILE: TerminalWorkspaceTabs.tsx -// Purpose: Renders the top-level workspace switcher between terminal and chat surfaces. -// Layer: Chat workspace chrome -// Depends on: terminal workspace store layout state and shared className helpers. -// -// Note: the two raw - -
-
- ); -} diff --git a/apps/web/src/components/ThreadTerminalDrawer.test.ts b/apps/web/src/components/ThreadTerminalDrawer.test.ts deleted file mode 100644 index 1e998837c..000000000 --- a/apps/web/src/components/ThreadTerminalDrawer.test.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { - resolveTerminalSelectionActionPosition, - shouldHandleTerminalSelectionMouseUp, - terminalSelectionActionDelayForClickCount, -} from "./terminal/terminalSelectionActions"; - -describe("resolveTerminalSelectionActionPosition", () => { - it("prefers the selection rect over the last pointer position", () => { - expect( - resolveTerminalSelectionActionPosition({ - bounds: { left: 100, top: 50, width: 500, height: 220 }, - selectionRect: { right: 260, bottom: 140 }, - pointer: { x: 520, y: 200 }, - viewport: { width: 1024, height: 768 }, - }), - ).toEqual({ - x: 260, - y: 144, - }); - }); - - it("falls back to the pointer position when no selection rect is available", () => { - expect( - resolveTerminalSelectionActionPosition({ - bounds: { left: 100, top: 50, width: 500, height: 220 }, - selectionRect: null, - pointer: { x: 180, y: 130 }, - viewport: { width: 1024, height: 768 }, - }), - ).toEqual({ - x: 180, - y: 130, - }); - }); - - it("clamps the pointer fallback into the terminal drawer bounds", () => { - expect( - resolveTerminalSelectionActionPosition({ - bounds: { left: 100, top: 50, width: 500, height: 220 }, - selectionRect: null, - pointer: { x: 720, y: 340 }, - viewport: { width: 1024, height: 768 }, - }), - ).toEqual({ - x: 600, - y: 270, - }); - - expect( - resolveTerminalSelectionActionPosition({ - bounds: { left: 100, top: 50, width: 500, height: 220 }, - selectionRect: null, - pointer: { x: 40, y: 20 }, - viewport: { width: 1024, height: 768 }, - }), - ).toEqual({ - x: 100, - y: 50, - }); - }); - - it("delays multi-click selection actions so triple-click selection can complete", () => { - expect(terminalSelectionActionDelayForClickCount(1)).toBe(0); - expect(terminalSelectionActionDelayForClickCount(2)).toBe(260); - expect(terminalSelectionActionDelayForClickCount(3)).toBe(260); - }); - - it("only handles mouseup when the selection gesture started in the terminal", () => { - expect(shouldHandleTerminalSelectionMouseUp(true, 0)).toBe(true); - expect(shouldHandleTerminalSelectionMouseUp(false, 0)).toBe(false); - expect(shouldHandleTerminalSelectionMouseUp(true, 1)).toBe(false); - }); -}); diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx deleted file mode 100644 index 0a7694e16..000000000 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ /dev/null @@ -1,779 +0,0 @@ -// FILE: ThreadTerminalDrawer.tsx -// Purpose: Hosts the terminal drawer/workspace chrome and each xterm viewport for a thread. -// Layer: Chat terminal workspace UI -// Depends on: xterm addons, native terminal APIs, and terminal workspace state from ChatView. - -import "@xterm/xterm/css/xterm.css"; -import { SearchAddon } from "@xterm/addon-search"; -import { - Plus, - SquareSplitHorizontal, - SquareSplitVertical, - Trash2, - TriangleAlertIcon, -} from "~/lib/icons"; -import { type ThreadId } from "@t3tools/contracts"; -import { type TerminalActivityState, type TerminalCliKind } from "@t3tools/shared/terminalThreads"; -import { Terminal } from "@xterm/xterm"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { type TerminalContextSelection } from "~/lib/terminalContext"; -import { readNativeApi } from "~/nativeApi"; -import { - MAX_TERMINALS_PER_GROUP, - type ThreadTerminalGroup, - type ThreadTerminalPresentationMode, -} from "../types"; -import { cn } from "~/lib/utils"; -import { Spinner } from "./ui/spinner"; -import { - type TerminalChromeActionItem, - TerminalSidebar, - TerminalWorkspaceTabBar, -} from "./terminal/TerminalChrome"; -import { resolveThreadTerminalLayout } from "./terminal/TerminalLayout"; -import { terminalDrawerShellClassName } from "./terminal/terminalDrawerShell"; -import { - resolveTerminalSelectionActionPosition, - shouldHandleTerminalSelectionMouseUp, - terminalSelectionActionDelayForClickCount, -} from "./terminal/terminalSelectionActions"; -import { - buildTerminalRuntimeKey, - terminalRuntimeRegistry, -} from "./terminal/terminalRuntimeRegistry"; -import { isTerminalRuntimeThreadDisposed } from "./terminal/terminalRuntimeHandle"; -import type { - TerminalRuntimeConfig, - TerminalRuntimeStatus, - TerminalRuntimeViewState, -} from "./terminal/terminalRuntimeTypes"; -import TerminalViewportPane from "./terminal/TerminalViewportPane"; -import { useTerminalDrawerHeight } from "./terminal/useTerminalDrawerHeight"; -import { TerminalSearch } from "./TerminalSearch"; -import { TerminalScrollToBottom } from "./TerminalScrollToBottom"; - -function serializeRuntimeEnv(runtimeEnv: Record | undefined): string { - if (!runtimeEnv) return ""; - const entries = Object.entries(runtimeEnv); - if (entries.length === 0) return ""; - entries.sort(([left], [right]) => left.localeCompare(right)); - return JSON.stringify(entries); -} - -function runtimeEnvFromSerialized( - serializedRuntimeEnv: string, -): Record | undefined { - if (!serializedRuntimeEnv) return undefined; - const entries = JSON.parse(serializedRuntimeEnv) as Array<[string, string]>; - return Object.fromEntries(entries); -} - -function getTerminalSelectionRect(mountElement: HTMLElement): DOMRect | null { - const selection = window.getSelection(); - if (!selection || selection.rangeCount === 0 || selection.isCollapsed) { - return null; - } - - const range = selection.getRangeAt(0); - const commonAncestor = range.commonAncestorContainer; - const selectionRoot = - commonAncestor instanceof Element ? commonAncestor : commonAncestor.parentElement; - if (!(selectionRoot instanceof Element) || !mountElement.contains(selectionRoot)) { - return null; - } - - const rects = Array.from(range.getClientRects()).filter( - (rect) => rect.width > 0 || rect.height > 0, - ); - if (rects.length > 0) { - return rects[rects.length - 1] ?? null; - } - - const boundingRect = range.getBoundingClientRect(); - return boundingRect.width > 0 || boundingRect.height > 0 ? boundingRect : null; -} - -function TerminalRuntimeStatusOverlay({ status }: { status: TerminalRuntimeStatus }) { - if (status !== "error" && status !== "connecting") return null; - - return ( -
- {status === "error" ? ( - <> - - Error - - ) : ( - <> - - Connecting… - - )} -
- ); -} - -interface TerminalViewportProps { - threadId: ThreadId; - terminalId: string; - terminalLabel: string; - terminalCliKind?: TerminalCliKind | null; - cwd: string; - runtimeEnv?: Record; - onSessionExited: () => void; - onTerminalMetadataChange: ( - terminalId: string, - metadata: { cliKind: TerminalCliKind | null; label: string }, - ) => void; - onTerminalActivityChange: ( - terminalId: string, - activity: { hasRunningSubprocess: boolean; agentState: TerminalActivityState | null }, - ) => void; - onAddTerminalContext: (selection: TerminalContextSelection) => void; - focusRequestId: number; - autoFocus: boolean; - isVisible: boolean; -} - -function TerminalViewport({ - threadId, - terminalId, - terminalLabel, - terminalCliKind = null, - cwd, - runtimeEnv, - onSessionExited, - onTerminalMetadataChange, - onTerminalActivityChange, - onAddTerminalContext, - focusRequestId, - autoFocus, - isVisible, -}: TerminalViewportProps) { - const containerRef = useRef(null); - const terminalRef = useRef(null); - const onAddTerminalContextRef = useRef(onAddTerminalContext); - const terminalLabelRef = useRef(terminalLabel); - const selectionPointerRef = useRef<{ x: number; y: number } | null>(null); - const selectionGestureActiveRef = useRef(false); - const selectionActionRequestIdRef = useRef(0); - const selectionActionOpenRef = useRef(false); - const selectionActionTimerRef = useRef(null); - const [searchOpen, setSearchOpen] = useState(false); - const [terminalInstance, setTerminalInstance] = useState(null); - const [searchAddonInstance, setSearchAddonInstance] = useState(null); - const [runtimeStatus, setRuntimeStatus] = useState("connecting"); - const runtimeStatusMountedRef = useRef(false); - const trimmedCwd = useMemo(() => cwd.trim(), [cwd]); - const runtimeCwdReady = trimmedCwd.length > 0; - const runtimeKey = useMemo( - () => buildTerminalRuntimeKey(threadId, terminalId), - [terminalId, threadId], - ); - const runtimeEnvSerialized = useMemo(() => serializeRuntimeEnv(runtimeEnv), [runtimeEnv]); - const runtimeEnvPayload = useMemo( - () => runtimeEnvFromSerialized(runtimeEnvSerialized), - [runtimeEnvSerialized], - ); - const runtimeConfig = useMemo( - () => ({ - runtimeKey, - threadId, - terminalId, - terminalLabel, - terminalCliKind, - cwd, - ...(runtimeEnvPayload ? { runtimeEnv: runtimeEnvPayload } : {}), - callbacks: { - onSessionExited, - onTerminalMetadataChange, - onTerminalActivityChange, - onTerminalRuntimeStatusChange: (changedTerminalId, status) => { - if (changedTerminalId === terminalId && runtimeStatusMountedRef.current) { - setRuntimeStatus(status); - } - }, - }, - }), - [ - cwd, - onSessionExited, - onTerminalActivityChange, - onTerminalMetadataChange, - runtimeEnvPayload, - runtimeKey, - terminalCliKind, - terminalId, - terminalLabel, - threadId, - ], - ); - const runtimeViewState = useMemo( - () => ({ autoFocus, isVisible }), - [autoFocus, isVisible], - ); - const runtimeConfigRef = useRef(runtimeConfig); - const runtimeViewStateRef = useRef(runtimeViewState); - - useEffect(() => { - onAddTerminalContextRef.current = onAddTerminalContext; - }, [onAddTerminalContext]); - - useEffect(() => { - runtimeStatusMountedRef.current = true; - return () => { - runtimeStatusMountedRef.current = false; - }; - }, []); - - useEffect(() => { - runtimeConfigRef.current = runtimeConfig; - }, [runtimeConfig]); - - useEffect(() => { - runtimeViewStateRef.current = runtimeViewState; - }, [runtimeViewState]); - - useEffect(() => { - terminalLabelRef.current = terminalLabel; - }, [terminalLabel]); - - useEffect(() => { - const mount = containerRef.current; - // This module is lazily loaded, so the thread can be deleted while the chunk - // is still in flight. Attaching then would open a PTY for a thread the - // server has already torn down, with nothing left to reap it. - if (!mount || !runtimeCwdReady || isTerminalRuntimeThreadDisposed(threadId)) { - terminalRef.current = null; - setTerminalInstance(null); - setSearchAddonInstance(null); - setRuntimeStatus("connecting"); - return; - } - const attachedRuntime = terminalRuntimeRegistry.attach( - runtimeConfigRef.current, - runtimeViewStateRef.current, - mount, - ); - - terminalRef.current = attachedRuntime.terminal; - setTerminalInstance(attachedRuntime.terminal); - setSearchAddonInstance(attachedRuntime.searchAddon); - setRuntimeStatus(attachedRuntime.runtimeStatus); - - return () => { - if (selectionActionTimerRef.current !== null) { - window.clearTimeout(selectionActionTimerRef.current); - selectionActionTimerRef.current = null; - } - selectionActionOpenRef.current = false; - terminalRuntimeRegistry.detach(runtimeKey); - terminalRef.current = null; - setTerminalInstance(null); - setSearchAddonInstance(null); - }; - }, [runtimeCwdReady, runtimeKey]); - - useEffect(() => { - if (!runtimeCwdReady) return; - terminalRuntimeRegistry.syncConfig(runtimeKey, runtimeConfig); - }, [runtimeConfig, runtimeCwdReady, runtimeKey]); - - useEffect(() => { - if (!runtimeCwdReady) return; - terminalRuntimeRegistry.setViewState(runtimeKey, runtimeViewState); - }, [runtimeCwdReady, runtimeKey, runtimeViewState]); - - useEffect(() => { - if (!autoFocus || !runtimeCwdReady) return; - terminalRuntimeRegistry.focus(runtimeKey); - }, [autoFocus, focusRequestId, runtimeCwdReady, runtimeKey]); - - useEffect(() => { - const mount = containerRef.current; - if (!mount) return; - - const handleKeyDown = (event: KeyboardEvent) => { - if ( - event.key.toLowerCase() === "f" && - (event.metaKey || event.ctrlKey) && - !event.altKey && - !event.shiftKey - ) { - event.preventDefault(); - event.stopPropagation(); - setSearchOpen(true); - } - }; - - mount.addEventListener("keydown", handleKeyDown, true); - return () => { - mount.removeEventListener("keydown", handleKeyDown, true); - }; - }, []); - - const clearSelectionAction = useCallback(() => { - selectionActionRequestIdRef.current += 1; - if (selectionActionTimerRef.current !== null) { - window.clearTimeout(selectionActionTimerRef.current); - selectionActionTimerRef.current = null; - } - }, []); - - const readSelectionAction = useCallback((): { - position: { x: number; y: number }; - selection: TerminalContextSelection; - } | null => { - const activeTerminal = terminalRef.current; - const mountElement = containerRef.current; - if (!activeTerminal || !mountElement || !activeTerminal.hasSelection()) { - return null; - } - const selectionText = activeTerminal.getSelection(); - const selectionPosition = activeTerminal.getSelectionPosition(); - const normalizedText = selectionText.replace(/\r\n/g, "\n").replace(/^\n+|\n+$/g, ""); - if (!selectionPosition || normalizedText.length === 0) { - return null; - } - const lineStart = selectionPosition.start.y + 1; - const lineCount = normalizedText.split("\n").length; - const lineEnd = Math.max(lineStart, lineStart + lineCount - 1); - const bounds = mountElement.getBoundingClientRect(); - const selectionRect = getTerminalSelectionRect(mountElement); - const position = resolveTerminalSelectionActionPosition({ - bounds, - selectionRect: - selectionRect === null - ? null - : { right: selectionRect.right, bottom: selectionRect.bottom }, - pointer: selectionPointerRef.current, - }); - return { - position, - selection: { - terminalId, - terminalLabel: terminalLabelRef.current, - lineStart, - lineEnd, - text: normalizedText, - }, - }; - }, [terminalId]); - - const showSelectionAction = useCallback(async () => { - if (selectionActionOpenRef.current) { - return; - } - const nextAction = readSelectionAction(); - if (!nextAction) { - clearSelectionAction(); - return; - } - const api = readNativeApi(); - if (!api) return; - const requestId = ++selectionActionRequestIdRef.current; - selectionActionOpenRef.current = true; - try { - const clicked = await api.contextMenu.show( - [{ id: "add-to-chat", label: "Add to chat" }], - nextAction.position, - ); - if (requestId !== selectionActionRequestIdRef.current || clicked !== "add-to-chat") { - return; - } - onAddTerminalContextRef.current(nextAction.selection); - terminalRef.current?.clearSelection(); - terminalRuntimeRegistry.focus(runtimeKey); - } finally { - selectionActionOpenRef.current = false; - } - }, [clearSelectionAction, readSelectionAction, runtimeKey]); - - useEffect(() => { - const terminal = terminalInstance; - const mount = containerRef.current; - if (!terminal || !mount) return; - - const selectionDisposable = terminal.onSelectionChange(() => { - if (terminal.hasSelection()) { - return; - } - clearSelectionAction(); - }); - - const handleMouseUp = (event: MouseEvent) => { - const shouldHandle = shouldHandleTerminalSelectionMouseUp( - selectionGestureActiveRef.current, - event.button, - ); - selectionGestureActiveRef.current = false; - if (!shouldHandle) { - return; - } - selectionPointerRef.current = { x: event.clientX, y: event.clientY }; - const delay = terminalSelectionActionDelayForClickCount(event.detail); - selectionActionTimerRef.current = window.setTimeout(() => { - selectionActionTimerRef.current = null; - window.requestAnimationFrame(() => { - void showSelectionAction(); - }); - }, delay); - }; - - const handlePointerDown = (event: PointerEvent) => { - clearSelectionAction(); - selectionGestureActiveRef.current = event.button === 0; - }; - - window.addEventListener("mouseup", handleMouseUp); - mount.addEventListener("pointerdown", handlePointerDown); - return () => { - selectionDisposable.dispose(); - window.removeEventListener("mouseup", handleMouseUp); - mount.removeEventListener("pointerdown", handlePointerDown); - clearSelectionAction(); - selectionGestureActiveRef.current = false; - }; - }, [clearSelectionAction, showSelectionAction, terminalInstance]); - - return ( -
-
- { - setSearchOpen(false); - terminalRuntimeRegistry.focus(runtimeKey); - }} - /> - - -
-
-
- ); -} - -export interface ThreadTerminalDrawerProps { - threadId: ThreadId; - cwd: string; - runtimeEnv?: Record; - height: number; - presentationMode: ThreadTerminalPresentationMode; - isVisible?: boolean; - terminalIds: string[]; - terminalLabelsById: Record; - terminalTitleOverridesById: Record; - terminalCliKindsById: Record; - terminalAttentionStatesById: Record; - runningTerminalIds: string[]; - activeTerminalId: string; - terminalGroups: ThreadTerminalGroup[]; - activeTerminalGroupId: string; - focusRequestId: number; - onSplitTerminal: () => void; - onSplitTerminalDown: () => void; - onNewTerminal: () => void; - onNewTerminalTab: (terminalId: string) => void; - onMoveTerminalToGroup: (terminalId: string) => void; - splitShortcutLabel?: string | undefined; - splitDownShortcutLabel?: string | undefined; - newShortcutLabel?: string | undefined; - closeShortcutLabel?: string | undefined; - workspaceCloseShortcutLabel?: string | undefined; - onActiveTerminalChange: (terminalId: string) => void; - onCloseTerminal: (terminalId: string) => void; - onCloseTerminalGroup: (groupId: string) => void; - onHeightChange: (height: number) => void; - onResizeTerminalSplit: (groupId: string, splitId: string, weights: number[]) => void; - onTerminalMetadataChange: ( - terminalId: string, - metadata: { cliKind: TerminalCliKind | null; label: string }, - ) => void; - onTerminalActivityChange: ( - terminalId: string, - activity: { hasRunningSubprocess: boolean; agentState: TerminalActivityState | null }, - ) => void; - onAddTerminalContext: (selection: TerminalContextSelection) => void; - onTogglePresentationMode?: (() => void) | undefined; - onTogglePanel?: (() => void) | undefined; - isPanelOpen?: boolean | undefined; -} - -export default function ThreadTerminalDrawer({ - threadId, - cwd, - runtimeEnv, - height, - presentationMode, - isVisible = true, - terminalIds, - terminalLabelsById, - terminalTitleOverridesById, - terminalCliKindsById, - terminalAttentionStatesById, - runningTerminalIds, - activeTerminalId, - terminalGroups, - activeTerminalGroupId, - focusRequestId, - onSplitTerminal, - onSplitTerminalDown, - onNewTerminal, - onNewTerminalTab, - onMoveTerminalToGroup, - splitShortcutLabel, - splitDownShortcutLabel, - newShortcutLabel, - closeShortcutLabel, - workspaceCloseShortcutLabel, - onActiveTerminalChange, - onCloseTerminal, - onCloseTerminalGroup, - onHeightChange, - onResizeTerminalSplit, - onTerminalMetadataChange, - onTerminalActivityChange, - onAddTerminalContext, - onTogglePresentationMode, - onTogglePanel, - isPanelOpen, -}: ThreadTerminalDrawerProps) { - const isWorkspaceMode = presentationMode === "workspace"; - const previousRuntimeKeysRef = useRef>(new Set()); - const { drawerHeight, handleResizePointerDown, handleResizePointerMove, handleResizePointerEnd } = - useTerminalDrawerHeight({ - height, - onHeightChange, - resetKey: threadId, - }); - - const { - normalizedTerminalIds, - resolvedActiveTerminalId, - resolvedActiveGroupId, - resolvedTerminalGroups, - activeGroupLayout, - hasTerminalSidebar, - showGroupHeaders, - hasReachedSplitLimit, - terminalVisualIdentityById, - } = useMemo( - () => - resolveThreadTerminalLayout({ - activeTerminalGroupId, - activeTerminalId, - runningTerminalIds, - terminalAttentionStatesById, - terminalCliKindsById, - terminalGroups, - terminalIds, - terminalLabelsById, - terminalTitleOverridesById, - }), - [ - activeTerminalGroupId, - activeTerminalId, - runningTerminalIds, - terminalAttentionStatesById, - terminalCliKindsById, - terminalGroups, - terminalIds, - terminalLabelsById, - terminalTitleOverridesById, - ], - ); - - useEffect(() => { - const nextRuntimeKeySet = new Set( - normalizedTerminalIds.map((terminalId) => buildTerminalRuntimeKey(threadId, terminalId)), - ); - for (const previousRuntimeKey of previousRuntimeKeysRef.current) { - if (nextRuntimeKeySet.has(previousRuntimeKey)) { - continue; - } - terminalRuntimeRegistry.dispose(previousRuntimeKey); - } - previousRuntimeKeysRef.current = nextRuntimeKeySet; - }, [normalizedTerminalIds, threadId]); - - const splitTerminalActionLabel = hasReachedSplitLimit - ? `Split Terminal (max ${MAX_TERMINALS_PER_GROUP} per group)` - : splitShortcutLabel - ? `Split Right (${splitShortcutLabel})` - : "Split Right"; - const splitTerminalDownActionLabel = hasReachedSplitLimit - ? `Split Down (max ${MAX_TERMINALS_PER_GROUP} per group)` - : splitDownShortcutLabel - ? `Split Down (${splitDownShortcutLabel})` - : "Split Down"; - const newTerminalActionLabel = newShortcutLabel - ? `New Terminal (${newShortcutLabel})` - : "New Terminal"; - const resolvedCloseShortcutLabel = isWorkspaceMode - ? (workspaceCloseShortcutLabel ?? closeShortcutLabel) - : closeShortcutLabel; - const closeTerminalActionLabel = resolvedCloseShortcutLabel - ? `Close Terminal (${resolvedCloseShortcutLabel})` - : "Close Terminal"; - const onSplitTerminalAction = useCallback(() => { - if (hasReachedSplitLimit) return; - onSplitTerminal(); - }, [hasReachedSplitLimit, onSplitTerminal]); - const onSplitTerminalDownAction = useCallback(() => { - if (hasReachedSplitLimit) return; - onSplitTerminalDown(); - }, [hasReachedSplitLimit, onSplitTerminalDown]); - const onNewTerminalAction = useCallback(() => { - onNewTerminal(); - }, [onNewTerminal]); - - const terminalChromeActions: TerminalChromeActionItem[] = [ - { - label: splitTerminalActionLabel, - onClick: onSplitTerminalAction, - disabled: hasReachedSplitLimit, - children: , - }, - { - label: splitTerminalDownActionLabel, - onClick: onSplitTerminalDownAction, - disabled: hasReachedSplitLimit, - children: , - }, - { - label: newTerminalActionLabel, - onClick: onNewTerminalAction, - children: , - }, - { - label: closeTerminalActionLabel, - onClick: () => onCloseTerminal(resolvedActiveTerminalId), - children: , - }, - ]; - const showTerminalGroupTabs = resolvedTerminalGroups.length > 1; - const topTabBarActions = terminalChromeActions; - - return ( - - ); -} diff --git a/apps/web/src/components/WorkspaceFilePreview.tsx b/apps/web/src/components/WorkspaceFilePreview.tsx deleted file mode 100644 index fcb54060e..000000000 --- a/apps/web/src/components/WorkspaceFilePreview.tsx +++ /dev/null @@ -1,665 +0,0 @@ -// FILE: WorkspaceFilePreview.tsx -// Purpose: Shared single-file preview (code with syntax highlighting, parsed -// markdown, images) for workspace files plus absolute local -// file references reused by editor and right-dock panes. Anything the -// file-read RPC rejects as binary (PDFs, archives, executables) falls -// back to the shared error state below the header, whose "Open in" -// control hands the file to an external app. -// Layer: Web chat presentation component -// Exports: WorkspaceFilePreview, isMarkdownPreviewablePath - -import { isSupportedLocalImagePath, lowerCaseExtensionOf } from "@t3tools/shared/localPreviewFiles"; -import { - isLocalAbsolutePath, - isWorkspaceRelativePathSafe, - joinWorkspaceRelativePath, -} from "@t3tools/shared/path"; -import { isScratchWorkspacePath } from "@t3tools/shared/threadWorkspace"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { - Component, - Suspense, - type MouseEvent as ReactMouseEvent, - type ReactNode, - memo, - use, - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from "react"; - -import { basenameOfPath } from "~/file-icons"; -import { useTheme } from "~/hooks/useTheme"; -import { getSelectionWithin, type ChatFileReference } from "~/lib/chatReferences"; -import { resolveDiffThemeName, type DiffThemeName } from "~/lib/diffRendering"; -import { formatFileCommentRange, type FileCommentSelection } from "~/lib/fileComments"; -import { showFileReferenceContextMenu } from "~/lib/fileReferenceContextMenu"; -import { PlusIcon } from "~/lib/icons"; -import { toggleMarkdownTaskMarker } from "~/lib/markdownTaskList"; -import { - isLocalPreviewGrantUsable, - projectLocalPreviewGrantQueryOptions, - projectReadFileQueryOptions, -} from "~/lib/projectReactQuery"; -import { - MAX_SYNTAX_HIGHLIGHT_INPUT_CHARS, - cacheSyntaxHighlightedHtml, - createSyntaxHighlightCacheKey, - getCachedSyntaxHighlightedHtml, - getSyntaxHighlighterPromise, - getSyntaxLanguageForPath, - highlightCodeToHtmlWithFallback, -} from "~/lib/syntaxHighlighting"; -import { cn } from "~/lib/utils"; -import { readNativeApi } from "~/nativeApi"; -import ChatMarkdown from "./ChatMarkdown"; -import { FileLineCommentBox } from "./chat/FileLineCommentBox"; -import { PanelStateMessage } from "./chat/PanelStateMessage"; -import { useFileLineCommenting } from "./chat/useFileLineCommenting"; -import { WorkspaceFilePreviewHeader } from "./chat/WorkspaceFilePreviewHeader"; -import { TranscriptSelectionAction } from "./chat/TranscriptSelectionAction"; -import { useCodeSelectionAction } from "./chat/useCodeSelectionAction"; -import { LocalImagePreview } from "./LocalImagePreview"; -import { Skeleton } from "./ui/skeleton"; - -const MARKDOWN_PREVIEW_EXTENSIONS = new Set([".markdown", ".md", ".mdx"]); - -export function isMarkdownPreviewablePath(filePath: string): boolean { - const extension = lowerCaseExtensionOf(filePath); - return extension !== null && MARKDOWN_PREVIEW_EXTENSIONS.has(extension); -} - -function parentDirectoryFromPath(path: string): string | null { - const normalized = path.replace(/\\/g, "/"); - const separatorIndex = normalized.lastIndexOf("/"); - if (separatorIndex <= 0) { - return null; - } - return normalized.slice(0, separatorIndex); -} - -function markdownPreviewCwd(workspaceRoot: string | null, filePath: string): string | undefined { - const parentDirectory = parentDirectoryFromPath(filePath); - if (isLocalAbsolutePath(filePath)) { - return parentDirectory ?? undefined; - } - if (!workspaceRoot) { - return undefined; - } - if (!parentDirectory) { - return workspaceRoot; - } - return joinWorkspaceRelativePath(workspaceRoot, parentDirectory); -} - -class FilePreviewHighlightErrorBoundary extends Component< - { fallback: ReactNode; children: ReactNode }, - { hasError: boolean } -> { - constructor(props: { fallback: ReactNode; children: ReactNode }) { - super(props); - this.state = { hasError: false }; - } - - static getDerivedStateFromError() { - return { hasError: true }; - } - - override render() { - if (this.state.hasError) { - return this.props.fallback; - } - return this.props.children; - } -} - -// Above this the plain fallback skips per-line spans (and therefore line -// numbers) to keep the DOM small for huge files. -const MAX_PLAIN_NUMBERED_LINES = 20_000; - -function escapeHtml(value: string): string { - return value - .replaceAll("&", "&") - .replaceAll("<", "<") - .replaceAll(">", ">") - .replaceAll('"', """); -} - -function PlainFileContents(props: { contents: string }) { - // Wrap each line in a .line span (mirroring Shiki output) so the CSS - // counter gutter applies. Built as an HTML string to avoid per-line React - // nodes; the trailing \n stays inside each span so selection math and - // clipboard copies keep working. - const numberedHtml = useMemo(() => { - if (props.contents.length === 0) { - return null; - } - const lines = props.contents.split("\n"); - if (lines.length > MAX_PLAIN_NUMBERED_LINES) { - return null; - } - return `${lines - .map((line, index) => - index === lines.length - 1 - ? `${escapeHtml(line)}` - : `${escapeHtml(line)}\n`, - ) - .join("")}`; - }, [props.contents]); - - if (numberedHtml !== null) { - return ( -
-    );
-  }
-
-  return (
-    
-      {props.contents}
-    
- ); -} - -function SyntaxHighlightedFileContents(props: { - path: string; - contents: string; - themeName: DiffThemeName; -}) { - const language = useMemo(() => getSyntaxLanguageForPath(props.path), [props.path]); - // The cache key hashes the whole file, so keep it off incidental re-renders - // (selection state, diff-warming churn) and only recompute when inputs change. - const cacheKey = useMemo( - () => createSyntaxHighlightCacheKey(props.contents, language, props.themeName), - [props.contents, language, props.themeName], - ); - const cachedHighlightedHtml = getCachedSyntaxHighlightedHtml(cacheKey); - - if (cachedHighlightedHtml != null) { - return ( -
- ); - } - - // The uncached path lives in its own component: an early return above must - // not change this component's hook order once the cache fills. - return ( - - ); -} - -function UncachedSyntaxHighlightedFileContents(props: { - cacheKey: string; - contents: string; - language: string; - themeName: DiffThemeName; -}) { - const highlighter = use(getSyntaxHighlighterPromise(props.language)); - const highlightedHtml = useMemo(() => { - return highlightCodeToHtmlWithFallback( - highlighter, - props.contents, - props.language, - props.themeName, - ); - }, [highlighter, props.contents, props.language, props.themeName]); - - useEffect(() => { - cacheSyntaxHighlightedHtml(props.cacheKey, highlightedHtml, props.contents); - }, [props.cacheKey, highlightedHtml, props.contents]); - - return ( -
- ); -} - -// Memoized: its inputs (path, contents, themeName) are stable across the -// preview re-renders triggered by selection state and diff-warming, so the -// highlighted body (and its cache lookup) is skipped unless the file changes. -const FileContentsView = memo(function FileContentsView(props: { - path: string; - contents: string; - themeName: DiffThemeName; -}) { - const plain = ; - if (props.contents.length === 0 || props.contents.length > MAX_SYNTAX_HIGHLIGHT_INPUT_CHARS) { - return plain; - } - - return ( - - - - - - ); -}); - -// Mimics indented code lines so the placeholder reads as a file body -// instead of a generic spinner block. -const FILE_PREVIEW_SKELETON_LINES = [ - { indent: 0, width: "w-5/12" }, - { indent: 0, width: "w-8/12" }, - { indent: 1, width: "w-10/12" }, - { indent: 1, width: "w-7/12" }, - { indent: 2, width: "w-9/12" }, - { indent: 2, width: "w-4/12" }, - { indent: 1, width: "w-6/12" }, - { indent: 0, width: "w-3/12" }, - { indent: 0, width: "w-7/12" }, - { indent: 1, width: "w-9/12" }, - { indent: 1, width: "w-5/12" }, - { indent: 0, width: "w-2/12" }, -]; - -function FilePreviewLoadingState() { - return ( -
- {FILE_PREVIEW_SKELETON_LINES.map((line) => ( -
- - -
- ))} - Loading file... -
- ); -} - -export interface WorkspaceFilePreviewProps { - workspaceRoot: string | null; - /** - * Workspace-relative path of the previewed file. Image previews may instead - * be absolute paths outside the workspace — e.g. a session's scratch - * directory — served by the local-image route, which never touches the - * workspace-relative file-read RPC. - */ - filePath: string | null; - /** - * Initial markdown render mode per file: the dock opens markdown already - * parsed, the editor surface stays source-first. The header toggle still - * lets the user flip either way. - */ - markdownPreviewDefault?: boolean; - /** Shown when no file is selected yet. */ - emptyState?: ReactNode; - onReferenceInChat?: ((reference: ChatFileReference) => void) | undefined; - onAskWhyInChat?: ((reference: ChatFileReference) => void) | undefined; - onCommentInChat?: ((comment: FileCommentSelection) => void) | undefined; -} - -export function WorkspaceFilePreview(props: WorkspaceFilePreviewProps) { - const { resolvedTheme } = useTheme(); - const diffThemeName = resolveDiffThemeName(resolvedTheme); - const contentsRef = useRef(null); - const taskWriteQueueRef = useRef>(Promise.resolve()); - const latestTaskWriteVersionRef = useRef({ next: 0, byFile: new Map() }); - const { filePath, onAskWhyInChat, onCommentInChat, onReferenceInChat, workspaceRoot } = props; - const queryClient = useQueryClient(); - const markdownPreviewDefault = props.markdownPreviewDefault ?? false; - const fileIsImage = filePath !== null && isSupportedLocalImagePath(filePath); - const fileIsLocalAbsolute = filePath !== null && isLocalAbsolutePath(filePath); - const fileIsWorkspaceRelative = filePath !== null && isWorkspaceRelativePathSafe(filePath); - const fileIsScratchImagePreview = - filePath !== null && fileIsImage && isScratchWorkspacePath(filePath); - const fileNeedsLocalPreviewGrant = - filePath !== null && fileIsLocalAbsolute && !fileIsScratchImagePreview; - const fileIsMarkdown = filePath !== null && isMarkdownPreviewablePath(filePath); - const [markdownPreviewEnabled, setMarkdownPreviewEnabled] = useState(markdownPreviewDefault); - const localPreviewGrantQuery = useQuery( - projectLocalPreviewGrantQueryOptions({ - path: filePath, - enabled: fileNeedsLocalPreviewGrant, - }), - ); - const localPreviewGrant = - fileNeedsLocalPreviewGrant && isLocalPreviewGrantUsable(localPreviewGrantQuery.data) - ? (localPreviewGrantQuery.data?.grant ?? null) - : null; - const fileQuery = useQuery( - projectReadFileQueryOptions({ - cwd: props.workspaceRoot, - relativePath: filePath, - previewGrant: localPreviewGrant, - // Images stream through the local-image HTTP route instead of the text - // file-read RPC. - enabled: - filePath !== null && - !fileIsImage && - (props.workspaceRoot !== null || localPreviewGrant !== null), - }), - ); - useEffect(() => { - setMarkdownPreviewEnabled(markdownPreviewDefault); - }, [filePath, markdownPreviewDefault]); - - const fileContents = fileQuery.data?.contents ?? ""; - const showMarkdownPreview = fileIsMarkdown && markdownPreviewEnabled; - const lineCount = useMemo( - () => (fileContents.length === 0 ? 0 : fileContents.split("\n").length), - [fileContents], - ); - // Highlight -> floating "Add to chat" -> reference that points at exactly what - // was selected, mirroring the transcript flow. This is offered only in the - // source view, where the DOM mirrors the file's lines/columns 1:1 so a - // selection resolves to an exact `line 12:5-12` span. The rendered-markdown - // view restructures the source (paragraphs, lists, headings), so a selection - // there cannot map back to an exact range — referencing a single word on a - // 3000-word line would pull in the whole line. The rendered view therefore - // stays read-only for references (browsing + task-list toggles only); use the - // Source toggle in the header to get a precise selection reference. - const readPreviewSelection = useCallback( - (container: HTMLElement): Omit | null => - showMarkdownPreview ? null : getSelectionWithin(container), - [showMarkdownPreview], - ); - const commitPreviewSelection = useCallback( - (selection: Omit) => { - if (filePath) { - onReferenceInChat?.({ path: filePath, ...selection }); - } - }, - [onReferenceInChat, filePath], - ); - const previewSelectionAction = useCodeSelectionAction({ - enabled: Boolean(onReferenceInChat && filePath) && !showMarkdownPreview, - readSelection: readPreviewSelection, - onCommit: commitPreviewSelection, - }); - // Hover "+" gutter affordance + inline "Local comment" box. Offered only in - // the source view, where the DOM mirrors the file's lines 1:1 so the hovered - // `.line` resolves to an exact line number (the rendered-markdown view - // restructures the source and cannot map a row back to a file line). - const lineCommentingEnabled = Boolean(onCommentInChat && filePath) && !showMarkdownPreview; - const lineCommenting = useFileLineCommenting({ - enabled: lineCommentingEnabled, - resetKey: filePath, - }); - const commitLineComment = useCallback( - (selection: Pick) => { - if (filePath) { - onCommentInChat?.({ path: filePath, ...selection }); - } - }, - [filePath, onCommentInChat], - ); - // Right-click references the selected line range in the source view, - // otherwise the whole file. The rendered-markdown view yields no selection - // (readPreviewSelection returns null there), so it always falls back to the - // whole-file reference. - const handleContentsContextMenu = useCallback( - (event: ReactMouseEvent) => { - if (!filePath) { - return; - } - event.preventDefault(); - const container = contentsRef.current; - const selection = container ? readPreviewSelection(container) : null; - void showFileReferenceContextMenu({ - path: filePath, - position: { x: event.clientX, y: event.clientY }, - selection, - onReferenceInChat, - onAskWhyInChat, - }); - }, - [onAskWhyInChat, onReferenceInChat, filePath, readPreviewSelection], - ); - // Clicking a task checkbox in the markdown preview persists the toggle to - // disk: optimistic cache update first, ordered write-through after, refetch - // on failure so the preview never drifts from the file. - const handleTaskToggle = useCallback( - ({ sourceLine, checked }: { sourceLine: number; checked: boolean }) => { - if (!workspaceRoot || !filePath) { - return; - } - const options = projectReadFileQueryOptions({ cwd: workspaceRoot, relativePath: filePath }); - const current = queryClient.getQueryData(options.queryKey); - if (!current || current.truncated) { - return; - } - const nextContents = toggleMarkdownTaskMarker(current.contents, sourceLine, checked); - if (nextContents === null) { - return; - } - // No API means no write can happen — bail before the optimistic update - // so the preview never shows a toggle that was silently dropped. - const api = readNativeApi(); - if (!api) { - return; - } - queryClient.setQueryData(options.queryKey, { ...current, contents: nextContents }); - // The read RPC may have resolved a bare/partial reference (e.g. a clicked - // `notes.md`) to its real nested path. Write back to that resolved path, - // not the opened reference, so the toggle lands on the file we read from - // instead of creating a stray file at the workspace root. - const writeRelativePath = current.relativePath; - // Writes carry the full file contents, so serialize them: a slower earlier - // checkbox write must never land after a newer toggle and erase it. - const fileKey = `${workspaceRoot}\0${filePath}`; - const writeVersion = latestTaskWriteVersionRef.current.next + 1; - latestTaskWriteVersionRef.current.next = writeVersion; - latestTaskWriteVersionRef.current.byFile.set(fileKey, writeVersion); - taskWriteQueueRef.current = taskWriteQueueRef.current - .catch(() => undefined) - .then(() => - api.projects.writeFile({ - cwd: workspaceRoot, - relativePath: writeRelativePath, - contents: nextContents, - }), - ) - .then(() => undefined) - .catch(() => { - if (latestTaskWriteVersionRef.current.byFile.get(fileKey) !== writeVersion) { - return; - } - void queryClient.invalidateQueries({ queryKey: options.queryKey }); - }); - void taskWriteQueueRef.current; - }, - [filePath, queryClient, workspaceRoot], - ); - const handleMarkdownPreviewChange = useCallback((rendered: boolean) => { - setMarkdownPreviewEnabled(rendered); - }, []); - // Toggling a task rewrites the file, so only enable it when the preview - // holds the complete contents (writing a truncated read would corrupt it). - const canToggleTasks = - props.workspaceRoot !== null && - fileIsWorkspaceRelative && - fileQuery.data !== undefined && - !fileQuery.data.truncated; - - if (!props.workspaceRoot && !fileIsLocalAbsolute && !fileIsScratchImagePreview) { - return ( - -

No workspace is attached to this chat.

-
- ); - } - - if (!filePath) { - return ( - props.emptyState ?? ( - -

Select a file from the explorer.

-
- ) - ); - } - if (fileNeedsLocalPreviewGrant && !localPreviewGrant) { - if (localPreviewGrantQuery.error) { - return ( - -

- {localPreviewGrantQuery.error instanceof Error - ? localPreviewGrantQuery.error.message - : "Could not create local file preview grant."} -

-
- ); - } - return ; - } - - const hoveredCommentLine = lineCommenting.hoveredLine; - const activeCommentLine = lineCommenting.activeLine; - - return ( -
- - {fileIsImage ? ( -
- -
- ) : fileQuery.isLoading ? ( - - ) : fileQuery.error ? ( - -

- {fileQuery.error instanceof Error ? fileQuery.error.message : "Could not read file."} -

-
- ) : ( -
- {showMarkdownPreview ? ( -
- -
- ) : ( - - )} - {!showMarkdownPreview && lineCount > 0 ? ( - {lineCount} lines - ) : null} - {previewSelectionAction.pendingAction ? ( - - ) : null} - {lineCommentingEnabled && hoveredCommentLine && !activeCommentLine ? ( - - ) : null} - {lineCommentingEnabled && activeCommentLine ? ( - <> - - )} -
- ); -} diff --git a/apps/web/src/components/chat/ChatHeader.test.ts b/apps/web/src/components/chat/ChatHeader.test.ts index 875966bf3..c50e980ed 100644 --- a/apps/web/src/components/chat/ChatHeader.test.ts +++ b/apps/web/src/components/chat/ChatHeader.test.ts @@ -8,15 +8,11 @@ import { describe, expect, it } from "vitest"; import { resolveChatHeaderThreadIconKind } from "./ChatHeader"; describe("resolveChatHeaderThreadIconKind", () => { - it("uses the terminal icon for terminal-first threads", () => { - expect(resolveChatHeaderThreadIconKind("terminal", "New terminal")).toBe("terminal"); - }); - - it("keeps provider branding for chat-first threads", () => { - expect(resolveChatHeaderThreadIconKind("chat", "Fix auth flow")).toBe("provider"); + it("keeps provider branding for named threads", () => { + expect(resolveChatHeaderThreadIconKind("Fix auth flow")).toBe("provider"); }); it("hides provider branding for untouched new chat threads", () => { - expect(resolveChatHeaderThreadIconKind("chat", "New thread")).toBe("none"); + expect(resolveChatHeaderThreadIconKind("New thread")).toBe("none"); }); }); diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index f9ae105a6..daf9f5cd6 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -80,7 +80,6 @@ const HEADER_COMPACT_BREAKPOINT = 700; interface ChatHeaderProps { activeThreadId: ThreadId; activeThreadTitle: string; - activeThreadEntryPoint: ThreadPrimarySurface; activeProvider: ProviderKind; activeProjectName: string | undefined; threadBreadcrumbs: ReadonlyArray<{ @@ -106,8 +105,6 @@ interface ChatHeaderProps { gitCwd: string | null; diffTotals: RepoDiffTotals; showGitActions?: boolean; - showDiffToggle?: boolean; - diffOpen: boolean; diffDisabledReason?: string | null; surfaceMode?: "single" | "split"; chatLayoutAction?: { @@ -137,7 +134,6 @@ interface ChatHeaderProps { onAddProjectScript: (input: NewProjectScriptInput) => Promise; onUpdateProjectScript: (scriptId: string, input: NewProjectScriptInput) => Promise; onDeleteProjectScript: (scriptId: string) => Promise; - onToggleDiff: () => void; onCreateHandoff: (targetProvider: ProviderKind) => void; onNavigateToThread: (threadId: ThreadId) => void; onRenameThread: () => void; @@ -463,22 +459,15 @@ function EditorRailTabs(props: { ); } -export type ChatHeaderThreadIconKind = "none" | "provider" | "terminal"; +export type ChatHeaderThreadIconKind = "none" | "provider"; -export function resolveChatHeaderThreadIconKind( - entryPoint: ThreadPrimarySurface, - title?: string, -): ChatHeaderThreadIconKind { - if (entryPoint === "chat" && isGenericChatThreadTitle(title)) { - return "none"; - } - return entryPoint === "terminal" ? "terminal" : "provider"; +export function resolveChatHeaderThreadIconKind(title?: string): ChatHeaderThreadIconKind { + return isGenericChatThreadTitle(title) ? "none" : "provider"; } export const ChatHeader = memo(function ChatHeader({ activeThreadId, activeThreadTitle, - activeThreadEntryPoint, activeProvider, activeProjectName, threadBreadcrumbs, @@ -501,8 +490,6 @@ export const ChatHeader = memo(function ChatHeader({ gitCwd, diffTotals, showGitActions = true, - showDiffToggle = true, - diffOpen, diffDisabledReason = null, surfaceMode = "single", chatLayoutAction = null, @@ -512,7 +499,6 @@ export const ChatHeader = memo(function ChatHeader({ onAddProjectScript, onUpdateProjectScript, onDeleteProjectScript, - onToggleDiff, onCreateHandoff, onNavigateToThread, onRenameThread, @@ -540,7 +526,7 @@ export const ChatHeader = memo(function ChatHeader({ // Split-chat creation moved to a shortcut only; the header keeps just the inline // "maximize" affordance for an already-split focused pane. const inlineChatLayoutAction = chatLayoutAction?.kind === "maximize" ? chatLayoutAction : null; - const threadIconKind = resolveChatHeaderThreadIconKind(activeThreadEntryPoint, activeThreadTitle); + const threadIconKind = resolveChatHeaderThreadIconKind(activeThreadTitle); useEffect(() => { const el = headerRef.current; @@ -582,9 +568,6 @@ export const ChatHeader = memo(function ChatHeader({ if (activeProjectName && showGitActions) { available.add("gitActions"); } - if (showDiffToggle) { - available.add("diff"); - } const hidden = new Set(hiddenChatHeaderControls); return DEFAULT_CHAT_HEADER_CONTROL_ORDER.filter( @@ -598,7 +581,6 @@ export const ChatHeader = memo(function ChatHeader({ chatHeaderControlOrder, hiddenChatHeaderControls, hideHandoffControls, - showDiffToggle, showGitActions, ]); @@ -669,48 +651,6 @@ export const ChatHeader = memo(function ChatHeader({ hideQuickActionLabel={compact} /> ); - case "diff": - return ( - - - {showDiffTotals ? ( - - - +{diffAdditions} - - - -{diffDeletions} - - - ) : null} - - - } - /> - - {!isGitRepo - ? "Diff panel is unavailable because this project is not a git repository." - : diffDisabledReason && !diffOpen - ? diffDisabledReason - : diffToggleShortcutLabel - ? `Toggle diff panel (${diffToggleShortcutLabel})` - : "Toggle diff panel"} - - - ); } }; @@ -757,17 +697,9 @@ export const ChatHeader = memo(function ChatHeader({ {threadIconKind === "none" ? null : ( - {threadIconKind === "terminal" ? ( - - ) : ( - renderProviderIcon(activeProvider, "size-3.5") - )} + {renderProviderIcon(activeProvider, "size-3.5")} )}

void }) { onRevertUserMessage={NOOP} onScrollToBottom={NOOP} onToggleWorkGroup={NOOP} - resolvedTheme="dark" revertTurnCountByUserMessageId={EMPTY_REVERT_COUNTS} scrollButtonVisible={false} - terminalWorkspaceTerminalTabActive={false} timelineEntries={TIMELINE_ENTRIES} timestampFormat="locale" turnDiffSummaryByAssistantMessageId={EMPTY_TURN_DIFFS} @@ -196,10 +194,8 @@ describe("ChatTranscriptPane", () => { onOpenThread={NOOP} onRevertUserMessage={NOOP} onScrollToBottom={NOOP} - resolvedTheme="dark" revertTurnCountByUserMessageId={EMPTY_REVERT_COUNTS} scrollButtonVisible={false} - terminalWorkspaceTerminalTabActive={false} timelineEntries={[ { id: "user-message-entry", @@ -275,10 +271,8 @@ describe("ChatTranscriptPane", () => { onOpenThread={NOOP} onRevertUserMessage={NOOP} onScrollToBottom={NOOP} - resolvedTheme="dark" revertTurnCountByUserMessageId={EMPTY_REVERT_COUNTS} scrollButtonVisible={false} - terminalWorkspaceTerminalTabActive={false} timelineEntries={[ { id: "user-message-entry-1", diff --git a/apps/web/src/components/chat/ChatTranscriptPane.test.tsx b/apps/web/src/components/chat/ChatTranscriptPane.test.tsx index ae66754ff..125e83280 100644 --- a/apps/web/src/components/chat/ChatTranscriptPane.test.tsx +++ b/apps/web/src/components/chat/ChatTranscriptPane.test.tsx @@ -40,10 +40,8 @@ function renderTranscriptPaneMarkup( onOpenThread={(_threadId: ThreadId) => {}} onRevertUserMessage={(_messageId: MessageId) => {}} onScrollToBottom={() => {}} - resolvedTheme="light" revertTurnCountByUserMessageId={new Map()} scrollButtonVisible - terminalWorkspaceTerminalTabActive={false} timelineEntries={[]} timestampFormat="locale" turnDiffSummaryByAssistantMessageId={new Map()} diff --git a/apps/web/src/components/chat/ChatTranscriptPane.tsx b/apps/web/src/components/chat/ChatTranscriptPane.tsx index de8c19a7e..49542f209 100644 --- a/apps/web/src/components/chat/ChatTranscriptPane.tsx +++ b/apps/web/src/components/chat/ChatTranscriptPane.tsx @@ -34,8 +34,6 @@ import { DISCLOSURE_CONTENT_MOTION_CLASS } from "~/lib/disclosureMotion"; import { type ExpandedImagePreview } from "./ExpandedImagePreview"; import { ChatEmptyStateHero } from "./ChatEmptyStateHero"; import { MessagesTimeline, type MessagesTimelineController } from "./MessagesTimeline"; -import { MessageTrail } from "./MessageTrail"; -import { createActiveTrailStore, deriveMessageTrailItems } from "./messageTrail.logic"; import { AgentActivityDetailView } from "./AgentActivityDetailView"; import type { AgentActivityDetail } from "./agentActivity.logic"; @@ -84,10 +82,8 @@ interface ChatTranscriptPaneProps { onEditUserMessage?: (messageId: MessageId, text: string) => boolean | Promise; onScrollToBottom: () => void; onToggleWorkGroup?: (groupId: string) => void; - resolvedTheme: "light" | "dark"; revertTurnCountByUserMessageId: Map; scrollButtonVisible: boolean; - terminalWorkspaceTerminalTabActive: boolean; timelineEntries: ComponentProps["timelineEntries"]; timestampFormat: TimestampFormat; turnDiffSummaryByAssistantMessageId: Map; @@ -140,10 +136,8 @@ export const ChatTranscriptPane = memo(function ChatTranscriptPane({ onEditUserMessage, onScrollToBottom, onToggleWorkGroup, - resolvedTheme, revertTurnCountByUserMessageId, scrollButtonVisible, - terminalWorkspaceTerminalTabActive, timelineEntries, timestampFormat, turnDiffSummaryByAssistantMessageId, @@ -154,35 +148,10 @@ export const ChatTranscriptPane = memo(function ChatTranscriptPane({ ? { paddingRight: contentInsetRightPx } : undefined; - // Left-edge navigation trail: one tick per sent message. Current + visible - // highlights are pushed up from MessagesTimeline as the viewport scrolls. They - // flow through a stable store (not pane state) so scroll updates re-render only - // the trail, not the memoized timeline; reset on thread switch so stale - // highlights can't linger. - const trailItems = useMemo(() => deriveMessageTrailItems(timelineEntries), [timelineEntries]); - const activeTrailStoreRef = useRef | null>(null); - if (activeTrailStoreRef.current === null) { - activeTrailStoreRef.current = createActiveTrailStore(); - } - const activeTrailStore = activeTrailStoreRef.current; - useEffect(() => { - activeTrailStore.set(null); - }, [activeThreadId, activeTrailStore]); - const handleTrailSelect = useCallback( - (messageId: MessageId) => { - timelineControllerRef?.current?.scrollToMessage(messageId); - }, - [timelineControllerRef], - ); - return (
{agentActivityDetail && onCloseAgentActivityDetail ? ( @@ -225,7 +194,6 @@ export const ChatTranscriptPane = memo(function ChatTranscriptPane({ onImageExpand={onExpandTimelineImage} followLiveOutput={followLiveOutput} onIsAtEndChange={onIsAtEndChange} - onTrailHighlightsChange={activeTrailStore.set} onMessagesScroll={onMessagesScroll} onMessagesClickCapture={onMessagesClickCapture} onMessagesMouseUp={onMessagesMouseUp} @@ -237,7 +205,6 @@ export const ChatTranscriptPane = memo(function ChatTranscriptPane({ onMessagesTouchMove={onMessagesTouchMove} onMessagesTouchEnd={onMessagesTouchEnd} markdownCwd={markdownCwd} - resolvedTheme={resolvedTheme} chatFontSizePx={chatFontSizePx} timestampFormat={timestampFormat} workspaceRoot={workspaceRoot} @@ -287,14 +254,6 @@ export const ChatTranscriptPane = memo(function ChatTranscriptPane({
) : null} - - {!agentActivityDetail ? ( - - ) : null}

); diff --git a/apps/web/src/components/chat/ComposerChoiceRow.tsx b/apps/web/src/components/chat/ComposerChoiceRow.tsx index 34162a281..9147883a2 100644 --- a/apps/web/src/components/chat/ComposerChoiceRow.tsx +++ b/apps/web/src/components/chat/ComposerChoiceRow.tsx @@ -62,7 +62,7 @@ export function ComposerChoiceRow({ disabled={disabled} onClick={onSelect} className={cn( - "group flex w-full items-start gap-2.5 rounded-[8px] border border-transparent px-2.5 py-2 text-left transition-[background-color,border-color,scale] duration-press ease-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background active:scale-[0.96] motion-reduce:active:scale-100", + "group flex w-full items-start gap-2.5 rounded-lg border border-transparent px-2.5 py-2 text-left transition-[background-color,border-color,scale] duration-press ease-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background active:scale-[0.96] motion-reduce:active:scale-100", selected ? "border-[color:color-mix(in_srgb,var(--foreground)_20%,var(--panel-border))] bg-[var(--selected)]" : ROW_TONE_CLASS_NAME[tone], diff --git a/apps/web/src/components/chat/ComposerCommandMenu.tsx b/apps/web/src/components/chat/ComposerCommandMenu.tsx index a6e486c01..369e98328 100644 --- a/apps/web/src/components/chat/ComposerCommandMenu.tsx +++ b/apps/web/src/components/chat/ComposerCommandMenu.tsx @@ -289,7 +289,6 @@ export function groupCommandItems( export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: { items: ComposerCommandItem[]; - resolvedTheme: "light" | "dark"; isLoading: boolean; triggerKind: ComposerTriggerKind | null; groupSlashCommandSections?: boolean; @@ -340,7 +339,6 @@ export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: { { itemRefs.current[item.id] = node; @@ -428,7 +426,7 @@ function commandMenuSlashGlyph(command: string, fallback: LucideIcon): ReactNode return ; } -function commandMenuItemGlyph(item: ComposerCommandItem, theme: "light" | "dark"): ReactNode { +function commandMenuItemGlyph(item: ComposerCommandItem): ReactNode { const cls = COMPOSER_COMMAND_ITEM_GLYPH_CLASSNAME; switch (item.type) { case "path": @@ -436,7 +434,6 @@ function commandMenuItemGlyph(item: ComposerCommandItem, theme: "light" | "dark" - {commandMenuItemGlyph(props.item, props.resolvedTheme)} + {commandMenuItemGlyph(props.item)} ); }); const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: { item: ComposerCommandItem; - resolvedTheme: "light" | "dark"; isActive: boolean; itemRef: (node: HTMLElement | null) => void; onHighlight: (itemId: string | null) => void; @@ -522,11 +517,7 @@ const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: { props.onSelect(props.item); }} > - +
diff --git a/apps/web/src/components/chat/ComposerImageAttachmentChip.tsx b/apps/web/src/components/chat/ComposerImageAttachmentChip.tsx index 53de88fbf..8aed9886d 100644 --- a/apps/web/src/components/chat/ComposerImageAttachmentChip.tsx +++ b/apps/web/src/components/chat/ComposerImageAttachmentChip.tsx @@ -51,10 +51,10 @@ export const ComposerImageAttachmentChip = memo(function ComposerImageAttachment ) : ( - + {(image.source.appName || "A").slice(0, 1).toUpperCase()} )} diff --git a/apps/web/src/components/chat/ComposerPendingApprovalPanel.tsx b/apps/web/src/components/chat/ComposerPendingApprovalPanel.tsx index edba54210..28dcf0cda 100644 --- a/apps/web/src/components/chat/ComposerPendingApprovalPanel.tsx +++ b/apps/web/src/components/chat/ComposerPendingApprovalPanel.tsx @@ -169,7 +169,7 @@ function ApprovalDetail({ parsed }: { parsed: ParsedApproval }) { if (code) { return (
         {code}
diff --git a/apps/web/src/components/chat/ComposerPendingTerminalContexts.test.tsx b/apps/web/src/components/chat/ComposerPendingTerminalContexts.test.tsx
deleted file mode 100644
index 060f197e9..000000000
--- a/apps/web/src/components/chat/ComposerPendingTerminalContexts.test.tsx
+++ /dev/null
@@ -1,28 +0,0 @@
-import { ThreadId } from "@t3tools/contracts";
-import { renderToStaticMarkup } from "react-dom/server";
-import { describe, expect, it } from "vitest";
-
-import { ComposerPendingTerminalContextChip } from "./ComposerPendingTerminalContexts";
-
-describe("ComposerPendingTerminalContextChip", () => {
-  it("renders expired terminal contexts with error styling", () => {
-    const markup = renderToStaticMarkup(
-      ,
-    );
-
-    expect(markup).toContain('data-terminal-context-expired="true"');
-    expect(markup).toContain("border-destructive/35");
-    expect(markup).toContain("Terminal 1 lines 2-4");
-  });
-});
diff --git a/apps/web/src/components/chat/ComposerPendingTerminalContexts.tsx b/apps/web/src/components/chat/ComposerPendingTerminalContexts.tsx
deleted file mode 100644
index 37c05eab2..000000000
--- a/apps/web/src/components/chat/ComposerPendingTerminalContexts.tsx
+++ /dev/null
@@ -1,44 +0,0 @@
-import { cn } from "~/lib/utils";
-import {
-  type TerminalContextDraft,
-  formatTerminalContextLabel,
-  isTerminalContextExpired,
-} from "~/lib/terminalContext";
-import { TerminalContextInlineChip } from "./TerminalContextInlineChip";
-
-interface ComposerPendingTerminalContextsProps {
-  contexts: ReadonlyArray;
-  className?: string;
-}
-
-interface ComposerPendingTerminalContextChipProps {
-  context: TerminalContextDraft;
-}
-
-export function ComposerPendingTerminalContextChip({
-  context,
-}: ComposerPendingTerminalContextChipProps) {
-  const label = formatTerminalContextLabel(context);
-  const expired = isTerminalContextExpired(context);
-  const tooltipText = expired
-    ? `Terminal context expired. Remove and re-add ${label} to include it in your message.`
-    : context.text;
-
-  return ;
-}
-
-export function ComposerPendingTerminalContexts(props: ComposerPendingTerminalContextsProps) {
-  const { contexts, className } = props;
-
-  if (contexts.length === 0) {
-    return null;
-  }
-
-  return (
-    
- {contexts.map((context) => ( - - ))} -
- ); -} diff --git a/apps/web/src/components/chat/DockExplorerPane.tsx b/apps/web/src/components/chat/DockExplorerPane.tsx deleted file mode 100644 index f55a6d6f8..000000000 --- a/apps/web/src/components/chat/DockExplorerPane.tsx +++ /dev/null @@ -1,87 +0,0 @@ -// FILE: DockExplorerPane.tsx -// Purpose: Right-dock pane that embeds the unified workspace explorer (a fixed -// search box over the file tree, switching to file-name results as the -// user types) alongside the shared file viewer. -// Layer: Chat right-dock UI -// Exports: DockExplorerPane - -import { memo, useCallback, useEffect, useState } from "react"; - -import type { ChatFileReference } from "~/lib/chatReferences"; -import type { FileCommentSelection } from "~/lib/fileComments"; -import { WorkspaceFilePreview } from "../WorkspaceFilePreview"; -import { PanelStateMessage } from "./PanelStateMessage"; -import { WorkspaceExplorerSidebar } from "./workspaceExplorer"; - -// The dock lays out as a fixed horizontal row, so the shared sidebar takes a -// full-height fixed-width column (the editor's responsive default would collapse -// to a stacked block here). With the activity rail gone, the search box sits at -// the top of this column and the freed width goes to the file viewer. -const DOCK_EXPLORER_SIDEBAR_CLASS = - "flex h-full min-h-0 w-60 shrink-0 flex-col border-r border-border/65 bg-[var(--color-background-surface)]"; - -export const DockExplorerPane = memo(function DockExplorerPane(props: { - workspaceRoot: string | null; - initialFilePath?: string | null; - onReferenceInChat?: ((reference: ChatFileReference) => void) | undefined; - onAskWhyInChat?: ((reference: ChatFileReference) => void) | undefined; - onCommentInChat?: ((comment: FileCommentSelection) => void) | undefined; -}) { - const [selectedFilePath, setSelectedFilePath] = useState( - props.initialFilePath ?? null, - ); - const [expandedDirectories, setExpandedDirectories] = useState>( - () => new Set(), - ); - const [searchQuery, setSearchQuery] = useState(""); - - useEffect(() => { - if (props.initialFilePath) setSelectedFilePath(props.initialFilePath); - }, [props.initialFilePath]); - - const handleSelectFile = useCallback((path: string) => { - setSelectedFilePath(path); - }, []); - - const handleToggleDirectory = useCallback((path: string) => { - setExpandedDirectories((current) => { - const next = new Set(current); - if (next.has(path)) { - next.delete(path); - } else { - next.add(path); - } - return next; - }); - }, []); - - return ( -
- -
- -

Select a file from the tree to view it.

- - } - onReferenceInChat={props.onReferenceInChat} - onAskWhyInChat={props.onAskWhyInChat} - onCommentInChat={props.onCommentInChat} - /> -
-
- ); -}); diff --git a/apps/web/src/components/chat/DockPaneHeader.tsx b/apps/web/src/components/chat/DockPaneHeader.tsx deleted file mode 100644 index cfcf8cb05..000000000 --- a/apps/web/src/components/chat/DockPaneHeader.tsx +++ /dev/null @@ -1,47 +0,0 @@ -// FILE: DockPaneHeader.tsx -// Purpose: Title bar for lightweight right-dock panes (e.g. source control) — a title, -// an optional action cluster, and the standard chrome close affordance. -// Shares the standard chrome-bar row (CHAT_SURFACE_HEADER_ROW_CLASS_NAME — height -// + bottom hairline) and the chrome button footprint (DOCK_HEADER_ICON_BUTTON_CLASS) -// with the tab strip and the DiffPanelShell/BrowserPanel headers so every dock -// surface lines up. -// Layer: Chat right-dock UI primitives - -import { type ReactNode } from "react"; - -import { cn } from "~/lib/utils"; -import { XIcon } from "~/lib/icons"; -import { IconButton } from "../ui/icon-button"; -import { - CHAT_SURFACE_HEADER_ROW_CLASS_NAME, - DOCK_HEADER_ICON_BUTTON_CLASS, -} from "./chatHeaderControls"; - -export function DockPaneHeader(props: { - title: ReactNode; - actions?: ReactNode; - onClose?: (() => void) | undefined; - closeLabel?: string; -}) { - return ( -
- - {props.title} - -
- {props.actions} - {props.onClose ? ( - - - - ) : null} -
-
- ); -} diff --git a/apps/web/src/components/chat/DockTerminalPane.tsx b/apps/web/src/components/chat/DockTerminalPane.tsx deleted file mode 100644 index 140213274..000000000 --- a/apps/web/src/components/chat/DockTerminalPane.tsx +++ /dev/null @@ -1,106 +0,0 @@ -// FILE: DockTerminalPane.tsx -// Purpose: Render an independent terminal workspace inside the right dock for a host thread. -// Layer: Chat right-dock UI -// Depends on: useTerminalSurfaceController (shared store wiring), LazyThreadTerminalDrawer. -// -// The dock terminal set is isolated from the bottom drawer via a synthetic scope id -// (dockTerminalThreadId), so the two never share xterm instances. All store wiring is -// shared with WorkspaceView through useTerminalSurfaceController; only the -// "ensure a terminal is open" policy is surface-specific (here: a single terminal-only page). - -import { type ProjectId, type ThreadId } from "@t3tools/contracts"; -import { useCallback, useEffect, useMemo } from "react"; - -import { useTerminalSurfaceController } from "~/hooks/useTerminalSurfaceController"; -import { dockTerminalThreadId } from "~/lib/dockTerminalScope"; -import { projectScriptRuntimeEnv } from "~/projectScripts"; -import { useStore } from "~/store"; -import { createProjectSelector, createThreadSelector } from "~/storeSelectors"; -import ThreadTerminalDrawer from "../terminal/LazyThreadTerminalDrawer"; - -export function DockTerminalPane(props: { - hostThreadId: ThreadId; - projectId: ProjectId | null; - // When false the pane stays mounted but hidden (another dock tab is active), - // so the xterm runtime sleeps its visual work without detaching its DOM. - isActive?: boolean; -}) { - const scopeId = useMemo(() => dockTerminalThreadId(props.hostThreadId), [props.hostThreadId]); - const thread = useStore( - useMemo(() => createThreadSelector(props.hostThreadId), [props.hostThreadId]), - ); - const project = useStore( - useMemo(() => createProjectSelector(props.projectId), [props.projectId]), - ); - const worktreePath = thread?.worktreePath ?? null; - const projectCwd = project?.cwd ?? null; - const cwd = worktreePath ?? projectCwd ?? ""; - const runtimeEnv = useMemo(() => { - if (!projectCwd) return {}; - return projectScriptRuntimeEnv({ project: { cwd: projectCwd }, worktreePath }); - }, [projectCwd, worktreePath]); - - const terminal = useTerminalSurfaceController(scopeId); - const { terminalState, openTerminalThreadPage, bumpFocusRequest, newTerminalGroup } = terminal; - - // A dock terminal pane always shows a live terminal: ensure one is open on mount - // and re-open if the user closes the last tab (normalize guarantees a default id). - useEffect(() => { - if (terminalState.terminalOpen) { - return; - } - openTerminalThreadPage(scopeId, { terminalOnly: true }); - }, [openTerminalThreadPage, scopeId, terminalState.terminalOpen]); - - const createTerminal = useCallback(() => { - if (!terminalState.terminalOpen) { - openTerminalThreadPage(scopeId, { terminalOnly: true }); - bumpFocusRequest(); - return; - } - newTerminalGroup(); - }, [ - bumpFocusRequest, - newTerminalGroup, - openTerminalThreadPage, - scopeId, - terminalState.terminalOpen, - ]); - - return ( - {}} - /> - ); -} - -export default DockTerminalPane; diff --git a/apps/web/src/components/chat/FileDiffView.tsx b/apps/web/src/components/chat/FileDiffView.tsx index 58f7f5540..66a565c6f 100644 --- a/apps/web/src/components/chat/FileDiffView.tsx +++ b/apps/web/src/components/chat/FileDiffView.tsx @@ -9,11 +9,7 @@ import { FileDiff, type FileDiffMetadata, Virtualizer } from "@pierre/diffs/react"; import { type ReactNode } from "react"; -import { - buildDiffPanelUnsafeCSS, - resolveDiffThemeName, - resolveFileDiffPath, -} from "~/lib/diffRendering"; +import { buildDiffPanelUnsafeCSS, DIFF_THEME_NAME, resolveFileDiffPath } from "~/lib/diffRendering"; import { cn } from "~/lib/utils"; import { FileEntryIcon } from "./FileEntryIcon"; @@ -42,7 +38,6 @@ export function FileDiffSurface(props: { className?: string; children: ReactNode // themed addition/deletion backgrounds. export function FileDiffCard(props: { fileDiff: FileDiffMetadata; - theme: "light" | "dark"; diffStyle?: "unified" | "split"; overflow?: "scroll" | "wrap"; collapsed?: boolean; @@ -56,9 +51,9 @@ export function FileDiffCard(props: { diffStyle: props.diffStyle ?? "unified", lineDiffType: "none", overflow: props.overflow ?? "scroll", - theme: resolveDiffThemeName(props.theme), - themeType: props.theme, - unsafeCSS: buildDiffPanelUnsafeCSS(props.theme), + theme: DIFF_THEME_NAME, + themeType: "dark", + unsafeCSS: buildDiffPanelUnsafeCSS(), ...(props.collapsed !== undefined ? { collapsed: props.collapsed } : {}), }} renderHeaderPrefix={() => ( @@ -66,7 +61,6 @@ export function FileDiffCard(props: { diff --git a/apps/web/src/components/chat/FileEntryIcon.tsx b/apps/web/src/components/chat/FileEntryIcon.tsx index 64a2aedbc..2c98234be 100644 --- a/apps/web/src/components/chat/FileEntryIcon.tsx +++ b/apps/web/src/components/chat/FileEntryIcon.tsx @@ -53,10 +53,6 @@ export const FileEntryIcon = memo(function FileEntryIcon(props: { // undefined for source-file surfaces (diff/editor/timeline) that key purely // off the path. mimeType?: string | null | undefined; - // Vestigial: Central icons are `currentColor` glyphs, so theme no longer - // affects icon selection. Optional so theme-less surfaces (e.g. markdown - // file links, code-block headers) can reuse this same primitive. - theme?: "light" | "dark" | undefined; className?: string; // Timeline changed-file rows pass their own muted color and should not pick // up extension-specific colors. diff --git a/apps/web/src/components/chat/GitPanel.tsx b/apps/web/src/components/chat/GitPanel.tsx deleted file mode 100644 index 225c9d076..000000000 --- a/apps/web/src/components/chat/GitPanel.tsx +++ /dev/null @@ -1,384 +0,0 @@ -// FILE: GitPanel.tsx -// Purpose: Source-control staging pane for the right dock (staged/unstaged lists + per-file diff). -// Layer: Chat right-dock UI -// Depends on: gitReactQuery (diff queries + stage/unstage mutations), diffRendering (patch parsing), -// @pierre/diffs FileDiff for the per-file viewer. -// -// The pane derives its cwd like DockTerminalPane (thread worktree or project cwd) and reads the -// staged/unstaged patches, parsing them into file lists. Stage/unstage are index mutations routed -// through GitCore; on settle we invalidate the per-cwd git caches so both lists stay in sync. - -import { type FileDiffMetadata } from "@pierre/diffs/react"; -import { type ProjectId, type ThreadId } from "@t3tools/contracts"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { memo, useCallback, useMemo, useState } from "react"; - -import { useTheme } from "~/hooks/useTheme"; -import { - buildFileDiffRenderKey, - getRenderablePatch, - resolveFileDiffPath, - sortFileDiffsByPath, - splitRepoRelativePath, - summarizeFileDiffStats, -} from "~/lib/diffRendering"; -import { - gitQueryKeys, - gitStageFilesMutationOptions, - gitUnstageFilesMutationOptions, - gitWorkingTreeDiffQueryOptions, -} from "~/lib/gitReactQuery"; -import { PlusIcon, RefreshCwIcon, RotateCcwIcon } from "~/lib/icons"; -import { cn } from "~/lib/utils"; -import { useStore } from "~/store"; -import { createProjectSelector, createThreadSelector } from "~/storeSelectors"; -import { Alert } from "../ui/alert"; -import { Button } from "../ui/button"; -import { IconButton } from "../ui/icon-button"; -import { DOCK_HEADER_ICON_BUTTON_CLASS } from "./chatHeaderControls"; -import { DiffStat } from "./DiffStatLabel"; -import { DockPaneHeader } from "./DockPaneHeader"; -import { FileDiffCard, FileDiffSurface } from "./FileDiffView"; -import { FileEntryIcon } from "./FileEntryIcon"; -import { PanelStateMessage } from "./PanelStateMessage"; - -type GitPanelSection = "staged" | "unstaged"; - -// Selection is keyed by section + working-tree path (not the content-hashed -// render key) so it survives a file moving between the staged and unstaged -// lists after a stage/unstage action. -interface SelectedFile { - section: GitPanelSection; - path: string; -} - -function parsePatchToSortedFiles( - patch: string | undefined, - cacheScope: string, -): FileDiffMetadata[] { - const renderable = getRenderablePatch(patch, cacheScope); - return renderable?.kind === "files" ? sortFileDiffsByPath(renderable.files) : []; -} - -// Memoized so a stage/unstage in-flight toggle (which flips `actionDisabled` -// across siblings) and unrelated parent re-renders stay cheap; the per-row stat -// is only recomputed when the underlying file diff actually changes. -const GitFileRow = memo(function GitFileRow(props: { - fileDiff: FileDiffMetadata; - theme: "light" | "dark"; - isSelected: boolean; - actionLabel: string; - actionIcon: "stage" | "unstage"; - actionDisabled: boolean; - onSelect: (file: FileDiffMetadata) => void; - onAction: (paths: string[]) => void; -}) { - const filePath = resolveFileDiffPath(props.fileDiff); - const { dir, name } = splitRepoRelativePath(filePath); - const stat = useMemo(() => summarizeFileDiffStats([props.fileDiff]), [props.fileDiff]); - return ( -
- - - props.onAction([filePath])} - > - {props.actionIcon === "stage" ? ( - - ) : ( - - )} - -
- ); -}); - -function GitFileSection(props: { - title: string; - emptyLabel: string; - files: FileDiffMetadata[]; - theme: "light" | "dark"; - section: GitPanelSection; - selectedPath: string | null; - actionLabel: string; - actionAllLabel: string; - actionIcon: "stage" | "unstage"; - actionDisabled: boolean; - onSelect: (file: FileDiffMetadata) => void; - onAction: (paths: string[]) => void; -}) { - const stat = useMemo(() => summarizeFileDiffStats(props.files), [props.files]); - const allPaths = useMemo( - () => props.files.map((file) => resolveFileDiffPath(file)), - [props.files], - ); - return ( -
-
- {props.title} - - {props.files.length} - - - {props.files.length > 0 ? ( - - ) : null} -
- {props.files.length === 0 ? ( -

{props.emptyLabel}

- ) : ( -
- {props.files.map((file) => { - const key = buildFileDiffRenderKey(file); - const filePath = resolveFileDiffPath(file); - return ( - - ); - })} -
- )} -
- ); -} - -// Isolated + memoized so the (heavy) diff viewer only re-renders when the -// selected file or theme changes — not when stage/unstage mutations toggle the -// pane's pending state. -const SelectedFileDiff = memo(function SelectedFileDiff(props: { - fileDiff: FileDiffMetadata; - theme: "light" | "dark"; -}) { - return ( - -
- -
-
- ); -}); - -export function GitPanel(props: { - hostThreadId: ThreadId; - projectId: ProjectId | null; - onClose?: () => void; -}) { - const queryClient = useQueryClient(); - const { resolvedTheme } = useTheme(); - const theme = resolvedTheme as "light" | "dark"; - const thread = useStore( - useMemo(() => createThreadSelector(props.hostThreadId), [props.hostThreadId]), - ); - const project = useStore( - useMemo(() => createProjectSelector(props.projectId), [props.projectId]), - ); - const cwd = thread?.worktreePath ?? project?.cwd ?? null; - - const [selected, setSelected] = useState(null); - - // No fixed polling: turn-driven file changes already push-invalidate the - // working-tree-diff cache (see __root.tsx), and focus + the Refresh button + - // post-mutation invalidation cover the rest. This keeps the pane cheap. - const stagedQuery = useQuery(gitWorkingTreeDiffQueryOptions({ cwd, scope: "staged" })); - const unstagedQuery = useQuery(gitWorkingTreeDiffQueryOptions({ cwd, scope: "unstaged" })); - - const stagedFiles = useMemo( - () => parsePatchToSortedFiles(stagedQuery.data?.patch, `git-pane:staged:${theme}`), - [stagedQuery.data?.patch, theme], - ); - const unstagedFiles = useMemo( - () => parsePatchToSortedFiles(unstagedQuery.data?.patch, `git-pane:unstaged:${theme}`), - [unstagedQuery.data?.patch, theme], - ); - - const stageMutation = useMutation(gitStageFilesMutationOptions({ cwd, queryClient })); - const unstageMutation = useMutation(gitUnstageFilesMutationOptions({ cwd, queryClient })); - const mutating = stageMutation.isPending || unstageMutation.isPending; - - const stage = useCallback( - (paths: string[]) => { - if (!cwd || paths.length === 0) return; - stageMutation.mutate(paths); - }, - [cwd, stageMutation], - ); - const unstage = useCallback( - (paths: string[]) => { - if (!cwd || paths.length === 0) return; - unstageMutation.mutate(paths); - }, - [cwd, unstageMutation], - ); - - const selectStaged = useCallback((file: FileDiffMetadata) => { - setSelected({ section: "staged", path: resolveFileDiffPath(file) }); - }, []); - const selectUnstaged = useCallback((file: FileDiffMetadata) => { - setSelected({ section: "unstaged", path: resolveFileDiffPath(file) }); - }, []); - - const refresh = useCallback(() => { - if (!cwd) return; - void queryClient.invalidateQueries({ queryKey: gitQueryKeys.workingTreeDiff(cwd, "staged") }); - void queryClient.invalidateQueries({ - queryKey: gitQueryKeys.workingTreeDiff(cwd, "unstaged"), - }); - }, [cwd, queryClient]); - - // Resolve the selected file by path, preferring its stored section but falling - // back to the other list so the diff (and row highlight) follow a file across a - // stage/unstage move instead of silently clearing. - const selectedResolved = useMemo(() => { - if (!selected) return null; - const findInSection = (section: GitPanelSection) => - (section === "staged" ? stagedFiles : unstagedFiles).find( - (file) => resolveFileDiffPath(file) === selected.path, - ) ?? null; - const preferred = findInSection(selected.section); - if (preferred) { - return { section: selected.section, file: preferred }; - } - const otherSection: GitPanelSection = selected.section === "staged" ? "unstaged" : "staged"; - const fallback = findInSection(otherSection); - return fallback ? { section: otherSection, file: fallback } : null; - }, [selected, stagedFiles, unstagedFiles]); - const selectedFileDiff = selectedResolved?.file ?? null; - const selectedPath = selected?.path ?? null; - - const isLoading = stagedQuery.isLoading || unstagedQuery.isLoading; - const error = - stagedQuery.error instanceof Error - ? stagedQuery.error.message - : unstagedQuery.error instanceof Error - ? unstagedQuery.error.message - : null; - const hasChanges = stagedFiles.length > 0 || unstagedFiles.length > 0; - - if (!cwd) { - return Source control is unavailable for this thread.; - } - - return ( -
- - - - } - /> - -
- {error ? ( - - {error} - - ) : null} - {!error && isLoading && !hasChanges ? ( -

Loading changes...

- ) : null} - {!error && !isLoading && !hasChanges ? ( -

- No changes in the working tree. -

- ) : null} - {hasChanges ? ( - <> - - - - ) : null} -
- -
- {selectedFileDiff ? ( - - ) : ( - Select a file to view its diff. - )} -
-
- ); -} - -export default GitPanel; diff --git a/apps/web/src/components/chat/InlineMentionChip.tsx b/apps/web/src/components/chat/InlineMentionChip.tsx index 2eaf8bb32..853caa315 100644 --- a/apps/web/src/components/chat/InlineMentionChip.tsx +++ b/apps/web/src/components/chat/InlineMentionChip.tsx @@ -22,7 +22,6 @@ import { MentionChipIcon, type MentionChipKind } from "./MentionChipIcon"; interface InlineMentionChipProps { path: string; - theme: "light" | "dark"; kind?: MentionChipKind; mentionReferences?: ReadonlyArray; /** Defaults to the path basename (composer-style label). */ @@ -42,7 +41,6 @@ export const InlineMentionChip = memo(function InlineMentionChip(props: InlineMe icon={ diff --git a/apps/web/src/components/chat/MentionChipIcon.tsx b/apps/web/src/components/chat/MentionChipIcon.tsx index 63eeea6c1..6ad6f19f6 100644 --- a/apps/web/src/components/chat/MentionChipIcon.tsx +++ b/apps/web/src/components/chat/MentionChipIcon.tsx @@ -26,13 +26,10 @@ function composerMentionChipCentralIconName(path: string, kind: MentionChipKind return getFileIconName(path); } -// `theme` is retained for call-site compatibility but no longer affects icon -// selection (Central icons are theme-agnostic `currentColor` glyphs). // `className` lets callers size the glyph per surface (composer token vs timeline // echo) while keeping the file/folder/plugin selection logic in one place. export const MentionChipIcon = memo(function MentionChipIcon(props: { path: string; - theme: "light" | "dark"; kind?: MentionChipKind; mentionReferences?: ReadonlyArray; className?: string; diff --git a/apps/web/src/components/chat/MessageTrail.tsx b/apps/web/src/components/chat/MessageTrail.tsx deleted file mode 100644 index c5a3bb9ae..000000000 --- a/apps/web/src/components/chat/MessageTrail.tsx +++ /dev/null @@ -1,105 +0,0 @@ -// FILE: MessageTrail.tsx -// Purpose: Quiet, explicit previous/next navigation between sent messages. -// Layer: Chat transcript shell (presentation) -// Depends on: the existing active trail store and timeline scroll controller. - -import { type MessageId } from "@t3tools/contracts"; -import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; - -import { ChevronDownIcon, ChevronUpIcon } from "~/lib/icons"; -import { cn } from "~/lib/utils"; -import { type ActiveTrailStore, type MessageTrailItem } from "./messageTrail.logic"; - -interface MessageTrailProps { - items: readonly MessageTrailItem[]; - /** Stable holder for current + visible highlights; only this control re-renders on change. */ - activeStore: ActiveTrailStore; - onSelect: (messageId: MessageId) => void; -} - -// Only render beside a transcript column with enough left gutter to avoid covering copy. -const MIN_PANE_WIDTH_PX = 864; - -export function MessageTrail({ items, activeStore, onSelect }: MessageTrailProps) { - const rootRef = useRef(null); - const [hasGutter, setHasGutter] = useState(false); - const trailSnapshot = useSyncExternalStore( - activeStore.subscribe, - activeStore.get, - activeStore.get, - ); - const currentIndex = useMemo(() => { - const directIndex = items.findIndex((item) => item.id === trailSnapshot.currentId); - if (directIndex >= 0) return directIndex; - const firstVisibleId = trailSnapshot.visibleIds[0]; - const visibleIndex = items.findIndex((item) => item.id === firstVisibleId); - return visibleIndex >= 0 ? visibleIndex : 0; - }, [items, trailSnapshot.currentId, trailSnapshot.visibleIds]); - - useEffect(() => { - const pane = rootRef.current?.parentElement; - if (!pane || typeof ResizeObserver === "undefined") return; - - let frameId: number | null = null; - const measure = () => { - frameId = null; - setHasGutter(pane.clientWidth >= MIN_PANE_WIDTH_PX); - }; - const scheduleMeasure = () => { - if (frameId === null) frameId = window.requestAnimationFrame(measure); - }; - scheduleMeasure(); - const observer = new ResizeObserver(scheduleMeasure); - observer.observe(pane); - return () => { - if (frameId !== null) window.cancelAnimationFrame(frameId); - observer.disconnect(); - }; - }, []); - - const visible = hasGutter && items.length > 1; - const previous = currentIndex > 0 ? items[currentIndex - 1] : undefined; - const next = currentIndex < items.length - 1 ? items[currentIndex + 1] : undefined; - - return ( - - ); -} diff --git a/apps/web/src/components/chat/MessagesTimeline.ledger.browser.tsx b/apps/web/src/components/chat/MessagesTimeline.ledger.browser.tsx index b51831cf3..5088c13e4 100644 --- a/apps/web/src/components/chat/MessagesTimeline.ledger.browser.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.ledger.browser.tsx @@ -76,7 +76,6 @@ describe("MessagesTimeline activity trail", () => { isRevertingCheckpoint={false} onImageExpand={() => {}} markdownCwd={undefined} - resolvedTheme="dark" timestampFormat="locale" workspaceRoot={undefined} /> diff --git a/apps/web/src/components/chat/MessagesTimeline.markerScroll.browser.tsx b/apps/web/src/components/chat/MessagesTimeline.markerScroll.browser.tsx deleted file mode 100644 index a23679892..000000000 --- a/apps/web/src/components/chat/MessagesTimeline.markerScroll.browser.tsx +++ /dev/null @@ -1,125 +0,0 @@ -// FILE: MessagesTimeline.markerScroll.browser.tsx -// Purpose: Browser regressions for marker deep-link scrolling in duplicated transcript panes. -// Layer: Vitest browser tests - -import "../../index.css"; - -import { MessageId, ThreadMarkerId, type ThreadMarker } from "@t3tools/contracts"; -import { createRef, type RefObject } from "react"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { render } from "vitest-browser-react"; - -import { MessagesTimeline, type MessagesTimelineController } from "./MessagesTimeline"; - -const ASSISTANT_MESSAGE_ID = MessageId.makeUnsafe("assistant-shared-marker-message"); -const MARKER_ID = ThreadMarkerId.makeUnsafe("marker-shared-between-panes"); -const MESSAGE_TEXT = "Before the exact marker text and after."; -const MARKER_TEXT = "exact marker text"; -const MARKER_START_OFFSET = MESSAGE_TEXT.indexOf(MARKER_TEXT); - -const marker: ThreadMarker = { - id: MARKER_ID, - messageId: ASSISTANT_MESSAGE_ID, - startOffset: MARKER_START_OFFSET, - endOffset: MARKER_START_OFFSET + MARKER_TEXT.length, - selectedText: MARKER_TEXT, - style: "highlight", - color: "yellow", - label: null, - done: false, - createdAt: "2026-06-06T00:00:00.000Z", - updatedAt: "2026-06-06T00:00:00.000Z", -}; - -function MarkerTimeline({ - controllerRef, -}: { - controllerRef: RefObject; -}) { - return ( - {}} - onOpenTurnDiff={() => {}} - revertTurnCountByUserMessageId={new Map()} - onRevertUserMessage={() => {}} - isRevertingCheckpoint={false} - onImageExpand={() => {}} - markdownCwd={undefined} - resolvedTheme="light" - timestampFormat="locale" - workspaceRoot={undefined} - /> - ); -} - -describe("MessagesTimeline marker fine-scroll", () => { - afterEach(() => { - document.body.innerHTML = ""; - vi.restoreAllMocks(); - }); - - it("scrolls the marker inside the controller's own timeline pane", async () => { - const leftControllerRef = createRef(); - const rightControllerRef = createRef(); - // Captured via a holder object so the outer-scope reads keep `Element | null` (a bare `let` - // assigned only inside the mock callback narrows to `null` at use sites). - const scrolled: { element: Element | null } = { element: null }; - vi.spyOn(Element.prototype, "scrollIntoView").mockImplementation(function scrollIntoViewMock( - this: Element, - _options?: boolean | ScrollIntoViewOptions, - ) { - scrolled.element = this; - }); - - await render( -
-
- -
-
- -
-
, - ); - - await expect.poll(() => rightControllerRef.current !== null).toBe(true); - rightControllerRef.current?.scrollToMarker(marker); - - await expect - .poll(() => scrolled.element?.closest("[data-pane]")?.getAttribute("data-pane")) - .toBe("right"); - expect(scrolled.element?.getAttribute("data-thread-marker-id")).toBe(MARKER_ID); - // The deep-link "active" ring is decorated imperatively (no markdown re-parse) on the jumped span. - expect(scrolled.element?.classList.contains("thread-marker-active")).toBe(true); - }); -}); diff --git a/apps/web/src/components/chat/MessagesTimeline.messageEnter.browser.tsx b/apps/web/src/components/chat/MessagesTimeline.messageEnter.browser.tsx index 6adf31afb..9be362a7d 100644 --- a/apps/web/src/components/chat/MessagesTimeline.messageEnter.browser.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.messageEnter.browser.tsx @@ -67,7 +67,6 @@ function MessageEnterTimeline() { isRevertingCheckpoint={false} onImageExpand={() => {}} markdownCwd={undefined} - resolvedTheme="dark" timestampFormat="locale" workspaceRoot={undefined} /> @@ -106,7 +105,6 @@ function HydratingTimeline() { isRevertingCheckpoint={false} onImageExpand={() => {}} markdownCwd={undefined} - resolvedTheme="dark" timestampFormat="locale" workspaceRoot={undefined} /> diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index dd700b086..e38d1c7df 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -108,7 +108,6 @@ describe("MessagesTimeline", () => { isRevertingCheckpoint={false} onImageExpand={() => {}} markdownCwd={undefined} - resolvedTheme="light" timestampFormat="locale" workspaceRoot={undefined} />, @@ -156,7 +155,6 @@ describe("MessagesTimeline", () => { isRevertingCheckpoint={false} onImageExpand={() => {}} markdownCwd={undefined} - resolvedTheme="light" timestampFormat="locale" workspaceRoot={undefined} />, @@ -198,7 +196,6 @@ describe("MessagesTimeline", () => { isRevertingCheckpoint={false} onImageExpand={() => {}} markdownCwd={undefined} - resolvedTheme="light" timestampFormat="locale" workspaceRoot={undefined} />, @@ -232,7 +229,6 @@ describe("MessagesTimeline", () => { isRevertingCheckpoint: false, onImageExpand: () => {}, markdownCwd: undefined, - resolvedTheme: "light" as const, timestampFormat: "locale" as const, workspaceRoot: undefined, }; @@ -355,7 +351,6 @@ describe("MessagesTimeline", () => { isRevertingCheckpoint={false} onImageExpand={() => {}} markdownCwd={undefined} - resolvedTheme="light" timestampFormat="locale" workspaceRoot={undefined} />, @@ -413,7 +408,6 @@ describe("MessagesTimeline", () => { isRevertingCheckpoint={false} onImageExpand={() => {}} markdownCwd={undefined} - resolvedTheme="light" timestampFormat="locale" workspaceRoot={undefined} />, @@ -461,7 +455,6 @@ describe("MessagesTimeline", () => { isRevertingCheckpoint={false} onImageExpand={() => {}} markdownCwd={undefined} - resolvedTheme="light" timestampFormat="locale" workspaceRoot={undefined} />, @@ -507,7 +500,6 @@ describe("MessagesTimeline", () => { isRevertingCheckpoint={false} onImageExpand={() => {}} markdownCwd={undefined} - resolvedTheme="light" timestampFormat="locale" workspaceRoot={undefined} />, @@ -550,7 +542,6 @@ describe("MessagesTimeline", () => { isRevertingCheckpoint={false} onImageExpand={() => {}} markdownCwd={undefined} - resolvedTheme="light" timestampFormat="locale" workspaceRoot={undefined} />, @@ -608,7 +599,6 @@ describe("MessagesTimeline", () => { isRevertingCheckpoint={false} onImageExpand={() => {}} markdownCwd={undefined} - resolvedTheme="light" timestampFormat="locale" workspaceRoot={undefined} />, @@ -650,7 +640,6 @@ describe("MessagesTimeline", () => { isRevertingCheckpoint={false} onImageExpand={() => {}} markdownCwd={undefined} - resolvedTheme="light" timestampFormat="locale" workspaceRoot={undefined} />, @@ -696,7 +685,6 @@ describe("MessagesTimeline", () => { isRevertingCheckpoint={false} onImageExpand={() => {}} markdownCwd={undefined} - resolvedTheme="light" timestampFormat="locale" workspaceRoot={undefined} />, @@ -706,57 +694,6 @@ describe("MessagesTimeline", () => { expect(markup).not.toContain(hiddenTail); }); - it("renders inline terminal labels with the composer chip UI", async () => { - const { MessagesTimeline } = await import("./MessagesTimeline"); - const markup = renderToStaticMarkup( - ", - "- Terminal 1 lines 1-5:", - " 1 | julius@mac effect-http-ws-cli % bun i", - " 2 | bun install v1.3.9 (cf6cdbbb)", - "", - ].join("\n"), - createdAt: "2026-03-17T19:12:28.000Z", - streaming: false, - }, - }, - ]} - turnDiffSummaryByAssistantMessageId={new Map()} - nowIso="2026-03-17T19:12:30.000Z" - expandedWorkGroups={{}} - onToggleWorkGroup={() => {}} - onOpenTurnDiff={() => {}} - revertTurnCountByUserMessageId={new Map()} - onRevertUserMessage={() => {}} - isRevertingCheckpoint={false} - onImageExpand={() => {}} - markdownCwd={undefined} - resolvedTheme="light" - timestampFormat="locale" - workspaceRoot={undefined} - />, - ); - - expect(markup).toContain("Terminal 1 lines 1-5"); - expect(markup).toContain("/central-icons-reversed/console.svg"); - expect(markup).toContain("yoo what's "); - }); - it("renders assistant selection chips from hidden prompt markup when attachments are missing", async () => { const { MessagesTimeline } = await import("./MessagesTimeline"); const markup = renderToStaticMarkup( @@ -796,7 +733,6 @@ describe("MessagesTimeline", () => { isRevertingCheckpoint={false} onImageExpand={() => {}} markdownCwd={undefined} - resolvedTheme="light" timestampFormat="locale" workspaceRoot={undefined} />, @@ -839,7 +775,6 @@ describe("MessagesTimeline", () => { isRevertingCheckpoint={false} onImageExpand={() => {}} markdownCwd={undefined} - resolvedTheme="light" timestampFormat="locale" workspaceRoot={undefined} />, @@ -882,7 +817,6 @@ describe("MessagesTimeline", () => { isRevertingCheckpoint={false} onImageExpand={() => {}} markdownCwd={undefined} - resolvedTheme="light" timestampFormat="locale" workspaceRoot={undefined} />, @@ -927,7 +861,6 @@ describe("MessagesTimeline", () => { isRevertingCheckpoint={false} onImageExpand={() => {}} markdownCwd={undefined} - resolvedTheme="light" timestampFormat="locale" workspaceRoot={undefined} />, @@ -968,7 +901,6 @@ describe("MessagesTimeline", () => { isRevertingCheckpoint={false} onImageExpand={() => {}} markdownCwd={undefined} - resolvedTheme="light" timestampFormat="locale" workspaceRoot={undefined} />, @@ -1023,7 +955,6 @@ describe("MessagesTimeline", () => { isRevertingCheckpoint={false} onImageExpand={() => {}} markdownCwd={undefined} - resolvedTheme="light" timestampFormat="locale" workspaceRoot={undefined} />, @@ -1082,7 +1013,6 @@ describe("MessagesTimeline", () => { isRevertingCheckpoint={false} onImageExpand={() => {}} markdownCwd={undefined} - resolvedTheme="light" timestampFormat="locale" workspaceRoot={undefined} />, @@ -1192,7 +1122,6 @@ describe("MessagesTimeline", () => { isRevertingCheckpoint={false} onImageExpand={() => {}} markdownCwd={undefined} - resolvedTheme="light" timestampFormat="locale" workspaceRoot={undefined} />, @@ -1254,7 +1183,6 @@ describe("MessagesTimeline", () => { isRevertingCheckpoint={false} onImageExpand={() => {}} markdownCwd={undefined} - resolvedTheme="light" timestampFormat="locale" workspaceRoot={undefined} />, @@ -1306,7 +1234,6 @@ describe("MessagesTimeline", () => { isRevertingCheckpoint={false} onImageExpand={() => {}} markdownCwd={undefined} - resolvedTheme="light" timestampFormat="locale" workspaceRoot={undefined} />, @@ -1416,7 +1343,6 @@ describe("MessagesTimeline", () => { isRevertingCheckpoint={false} onImageExpand={() => {}} markdownCwd={undefined} - resolvedTheme="light" timestampFormat="locale" workspaceRoot={undefined} />, @@ -1492,7 +1418,6 @@ describe("MessagesTimeline", () => { isRevertingCheckpoint={false} onImageExpand={() => {}} markdownCwd={undefined} - resolvedTheme="light" timestampFormat="locale" workspaceRoot={undefined} />, @@ -1595,7 +1520,6 @@ describe("MessagesTimeline", () => { isRevertingCheckpoint={false} onImageExpand={() => {}} markdownCwd={undefined} - resolvedTheme="light" timestampFormat="locale" workspaceRoot={undefined} />, @@ -1680,7 +1604,6 @@ describe("MessagesTimeline", () => { isRevertingCheckpoint={false} onImageExpand={() => {}} markdownCwd={undefined} - resolvedTheme="dark" timestampFormat="locale" workspaceRoot={undefined} />, @@ -1734,7 +1657,6 @@ describe("MessagesTimeline", () => { isRevertingCheckpoint={false} onImageExpand={() => {}} markdownCwd={undefined} - resolvedTheme="dark" timestampFormat="locale" workspaceRoot={undefined} />, @@ -1782,7 +1704,6 @@ describe("MessagesTimeline", () => { isRevertingCheckpoint={false} onImageExpand={() => {}} markdownCwd={undefined} - resolvedTheme="dark" timestampFormat="locale" workspaceRoot={undefined} />, @@ -1846,7 +1767,6 @@ describe("MessagesTimeline", () => { isRevertingCheckpoint={false} onImageExpand={() => {}} markdownCwd={undefined} - resolvedTheme="dark" timestampFormat="locale" workspaceRoot={undefined} />, @@ -1898,7 +1818,6 @@ describe("MessagesTimeline", () => { isRevertingCheckpoint={false} onImageExpand={() => {}} markdownCwd={undefined} - resolvedTheme="dark" timestampFormat="locale" workspaceRoot={undefined} />, @@ -2038,7 +1957,6 @@ describe("MessagesTimeline", () => { isRevertingCheckpoint={false} onImageExpand={() => {}} markdownCwd={undefined} - resolvedTheme="dark" timestampFormat="locale" workspaceRoot={undefined} />, @@ -2085,7 +2003,6 @@ describe("MessagesTimeline", () => { isRevertingCheckpoint={false} onImageExpand={() => {}} markdownCwd={undefined} - resolvedTheme="dark" timestampFormat="locale" workspaceRoot={undefined} />, @@ -2130,7 +2047,6 @@ describe("MessagesTimeline", () => { isRevertingCheckpoint={false} onImageExpand={() => {}} markdownCwd={undefined} - resolvedTheme="dark" timestampFormat="locale" workspaceRoot={undefined} />, @@ -2175,7 +2091,6 @@ describe("MessagesTimeline", () => { isRevertingCheckpoint={false} onImageExpand={() => {}} markdownCwd={undefined} - resolvedTheme="dark" timestampFormat="locale" workspaceRoot={undefined} />, @@ -2221,7 +2136,6 @@ describe("MessagesTimeline", () => { isRevertingCheckpoint={false} onImageExpand={() => {}} markdownCwd={undefined} - resolvedTheme="dark" timestampFormat="locale" workspaceRoot={undefined} />, @@ -2265,7 +2179,6 @@ describe("MessagesTimeline", () => { isRevertingCheckpoint={false} onImageExpand={() => {}} markdownCwd={undefined} - resolvedTheme="dark" timestampFormat="locale" workspaceRoot={undefined} />, @@ -2348,7 +2261,6 @@ describe("MessagesTimeline", () => { isRevertingCheckpoint={false} onImageExpand={() => {}} markdownCwd={undefined} - resolvedTheme="dark" timestampFormat="locale" workspaceRoot={undefined} />, @@ -2435,7 +2347,6 @@ describe("MessagesTimeline", () => { isRevertingCheckpoint={false} onImageExpand={() => {}} markdownCwd={undefined} - resolvedTheme="dark" timestampFormat="locale" workspaceRoot={undefined} />, @@ -2499,7 +2410,6 @@ describe("MessagesTimeline", () => { isRevertingCheckpoint={false} onImageExpand={() => {}} markdownCwd={undefined} - resolvedTheme="dark" timestampFormat="locale" workspaceRoot={undefined} />, diff --git a/apps/web/src/components/chat/MessagesTimeline.toolDetails.browser.tsx b/apps/web/src/components/chat/MessagesTimeline.toolDetails.browser.tsx index 04a2dcd67..7ff6e93c1 100644 --- a/apps/web/src/components/chat/MessagesTimeline.toolDetails.browser.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.toolDetails.browser.tsx @@ -50,7 +50,6 @@ function ToolDetailsTimeline() { isRevertingCheckpoint={false} onImageExpand={() => {}} markdownCwd={undefined} - resolvedTheme="dark" timestampFormat="locale" workspaceRoot={undefined} /> diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index ac71958ce..fea376981 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -112,9 +112,7 @@ import { LinkChipIcon } from "../LinkChipIcon"; import { openWorkspaceFileReference, useWorkspaceFileOpener } from "../../lib/workspaceFileOpener"; import { isAgentActivityWorkEntry } from "./agentActivity.logic"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; -import { TerminalContextInlineChip } from "./TerminalContextInlineChip"; import { deriveDisplayedUserMessageState } from "~/lib/composerMessageContext"; -import { type ParsedTerminalContextEntry } from "~/lib/terminalContext"; import { cn } from "~/lib/utils"; import { DEFAULT_CHAT_FONT_SIZE_PX, @@ -127,11 +125,6 @@ import { ENVIRONMENT_CONTENT_INSET_MOTION_CLASS, } from "./composerPickerStyles"; import { formatShortTimestamp } from "../../timestampFormat"; -import { - buildInlineTerminalContextText, - formatInlineTerminalContextLabel, - textContainsInlineTerminalContextLabels, -} from "./userMessageTerminalContexts"; import { splitPromptIntoDisplaySegments } from "~/composer-editor-mentions"; import { getChatMessageFooterTextStyle, @@ -154,11 +147,6 @@ import { summarizeWorkTrail, type SequencedWorkTrailEntry, } from "./workTrail"; -import { - resolveActiveTrailSnapshot, - type ActiveTrailSnapshot, - type MessageTrailAnchor, -} from "./messageTrail.logic"; const MAX_VISIBLE_INLINE_TOOL_ENTRIES = 4; // Changed-files list in the per-turn card is capped so large turns stay compact; @@ -193,7 +181,6 @@ const MESSAGE_SEND_ENTER_CLEANUP_BUFFER_MS = 60; // Treat any partially visible row (>= 1px) as in view, so the navigation trail's // "active" tick tracks the topmost rendered row rather than waiting for a turn to // be substantially on-screen. -const TRAIL_VIEWABILITY_CONFIG = { itemVisiblePercentThreshold: 0 } as const; // The deep-link "active" ring is applied imperatively to the rendered marker spans so jumping // never re-parses a message's markdown tree (the className is purely a CSS box-shadow). const ACTIVE_MARKER_CLASS_NAME = "thread-marker-active"; @@ -449,7 +436,6 @@ interface MessagesTimelineProps { onImageExpand: (preview: ExpandedImagePreview) => void; onIsAtEndChange?: (isAtEnd: boolean) => void; /** Emits current + visible sent-message anchors as the viewport scrolls (drives the trail). */ - onTrailHighlightsChange?: (snapshot: ActiveTrailSnapshot) => void; onMessagesClickCapture?: ComponentProps["onClickCapture"]; onMessagesMouseUp?: ComponentProps["onMouseUp"]; onMessagesPointerCancel?: ComponentProps["onPointerCancel"]; @@ -461,7 +447,6 @@ interface MessagesTimelineProps { onMessagesTouchStart?: ComponentProps["onTouchStart"]; onMessagesWheel?: ComponentProps["onWheel"]; markdownCwd: string | undefined; - resolvedTheme: "light" | "dark"; chatFontSizePx?: number; timestampFormat: TimestampFormat; workspaceRoot: string | undefined; @@ -504,7 +489,6 @@ export const MessagesTimeline = memo(function MessagesTimeline({ isRevertingCheckpoint, onImageExpand, onIsAtEndChange, - onTrailHighlightsChange, onMessagesClickCapture, onMessagesMouseUp, onMessagesPointerCancel, @@ -516,7 +500,6 @@ export const MessagesTimeline = memo(function MessagesTimeline({ onMessagesTouchStart, onMessagesWheel, markdownCwd, - resolvedTheme, chatFontSizePx = DEFAULT_CHAT_FONT_SIZE_PX, timestampFormat, workspaceRoot, @@ -874,72 +857,16 @@ export const MessagesTimeline = memo(function MessagesTimeline({ window.cancelAnimationFrame(frameId); }; }, [onIsAtEndChange, resolvedListRef, rows.length]); - // Sent-message anchors (id + position in the virtualized row list) for the - // navigation trail. Held in a ref so the viewability callback stays stable and - // doesn't re-subscribe LegendList on every transcript change. - const userMessageAnchors = useMemo(() => { - const anchors: MessageTrailAnchor[] = []; - rows.forEach((row, index) => { - if (row.kind === "message" && row.message.role === "user") { - anchors.push({ id: row.message.id, rowIndex: index }); - } - }); - return anchors; - }, [rows]); - const userMessageAnchorsRef = useRef(userMessageAnchors); - userMessageAnchorsRef.current = userMessageAnchors; - const emitTrailHighlightsForViewport = useCallback( - (topRowIndex: number, bottomRowIndex: number) => { - if (!onTrailHighlightsChange || !Number.isFinite(topRowIndex)) { - return; - } - onTrailHighlightsChange( - resolveActiveTrailSnapshot(userMessageAnchorsRef.current, topRowIndex, bottomRowIndex), - ); - }, - [onTrailHighlightsChange], - ); const handleListScroll = useCallback>( (event) => { onMessagesScroll?.(event); const state = resolvedListRef.current?.getState?.(); if (state) { onIsAtEndChange?.(state.isAtEnd); - emitTrailHighlightsForViewport(state.start, state.end); - } - }, - [emitTrailHighlightsForViewport, onIsAtEndChange, onMessagesScroll, resolvedListRef], - ); - const handleViewableItemsChanged = useCallback< - NonNullable["onViewableItemsChanged"]> - >( - ({ viewableItems }) => { - let topIndex = Number.POSITIVE_INFINITY; - let bottomIndex = Number.NEGATIVE_INFINITY; - for (const token of viewableItems) { - if (token.isViewable) { - topIndex = Math.min(topIndex, token.index); - bottomIndex = Math.max(bottomIndex, token.index); - } } - emitTrailHighlightsForViewport(topIndex, bottomIndex); }, - [emitTrailHighlightsForViewport], + [onIsAtEndChange, onMessagesScroll, resolvedListRef], ); - useEffect(() => { - if (!onTrailHighlightsChange) { - return; - } - const frameId = window.requestAnimationFrame(() => { - const state = resolvedListRef.current?.getState?.(); - if (state) { - emitTrailHighlightsForViewport(state.start, state.end); - } - }); - return () => { - window.cancelAnimationFrame(frameId); - }; - }, [emitTrailHighlightsForViewport, onTrailHighlightsChange, resolvedListRef, rows.length]); const toggleFileChangesExpanded = useCallback((turnId: TurnId) => { setExpandedFileChangesByTurnId((current) => ({ ...current, @@ -1104,7 +1031,6 @@ export const MessagesTimeline = memo(function MessagesTimeline({ assistantMessageId: selection.assistantMessageId, text: selection.text, })); - const terminalContexts = displayedUserMessage.contexts; const renderedFileComments = displayedUserMessage.fileComments; const renderedPastedTexts = displayedUserMessage.pastedTexts; const userMessagePreview = deriveUserMessagePreviewState( @@ -1114,11 +1040,9 @@ export const MessagesTimeline = memo(function MessagesTimeline({ }, ); const userMessageExpanded = expandedUserMessagesById[row.message.id] ?? false; - const showUserText = - userMessagePreview.text.trim().length > 0 || terminalContexts.length > 0; + const showUserText = userMessagePreview.text.trim().length > 0; const bubbleIsChipOnly = showUserText && - terminalContexts.length === 0 && hasOnlyInlineSkillChips(userMessagePreview.text, row.message.mentions ?? []); const canRevertAgentWork = typeof row.revertTurnCount === "number"; const isEditingThisMessage = editingUserMessageId === row.message.id; @@ -1193,7 +1117,6 @@ export const MessagesTimeline = memo(function MessagesTimeline({ onTimelineImageLoad={ isTailContentRow ? scrollTailExpansionToEnd : ignoreTimelineImageLoad } - resolvedTheme={resolvedTheme} /> ))}
@@ -1220,9 +1143,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ {userMessagePreview.collapsible && ( ); return ( -
+
0 - ? `${props.context.header}\n${props.context.body}` - : props.context.header; - - return ; - }, -); - const UserImageAttachmentThumbnail = memo(function UserImageAttachmentThumbnail(props: { image: Extract[number], { type: "image" }>; userImages: Array< @@ -2294,7 +2197,6 @@ const UserImageAttachmentThumbnail = memo(function UserImageAttachmentThumbnail( >; onImageExpand: (preview: ExpandedImagePreview) => void; onTimelineImageLoad: () => void; - resolvedTheme: "light" | "dark"; }) { return ( @@ -2334,7 +2231,6 @@ const UserImageAttachmentThumbnail = memo(function UserImageAttachmentThumbnail( function renderUserMessageInlineText( text: string, keyPrefix: string, - resolvedTheme: "light" | "dark", mentionReferences: ReadonlyArray = [], ): ReactNode[] { return splitPromptIntoDisplaySegments(text, mentionReferences).flatMap((segment, index) => { @@ -2350,7 +2246,6 @@ function renderUserMessageInlineText( , @@ -2485,115 +2380,13 @@ const UserMessageEditForm = memo(function UserMessageEditForm(props: { const UserMessageBody = memo(function UserMessageBody(props: { text: string; mentionReferences: ReadonlyArray; - terminalContexts: ParsedTerminalContextEntry[]; chatTypographyStyle: CSSProperties; - resolvedTheme: "light" | "dark"; }) { - if (props.terminalContexts.length > 0) { - const hasEmbeddedInlineLabels = textContainsInlineTerminalContextLabels( - props.text, - props.terminalContexts, - ); - const inlinePrefix = buildInlineTerminalContextText(props.terminalContexts); - const inlineNodes: ReactNode[] = []; - - if (hasEmbeddedInlineLabels) { - let cursor = 0; - - for (const context of props.terminalContexts) { - const label = formatInlineTerminalContextLabel(context.header); - const matchIndex = props.text.indexOf(label, cursor); - if (matchIndex === -1) { - inlineNodes.length = 0; - break; - } - if (matchIndex > cursor) { - inlineNodes.push( - ...renderUserMessageInlineText( - props.text.slice(cursor, matchIndex), - `user-terminal-context-inline-before:${context.header}:${cursor}`, - props.resolvedTheme, - props.mentionReferences, - ), - ); - } - inlineNodes.push( - , - ); - cursor = matchIndex + label.length; - } - - if (inlineNodes.length > 0) { - if (cursor < props.text.length) { - inlineNodes.push( - ...renderUserMessageInlineText( - props.text.slice(cursor), - `user-message-terminal-context-inline-rest:${cursor}`, - props.resolvedTheme, - props.mentionReferences, - ), - ); - } - - return ( -
- {inlineNodes} -
- ); - } - } - - for (const context of props.terminalContexts) { - inlineNodes.push( - , - ); - inlineNodes.push( - , - ); - } - - if (props.text.length > 0) { - inlineNodes.push( - ...renderUserMessageInlineText( - props.text, - "user-message-terminal-context-inline-text", - props.resolvedTheme, - props.mentionReferences, - ), - ); - } else if (inlinePrefix.length === 0) { - return null; - } - - return ( -
- {inlineNodes} -
- ); - } - if (props.text.length === 0) { return null; } - if ( - props.terminalContexts.length === 0 && - hasOnlyInlineSkillChips(props.text, props.mentionReferences) - ) { + if (hasOnlyInlineSkillChips(props.text, props.mentionReferences)) { return (
@@ -2614,12 +2406,7 @@ const UserMessageBody = memo(function UserMessageBody(props: { className="block max-w-full min-w-0 whitespace-pre-wrap break-words font-system-ui text-foreground" style={props.chatTypographyStyle} > - {renderUserMessageInlineText( - props.text, - "user-message-inline", - props.resolvedTheme, - props.mentionReferences, - )} + {renderUserMessageInlineText(props.text, "user-message-inline", props.mentionReferences)}
); }); diff --git a/apps/web/src/components/chat/MessagesTimeline.worktreeSetup.browser.tsx b/apps/web/src/components/chat/MessagesTimeline.worktreeSetup.browser.tsx index 603509984..d299586b2 100644 --- a/apps/web/src/components/chat/MessagesTimeline.worktreeSetup.browser.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.worktreeSetup.browser.tsx @@ -85,7 +85,6 @@ function WorktreeSetupTimeline() { isRevertingCheckpoint={false} onImageExpand={() => {}} markdownCwd={undefined} - resolvedTheme="dark" timestampFormat="locale" workspaceRoot={undefined} /> @@ -114,7 +113,6 @@ function SetupActionTimeline() { isRevertingCheckpoint={false} onImageExpand={() => {}} markdownCwd={undefined} - resolvedTheme="dark" timestampFormat="locale" workspaceRoot={undefined} /> @@ -142,7 +140,6 @@ function FailedSetupWithoutMessagesTimeline() { isRevertingCheckpoint={false} onImageExpand={() => {}} markdownCwd={undefined} - resolvedTheme="dark" timestampFormat="locale" workspaceRoot={undefined} /> diff --git a/apps/web/src/components/chat/RightDock.tsx b/apps/web/src/components/chat/RightDock.tsx deleted file mode 100644 index 9d99d2fa5..000000000 --- a/apps/web/src/components/chat/RightDock.tsx +++ /dev/null @@ -1,276 +0,0 @@ -// FILE: RightDock.tsx -// Purpose: Tabbed right sidebar shell for project exploration, diffs, and terminals. -// Layer: Chat right-dock UI -// Depends on: ui/sidebar primitive, right-dock pane metadata, and a caller-provided pane renderer. - -import { type CSSProperties, type ReactNode, useEffect, useRef, useState } from "react"; - -import { cn } from "~/lib/utils"; -import { - type DockPaneRuntimeMode, - EMPTY_PANE_ID_SET, - reconcileKeepMountedPaneIds, -} from "~/lib/dockPaneActivation"; -import { PanelRightCloseIcon, PlusIcon } from "~/lib/icons"; -import type { - RightDockPane, - RightDockPaneKind, - RightDockThreadState, -} from "~/rightDockStore.logic"; -import { resolveActivePane } from "~/rightDockStore.logic"; -import { Button } from "../ui/button"; -import { IconButton } from "../ui/icon-button"; -import { Menu, MenuItem, MenuTrigger } from "../ui/menu"; -import { PANEL_SURFACE_CLASS_NAME } from "../ui/surface"; -import { - Sidebar, - SIDEBAR_OFFCANVAS_MOTION_CLASS, - SIDEBAR_OFFCANVAS_MOTION_SUPPRESSED_CLASS, - SidebarProvider, - SidebarRail, -} from "../ui/sidebar"; -import { ComposerPickerMenuPopup } from "./ComposerPickerMenuPopup"; -import { - CHAT_SURFACE_HEADER_ROW_CLASS_NAME, - DOCK_HEADER_ICON_BUTTON_CLASS, - SurfaceTabChip, -} from "./chatHeaderControls"; -import { - getRightDockPaneMeta, - resolveRightDockPaneIcon, - resolveRightDockPaneLabel, -} from "./rightDockPaneMeta"; -import { useDesktopTopBarWindowControlsGutterClassName } from "~/hooks/useDesktopTopBarGutter"; - -interface RightDockProps { - state: RightDockThreadState; - minWidth: number; - defaultWidth: string; - shouldAcceptWidth: (context: { nextWidth: number; wrapper: HTMLElement }) => boolean; - paneLabelOverrides?: Record; - addMenuKinds: readonly RightDockPaneKind[]; - onSelectPane: (paneId: string) => void; - onClosePane: (paneId: string) => void; - onCollapse: () => void; - onOpenChange: (open: boolean) => void; - onAddPane: (kind: RightDockPaneKind) => void; - motionKey?: string; - activePaneRuntimeMode?: DockPaneRuntimeMode; - renderPane: ( - pane: RightDockPane, - context: { runtimeMode: DockPaneRuntimeMode; isActive: boolean }, - ) => ReactNode; -} - -function RightDockTab(props: { - pane: RightDockPane; - label: string; - active: boolean; - onSelect: () => void; - onClose: () => void; -}) { - return ( - - ); -} - -// Persist which keep-mounted panes (e.g. terminals) have been activated so they -// stay in the DOM while another tab is selected, pruned to live panes so closed -// panes drop out and the set never leaks across thread switches. The set is -// reconciled during render on purpose: when a kept pane stops being active it -// must remain in the rendered list on that same render, otherwise it would -// unmount for a frame and lose the very runtime keep-mount is protecting. -function useKeepMountedPaneIds( - panes: readonly RightDockPane[], - activePane: RightDockPane | null, -): ReadonlySet { - const ref = useRef>(EMPTY_PANE_ID_SET); - ref.current = reconcileKeepMountedPaneIds({ - previous: ref.current, - panes, - activePaneId: activePane?.id ?? null, - activePaneKind: activePane?.kind ?? null, - }); - return ref.current; -} - -export function RightDock(props: RightDockProps) { - const activePane = resolveActivePane(props.state); - const activePaneRuntimeMode = props.activePaneRuntimeMode ?? "live"; - // The dock is the right-most surface when open, so its header sits under the - // fixed Windows caption cluster — reserve the same gutter the chat header uses. - const desktopTopBarWindowControlsGutterClassName = - useDesktopTopBarWindowControlsGutterClassName(); - - const keepMountedPaneIds = useKeepMountedPaneIds(props.state.panes, activePane); - // The dock must open as an exact 50/50 split of the chat shell. The CSS - // default can only approximate half (it cannot observe the resizable left - // sidebar), so on every open we measure the shell row hosting chat + dock and - // pin the dock width to exactly half of it. Mid-session drags still resize - // freely; the next open re-centers the split. - const contentRef = useRef(null); - const minWidth = props.minWidth; - useEffect(() => { - if (!props.state.open) { - return; - } - const wrapper = contentRef.current?.closest("[data-slot='sidebar-wrapper']"); - const shell = wrapper?.parentElement; - if (!wrapper || !shell) { - return; - } - const halfWidth = Math.round(shell.getBoundingClientRect().width / 2); - if (halfWidth > 0) { - wrapper.style.setProperty("--sidebar-width", `${Math.max(minWidth, halfWidth)}px`); - } - }, [props.state.open, minWidth]); - const renderedPanes = props.state.panes.filter( - (pane) => pane.id === activePane?.id || keepMountedPaneIds.has(pane.id), - ); - const [allowChromeMotion, setAllowChromeMotion] = useState(() => !props.state.open); - const [, forceMotionClassRefresh] = useState(0); - const previousMotionKeyRef = useRef(props.motionKey); - const motionKeyChanged = previousMotionKeyRef.current !== props.motionKey; - const shouldSuppressChromeMotion = !allowChromeMotion || motionKeyChanged; - - useEffect(() => { - const hadMotionKeyChange = previousMotionKeyRef.current !== props.motionKey; - previousMotionKeyRef.current = props.motionKey; - - if (!shouldSuppressChromeMotion) { - return; - } - - if (!allowChromeMotion) { - setAllowChromeMotion(true); - } - if (hadMotionKeyChange && allowChromeMotion) { - forceMotionClassRefresh((version) => version + 1); - } - }, [allowChromeMotion, props.motionKey, shouldSuppressChromeMotion]); - - // Smooth drawer-style easing for the open/close slide. `ease-linear` (the - // sidebar default) reads as stepped/janky on the wide dock; this curve front- - // loads motion and settles softly. Applied to both the width gap and the - // sliding container so they stay in lockstep. - const chromeMotionClass = shouldSuppressChromeMotion - ? SIDEBAR_OFFCANVAS_MOTION_SUPPRESSED_CLASS - : SIDEBAR_OFFCANVAS_MOTION_CLASS; - - return ( - - -
-
-
- {props.state.panes.map((pane) => ( - props.onSelectPane(pane.id)} - onClose={() => props.onClosePane(pane.id)} - /> - ))} -
- - - } - > - - - - {props.addMenuKinds.map((kind) => { - const { Icon, label } = getRightDockPaneMeta(kind); - return ( - props.onAddPane(kind)}> - - {label} - - ); - })} - - - - - -
-
- {renderedPanes.map((pane) => { - const isActive = pane.id === activePane?.id; - // Keep-mounted panes that are not the active tab are already - // hydrated, so they render live (just hidden); the active pane uses - // the deferred-aware runtime mode from the activation hook. - const runtimeMode: DockPaneRuntimeMode = isActive ? activePaneRuntimeMode : "live"; - return ( -
- {props.renderPane(pane, { runtimeMode, isActive })} -
- ); - })} -
-
- -
-
- ); -} - -export default RightDock; diff --git a/apps/web/src/components/chat/SubagentCardList.tsx b/apps/web/src/components/chat/SubagentCardList.tsx index 9cb3e6f4d..49c1e64aa 100644 --- a/apps/web/src/components/chat/SubagentCardList.tsx +++ b/apps/web/src/components/chat/SubagentCardList.tsx @@ -108,7 +108,7 @@ export const SubagentCardList = memo(function SubagentCardList({ return (
diff --git a/apps/web/src/components/chat/TerminalContextInlineChip.tsx b/apps/web/src/components/chat/TerminalContextInlineChip.tsx deleted file mode 100644 index 2dffae730..000000000 --- a/apps/web/src/components/chat/TerminalContextInlineChip.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import { TerminalIcon } from "~/lib/icons"; - -import { cn } from "~/lib/utils"; -import { - COMPOSER_INLINE_CHIP_CLASS_NAME, - COMPOSER_INLINE_CHIP_ICON_CLASS_NAME, - COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME, -} from "../composerInlineChip"; -import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; - -interface TerminalContextInlineChipProps { - label: string; - tooltipText: string; - expired?: boolean; -} - -export function TerminalContextInlineChip(props: TerminalContextInlineChipProps) { - const { label, tooltipText, expired = false } = props; - - return ( - - - - {label} - - } - /> - - {tooltipText} - - - ); -} diff --git a/apps/web/src/components/chat/TraitsPicker.browser.tsx b/apps/web/src/components/chat/TraitsPicker.browser.tsx index 4979a7f8e..802b2a923 100644 --- a/apps/web/src/components/chat/TraitsPicker.browser.tsx +++ b/apps/web/src/components/chat/TraitsPicker.browser.tsx @@ -92,7 +92,6 @@ async function mountClaudePicker(props?: { nonPersistedImageIds: [], persistedAttachments: [], assistantSelections: [], - terminalContexts: [], fileComments: [], pastedTexts: [], skills: [], @@ -326,7 +325,6 @@ async function mountCodexPicker(props: { model?: string; options?: CodexModelOpt nonPersistedImageIds: [], persistedAttachments: [], assistantSelections: [], - terminalContexts: [], fileComments: [], pastedTexts: [], skills: [], @@ -661,7 +659,6 @@ async function mountOpenCodePicker(props?: { files: [], nonPersistedImageIds: [], persistedAttachments: [], - terminalContexts: [], fileComments: [], pastedTexts: [], skills: [], diff --git a/apps/web/src/components/chat/chatHeaderControls.tsx b/apps/web/src/components/chat/chatHeaderControls.tsx index 5d62f104e..adc13df3c 100644 --- a/apps/web/src/components/chat/chatHeaderControls.tsx +++ b/apps/web/src/components/chat/chatHeaderControls.tsx @@ -101,7 +101,7 @@ export const CHAT_SURFACE_CONTROL_HOVER_CLASS_NAME = */ export const CHAT_SURFACE_CHIP_CLASS_NAME = cn( CHAT_HEADER_CONTROL_CLASS_NAME, - "!h-8 gap-1.5 rounded-[7px] border-0 px-2.5 text-[length:var(--app-font-size-ui-sm,13px)] font-normal transition-colors duration-menu ease-out", + "!h-8 gap-1.5 rounded-lg border-0 px-2.5 text-[length:var(--app-font-size-ui-sm,13px)] font-normal transition-colors duration-menu ease-out", CHAT_SURFACE_CONTROL_IDLE_TEXT_CLASS_NAME, CHAT_SURFACE_CONTROL_HOVER_CLASS_NAME, ); @@ -136,7 +136,7 @@ export const CHAT_HEADER_TOGGLE_CLASS_NAME = cn( * touch more breathing room than the symmetric chip base. */ export const DOCK_TAB_CHIP_CLASS_NAME = cn( CHAT_SURFACE_CHIP_CLASS_NAME, - "inline-flex min-w-0 items-center rounded-[8px] border border-transparent pr-2.5 data-[active=true]:border-panel-border data-[active=true]:bg-panel data-[active=true]:text-foreground", + "inline-flex min-w-0 items-center rounded-lg border border-transparent pr-2.5 data-[active=true]:border-panel-border data-[active=true]:bg-panel data-[active=true]:text-foreground", ); /** Icon slot for dock tabs — bare larger icon at rest; on hover a circular disc + X appears. diff --git a/apps/web/src/components/chat/composerPickerStyles.ts b/apps/web/src/components/chat/composerPickerStyles.ts index df2e0b53a..ef87b0aa8 100644 --- a/apps/web/src/components/chat/composerPickerStyles.ts +++ b/apps/web/src/components/chat/composerPickerStyles.ts @@ -46,7 +46,7 @@ export const COMPOSER_PICKER_MODEL_LIST_SCROLL_CLASS_NAME = "composer-picker-scr export const COMPOSER_PICKER_RADIUS_CLASS_NAME = "rounded-xl"; /** Tighter corner radius for option rows / selection pills inside picker panels. */ -export const COMPOSER_PICKER_OPTION_RADIUS_CLASS_NAME = "rounded-[0.5rem]"; +export const COMPOSER_PICKER_OPTION_RADIUS_CLASS_NAME = "rounded-lg"; /** Collapsible section headers inside model provider lists. */ export const COMPOSER_PICKER_MODEL_GROUP_HEADER_CLASS_NAME = `grid w-full grid-cols-[0.75rem_minmax(0,1fr)_2.5rem] items-center gap-x-1.5 ${COMPOSER_PICKER_RADIUS_CLASS_NAME} px-2 py-1 text-left text-[11px] font-medium text-muted-foreground/80 outline-none transition-colors hover:bg-hover focus-visible:ring-0`; diff --git a/apps/web/src/components/chat/environment/EnvironmentRow.tsx b/apps/web/src/components/chat/environment/EnvironmentRow.tsx index 2eed06487..30c71302d 100644 --- a/apps/web/src/components/chat/environment/EnvironmentRow.tsx +++ b/apps/web/src/components/chat/environment/EnvironmentRow.tsx @@ -24,7 +24,7 @@ import { * trigger and a plain button row are visually identical. */ export const ENVIRONMENT_ROW_CLASS_NAME = cn( - "flex min-h-[38px] w-full cursor-pointer items-center gap-2 rounded-[8px] px-2.5 py-1 text-left", + "flex min-h-[38px] w-full cursor-pointer items-center gap-2 rounded-lg px-2.5 py-1 text-left", "text-[length:var(--app-font-size-ui,14px)] font-normal text-[var(--color-text-foreground)]", "outline-none transition-colors", "hover:bg-[var(--color-background-elevated-secondary)]", @@ -100,7 +100,7 @@ export function EnvironmentCollapsibleSection({ 0 - ? { attachments: Array.from({ length: attachmentCount }, () => ({}) as never) } - : {}), - }, - } as TimelineEntry; -} - -function workEntry(id: string): TimelineEntry { - return { - id, - kind: "work", - createdAt: "2026-01-01T00:00:00Z", - entry: {} as never, - } as TimelineEntry; -} - -function anchor(id: string, rowIndex: number): MessageTrailAnchor { - return { id: MessageId.makeUnsafe(id), rowIndex }; -} - -describe("deriveMessageTrailItems", () => { - it("keeps only user messages, in order, with sequential ordinals", () => { - const items = deriveMessageTrailItems([ - messageEntry("u1", "user", "first"), - messageEntry("a1", "assistant", "reply"), - workEntry("w1"), - messageEntry("u2", "user", "second"), - messageEntry("s1", "system", "system note"), - ]); - - expect(items.map((item) => item.id)).toEqual([ - MessageId.makeUnsafe("u1"), - MessageId.makeUnsafe("u2"), - ]); - expect(items.map((item) => item.ordinal)).toEqual([1, 2]); - }); - - it("collapses whitespace and caps very long previews", () => { - const [single] = deriveMessageTrailItems([ - messageEntry("u1", "user", " hello\n\n world\t! "), - ]); - expect(single?.preview).toBe("hello world !"); - - const long = "x".repeat(400); - const [capped] = deriveMessageTrailItems([messageEntry("u2", "user", long)]); - expect(capped?.preview.endsWith("…")).toBe(true); - expect(capped?.preview.length).toBeLessThanOrEqual(281); - }); - - it("does not add an ellipsis when the collapsed text lands exactly on the cap", () => { - // Long enough to take the bounded-window fast path, but the trailing - // whitespace collapses away so the real preview is exactly at the cap and - // nothing was actually truncated. - const exactlyAtCap = "a".repeat(280) + "\n".repeat(2000); - const [item] = deriveMessageTrailItems([messageEntry("u1", "user", exactlyAtCap)]); - expect(item?.preview).toBe("a".repeat(280)); - expect(item?.preview.endsWith("…")).toBe(false); - }); - - it("matches an unwindowed collapse for whitespace-heavy text past the window", () => { - // The fast path's window under-fills here, so it must fall back rather than - // return a short preview. - const text = `${" ".repeat(3000)}${"z".repeat(50)}`; - const [item] = deriveMessageTrailItems([messageEntry("u1", "user", text)]); - expect(item?.preview).toBe("z".repeat(50)); - }); - - it("reports attachment counts", () => { - const [item] = deriveMessageTrailItems([messageEntry("u1", "user", "look", 3)]); - expect(item?.attachmentCount).toBe(3); - }); - - it("captures the turn's final assistant message (end-of-turn reply, not the preamble)", () => { - const items = deriveMessageTrailItems([ - messageEntry("u1", "user", "first question"), - messageEntry("a1", "assistant", " opening preamble "), - messageEntry("a2", "assistant", "final answer after the work"), - messageEntry("u2", "user", "second question"), - messageEntry("s1", "system", "system note"), - messageEntry("u3", "user", "third question"), - ]); - - // Last reply per turn wins (a2, not the a1 preamble); turns with no reply stay empty. - expect(items.map((item) => item.responsePreview)).toEqual([ - "final answer after the work", - "", - "", - ]); - }); - - it("ignores a trailing empty assistant row so the last real reply stays the end-of-turn text", () => { - const [item] = deriveMessageTrailItems([ - messageEntry("u1", "user", "ask"), - messageEntry("a1", "assistant", "preamble"), - messageEntry("a2", "assistant", "real final reply"), - messageEntry("a3", "assistant", " "), - ]); - expect(item?.responsePreview).toBe("real final reply"); - }); -}); - -describe("resolveActiveTrailMessageId", () => { - const anchors = [anchor("u1", 0), anchor("u2", 4), anchor("u3", 9)]; - - it("returns null when there are no anchors", () => { - expect(resolveActiveTrailMessageId([], 5)).toBeNull(); - }); - - it("returns the last anchor at or above the topmost visible row", () => { - expect(resolveActiveTrailMessageId(anchors, 0)).toBe(MessageId.makeUnsafe("u1")); - expect(resolveActiveTrailMessageId(anchors, 3)).toBe(MessageId.makeUnsafe("u1")); - expect(resolveActiveTrailMessageId(anchors, 4)).toBe(MessageId.makeUnsafe("u2")); - expect(resolveActiveTrailMessageId(anchors, 12)).toBe(MessageId.makeUnsafe("u3")); - }); - - it("updates in both scroll directions from the current top visible row", () => { - const scrollPath = [0, 3, 4, 8, 9, 12, 8, 4, 3, 0]; - - expect(scrollPath.map((rowIndex) => resolveActiveTrailMessageId(anchors, rowIndex))).toEqual([ - MessageId.makeUnsafe("u1"), - MessageId.makeUnsafe("u1"), - MessageId.makeUnsafe("u2"), - MessageId.makeUnsafe("u2"), - MessageId.makeUnsafe("u3"), - MessageId.makeUnsafe("u3"), - MessageId.makeUnsafe("u2"), - MessageId.makeUnsafe("u2"), - MessageId.makeUnsafe("u1"), - MessageId.makeUnsafe("u1"), - ]); - }); - - it("falls back to the first anchor when the viewport sits above it", () => { - expect(resolveActiveTrailMessageId([anchor("u1", 2), anchor("u2", 5)], 0)).toBe( - MessageId.makeUnsafe("u1"), - ); - }); -}); - -describe("resolveActiveTrailSnapshot", () => { - const anchors = [anchor("u1", 0), anchor("u2", 4), anchor("u3", 9), anchor("u4", 14)]; - - it("returns the current anchor plus every sent message visible in the viewport", () => { - expect(resolveActiveTrailSnapshot(anchors, 3, 10)).toEqual({ - currentId: MessageId.makeUnsafe("u1"), - visibleIds: [MessageId.makeUnsafe("u2"), MessageId.makeUnsafe("u3")], - }); - }); - - it("keeps the current anchor even when no sent-message row is directly visible", () => { - expect(resolveActiveTrailSnapshot(anchors, 5, 8)).toEqual({ - currentId: MessageId.makeUnsafe("u2"), - visibleIds: [], - }); - }); - - it("tracks visible sent messages as the viewport moves up and down", () => { - expect([ - resolveActiveTrailSnapshot(anchors, 0, 4).visibleIds, - resolveActiveTrailSnapshot(anchors, 4, 14).visibleIds, - resolveActiveTrailSnapshot(anchors, 0, 4).visibleIds, - ]).toEqual([ - [MessageId.makeUnsafe("u1"), MessageId.makeUnsafe("u2")], - [MessageId.makeUnsafe("u2"), MessageId.makeUnsafe("u3"), MessageId.makeUnsafe("u4")], - [MessageId.makeUnsafe("u1"), MessageId.makeUnsafe("u2")], - ]); - }); -}); - -describe("createActiveTrailStore", () => { - it("notifies subscribers only when the value actually changes", () => { - const store = createActiveTrailStore(); - let notifications = 0; - const unsubscribe = store.subscribe(() => { - notifications += 1; - }); - - expect(store.get()).toEqual({ currentId: null, visibleIds: [] }); - - store.set({ - currentId: MessageId.makeUnsafe("u1"), - visibleIds: [MessageId.makeUnsafe("u1"), MessageId.makeUnsafe("u2")], - }); - store.set({ - currentId: MessageId.makeUnsafe("u1"), - visibleIds: [MessageId.makeUnsafe("u1"), MessageId.makeUnsafe("u2")], - }); // duplicate snapshot — no notification - expect(store.get()).toEqual({ - currentId: MessageId.makeUnsafe("u1"), - visibleIds: [MessageId.makeUnsafe("u1"), MessageId.makeUnsafe("u2")], - }); - expect(notifications).toBe(1); - - store.set({ - currentId: MessageId.makeUnsafe("u1"), - visibleIds: [MessageId.makeUnsafe("u2")], - }); - expect(notifications).toBe(2); - - store.set(null); - expect(store.get()).toEqual({ currentId: null, visibleIds: [] }); - expect(notifications).toBe(3); - - unsubscribe(); - store.set({ currentId: MessageId.makeUnsafe("u3"), visibleIds: [] }); - expect(notifications).toBe(3); - expect(store.get()).toEqual({ currentId: MessageId.makeUnsafe("u3"), visibleIds: [] }); - }); -}); - -const allFinite = (values: readonly number[]) => values.every((v) => Number.isFinite(v)); - -describe("computeTrailGeometry", () => { - it("returns null for N=0", () => { - expect(computeTrailGeometry({ count: 0 })).toBeNull(); - }); - - it("places a single tick at the top padding with no spacing (N=1)", () => { - const geom = computeTrailGeometry({ count: 1, paddingPx: 12 }); - expect(geom).toEqual({ startY: 12, spacing: 0, centerYs: [12], contentHeight: 24 }); - }); - - it("lays ticks out at the fixed spacing from the top padding (N=2)", () => { - const geom = computeTrailGeometry({ count: 2, spacingPx: 10, paddingPx: 12 })!; - expect(geom.spacing).toBe(10); - expect(geom.startY).toBe(12); - expect(geom.centerYs).toEqual([12, 22]); - expect(geom.contentHeight).toBe(34); // 2*12 + 1*10 - }); - - it("keeps the spacing fixed for many messages and grows the content height", () => { - const spacing = 10; - const padding = 12; - const count = 200; - const geom = computeTrailGeometry({ count, spacingPx: spacing, paddingPx: padding })!; - expect(geom.spacing).toBe(spacing); // never compressed to fit - expect(geom.centerYs[0]).toBe(padding); - expect(geom.centerYs[count - 1]).toBe(padding + (count - 1) * spacing); - expect(geom.contentHeight).toBe(2 * padding + (count - 1) * spacing); - expect(allFinite(geom.centerYs)).toBe(true); - }); -}); - -describe("computeSigma", () => { - it("tracks spacing within the density-aware clamp", () => { - expect(computeSigma(9)).toBeCloseTo(13.5, 5); // clamp(13.5, min(18,8)=8, 22) - expect(computeSigma(2)).toBeCloseTo(4, 5); // clamp(3, min(4,8)=4, 22) -> floor wins - expect(computeSigma(0.5)).toBeCloseTo(1, 5); // clamp(0.75, min(1,8)=1, 22) -> 1 - expect(computeSigma(20)).toBeCloseTo(22, 5); // clamp(30, 8, 22) -> upper cap - }); -}); - -describe("computeGaussianWeights", () => { - const centerYs = [0, 10, 20, 30, 40]; - - it("peaks at exactly 1 under the pointer and stays within [0,1]", () => { - const weights = computeGaussianWeights(centerYs, 20, 7); - expect(weights[2]).toBe(1); - expect(weights.every((w) => w >= 0 && w <= 1)).toBe(true); - }); - - it("is symmetric around the pointer", () => { - const weights = computeGaussianWeights(centerYs, 20, 7); - expect(weights[1]).toBeCloseTo(weights[3]!, 10); - expect(weights[0]).toBeCloseTo(weights[4]!, 10); - }); -}); - -describe("computeTickStyles", () => { - const baseW = 14; - const maxW = 34; - const rest = 0.38; - const anchor = 0.9; - - it("grows the focused tick to maxW but keeps its rest colour (size changes, not opacity)", () => { - const [style] = computeTickStyles([1], null, baseW, maxW, rest, anchor); - expect(style!.width).toBeCloseTo(maxW, 5); - expect(style!.opacity).toBeCloseTo(rest, 5); // colour never follows the cursor - }); - - it("keeps opacity fixed per state regardless of weight (anchor dark, others rest)", () => { - // Even the magnified anchor (weight 1) stays at anchor opacity, not brighter. - const styles = computeTickStyles([1, 0.5, 0], 0, baseW, maxW, rest, anchor); - expect(styles.map((s) => s.opacity)).toEqual([anchor, rest, rest]); - expect(styles[0]!.width).toBeCloseTo(maxW, 5); - expect(styles[2]!.width).toBeCloseTo(baseW, 5); - }); -}); - -describe("computeRestStyles", () => { - it("brightens only the anchor tick", () => { - const styles = computeRestStyles(3, 1, 14, 0.38, 0.9); - expect(styles.map((s) => s.opacity)).toEqual([0.38, 0.9, 0.38]); - expect(styles.every((s) => s.width === 14)).toBe(true); - }); - - it("leaves all ticks at rest when there is no anchor", () => { - const styles = computeRestStyles(2, null, 14, 0.38, 0.9); - expect(styles.map((s) => s.opacity)).toEqual([0.38, 0.38]); - }); -}); - -describe("computeFocusedIndex", () => { - const geom: TrailGeometry = { - startY: 100, - spacing: 10, - centerYs: [100, 110, 120, 130, 140], - contentHeight: 264, - }; - - it("returns 0 for a single/degenerate rail", () => { - expect( - computeFocusedIndex(999, { startY: 50, spacing: 0, centerYs: [50], contentHeight: 100 }), - ).toBe(0); - }); - - it("maps pointer position to the nearest tick", () => { - expect(computeFocusedIndex(100, geom)).toBe(0); - expect(computeFocusedIndex(124, geom)).toBe(2); - expect(computeFocusedIndex(140, geom)).toBe(4); - }); - - it("clamps out-of-range pointers to the first/last tick (never negative or N)", () => { - expect(computeFocusedIndex(-500, geom)).toBe(0); - expect(computeFocusedIndex(99999, geom)).toBe(4); - }); - - it("is finite-safe for a NaN pointer", () => { - expect(computeFocusedIndex(Number.NaN, geom)).toBe(0); - }); -}); - -describe("clampTooltipTop", () => { - it("keeps the tooltip on-screen near the edges and untouched in the middle", () => { - expect(clampTooltipTop(10, 56, 500, 4)).toBe(32); - expect(clampTooltipTop(490, 56, 500, 4)).toBe(468); - expect(clampTooltipTop(250, 56, 500, 4)).toBe(250); - }); -}); - -describe("clampNumber", () => { - it("clamps, and is finite-safe / range-safe", () => { - expect(clampNumber(5, 0, 10)).toBe(5); - expect(clampNumber(-1, 0, 10)).toBe(0); - expect(clampNumber(11, 0, 10)).toBe(10); - expect(clampNumber(Number.NaN, 2, 8)).toBe(2); - expect(clampNumber(5, 10, 0)).toBe(10); // inverted range -> min - }); -}); diff --git a/apps/web/src/components/chat/messageTrail.logic.ts b/apps/web/src/components/chat/messageTrail.logic.ts deleted file mode 100644 index b0731880c..000000000 --- a/apps/web/src/components/chat/messageTrail.logic.ts +++ /dev/null @@ -1,428 +0,0 @@ -// FILE: messageTrail.logic.ts -// Purpose: Pure helpers for the left-edge message navigation trail — project the -// timeline into one tick per sent message and resolve which tick is active. -// Layer: Chat transcript shell (presentation-adjacent logic, unit-tested) -// Depends on: timeline entry shape only — no React, no DOM. - -import { type MessageId } from "@t3tools/contracts"; -import { type TimelineEntry } from "../../session-logic"; - -/** One tick on the navigation trail — a single message the user sent. */ -export interface MessageTrailItem { - id: MessageId; - /** 1-based position among sent messages, used for labels/aria. */ - ordinal: number; - /** Whitespace-normalized, length-capped text for the hover preview (the sent message). */ - preview: string; - /** - * Whitespace-normalized, length-capped start of the turn's final assistant message - * — the muted second line in the hover card. This is the end-of-turn reply (the - * message that lands after the turn's work), not the opening preamble. Empty when - * the turn has produced no assistant text yet. - */ - responsePreview: string; - /** Number of attachments on the message (rendered as a small hint). */ - attachmentCount: number; -} - -/** Hard cap so a pathological paste can't bloat the hover-card payload. */ -const MAX_PREVIEW_LENGTH = 280; - -/** - * How much raw text we collapse before giving up on the fast path. Whitespace - * runs shrink, so a window can under-fill the cap; 8x leaves room for prose - * while keeping the regex bounded regardless of message size. - */ -const PREVIEW_SCAN_WINDOW = MAX_PREVIEW_LENGTH * 8; - -function normalizePreview(text: string): string { - // The cap is applied *after* collapsing, so a naive implementation scans and - // reallocates the entire message body to produce at most 280 characters. - // Collapse a bounded window first and only fall back to the full scan when - // that window couldn't fill the cap (whitespace-dominated text). - if (text.length > PREVIEW_SCAN_WINDOW) { - const windowed = text.slice(0, PREVIEW_SCAN_WINDOW).replace(/\s+/g, " ").trim(); - // Strictly greater, matching the full-scan branch below. At exactly - // MAX_PREVIEW_LENGTH the text fits and must NOT get an ellipsis — `>=` here - // appended one to any message whose collapsed prefix landed on exactly 280. - if (windowed.length > MAX_PREVIEW_LENGTH) { - return `${windowed.slice(0, MAX_PREVIEW_LENGTH).trimEnd()}…`; - } - } - const collapsed = text.replace(/\s+/g, " ").trim(); - return collapsed.length > MAX_PREVIEW_LENGTH - ? `${collapsed.slice(0, MAX_PREVIEW_LENGTH).trimEnd()}…` - : collapsed; -} - -/** - * Previews are re-derived on every store flush (10x/second while streaming) for - * every message in the transcript, so this cache is what keeps the cost bounded - * to the tail message. - * - * Keyed on message id + text, NOT on the message object: two live paths hand us - * a freshly allocated object every derive — `stripProposedPlanBlocksFromText` - * spreads every assistant message in a turn that has a proposed plan, and the - * attachment-preview handoff spreads while in flight — so an identity-keyed - * WeakMap had a 0% hit rate on exactly the threads that need it most. - * - * The text comparison is cheap in the common case: while streaming, the length - * changes on every delta, so a mismatch is rejected on the length check. - */ -const MAX_CACHED_PREVIEWS = 1_000; -const previewByMessageId = new Map(); - -function cachedPreview(messageId: string, text: string): string { - const cached = previewByMessageId.get(messageId); - if (cached !== undefined && cached.text === text) { - return cached.preview; - } - const preview = normalizePreview(text); - // Map iterates in insertion order, so the first key is the oldest. - if (!cached && previewByMessageId.size >= MAX_CACHED_PREVIEWS) { - const oldest = previewByMessageId.keys().next(); - if (!oldest.done) previewByMessageId.delete(oldest.value); - } - previewByMessageId.set(messageId, { text, preview }); - return preview; -} - -/** - * Project the timeline into one trail item per user message, in transcript order. - * Each item also carries the start of its turn's *final* assistant message (the muted - * second line in the hover card) — the reply that lands after the turn's work, not the - * opening preamble. System / work rows are skipped, and a turn with no assistant text - * yet keeps an empty `responsePreview`. - */ -export function deriveMessageTrailItems( - timelineEntries: readonly TimelineEntry[], -): MessageTrailItem[] { - const items: MessageTrailItem[] = []; - // Index of the user item whose turn we're inside; every non-empty assistant row - // overwrites its response so the last one (the end-of-turn message) wins. - let currentTurnIndex = -1; - for (const entry of timelineEntries) { - if (entry.kind !== "message") { - continue; - } - const { role } = entry.message; - if (role === "user") { - items.push({ - id: entry.message.id, - ordinal: items.length + 1, - preview: cachedPreview(entry.message.id, entry.message.text), - responsePreview: "", - attachmentCount: entry.message.attachments?.length ?? 0, - }); - currentTurnIndex = items.length - 1; - } else if (role === "assistant" && currentTurnIndex >= 0) { - const responsePreview = cachedPreview(entry.message.id, entry.message.text); - if (responsePreview !== "") { - items[currentTurnIndex]!.responsePreview = responsePreview; - } - } - } - return items; -} - -/** A sent-message row paired with its index in the virtualized row list. */ -export interface MessageTrailAnchor { - id: MessageId; - rowIndex: number; -} - -/** - * Resolve the active trail anchor from the topmost row currently in view. - * - * "Active" is the last sent message at or above the top of the viewport — i.e. - * the turn you are currently reading, even when the user bubble itself has - * scrolled above the fold beneath a long assistant reply. Anchors must be sorted - * by ascending `rowIndex` (transcript order); the topmost index is `0`. - */ -export function resolveActiveTrailMessageId( - anchors: readonly MessageTrailAnchor[], - topVisibleRowIndex: number, -): MessageId | null { - if (anchors.length === 0) { - return null; - } - // Default to the first anchor so a viewport sitting above the first sent - // message (e.g. a leading system bubble) still highlights something sensible. - let activeId: MessageId = anchors[0]!.id; - for (const anchor of anchors) { - if (anchor.rowIndex <= topVisibleRowIndex) { - activeId = anchor.id; - } else { - break; - } - } - return activeId; -} - -/** Current reading anchor plus every sent-message row visible in the viewport. */ -export interface ActiveTrailSnapshot { - currentId: MessageId | null; - visibleIds: readonly MessageId[]; -} - -export const EMPTY_ACTIVE_TRAIL_SNAPSHOT: ActiveTrailSnapshot = { - currentId: null, - visibleIds: [], -}; - -function areMessageIdListsEqual(a: readonly MessageId[], b: readonly MessageId[]): boolean { - if (a.length !== b.length) { - return false; - } - for (let i = 0; i < a.length; i += 1) { - if (a[i] !== b[i]) { - return false; - } - } - return true; -} - -export function areActiveTrailSnapshotsEqual( - a: ActiveTrailSnapshot, - b: ActiveTrailSnapshot, -): boolean { - return a.currentId === b.currentId && areMessageIdListsEqual(a.visibleIds, b.visibleIds); -} - -/** - * Resolve both the current reading anchor and the sent messages directly visible - * inside the viewport range. The current anchor can sit above the viewport during - * a long assistant reply; visible ids only include user rows within `[top, bottom]`. - */ -export function resolveActiveTrailSnapshot( - anchors: readonly MessageTrailAnchor[], - topVisibleRowIndex: number, - bottomVisibleRowIndex: number, -): ActiveTrailSnapshot { - if (anchors.length === 0 || !Number.isFinite(topVisibleRowIndex)) { - return EMPTY_ACTIVE_TRAIL_SNAPSHOT; - } - const currentId = resolveActiveTrailMessageId(anchors, topVisibleRowIndex); - const bottomRowIndex = Number.isFinite(bottomVisibleRowIndex) - ? Math.max(topVisibleRowIndex, bottomVisibleRowIndex) - : topVisibleRowIndex; - const visibleIds: MessageId[] = []; - for (const anchor of anchors) { - if (anchor.rowIndex < topVisibleRowIndex) { - continue; - } - if (anchor.rowIndex > bottomRowIndex) { - break; - } - visibleIds.push(anchor.id); - } - return visibleIds.length === 0 && currentId === null - ? EMPTY_ACTIVE_TRAIL_SNAPSHOT - : { currentId, visibleIds }; -} - -/** - * A stable subscribable holder for current + visible trail highlights. - * - * The producer (MessagesTimeline) and consumer (MessageTrail) are siblings under - * a memoized transcript pane. Threading this through that pane's state - * would re-render the heavy timeline on every scroll, so it flows through this - * store instead: the pane creates it once, hands `set` to the timeline and the - * whole store to the trail, and only the trail re-renders on change. - */ -export interface ActiveTrailStore { - get: () => ActiveTrailSnapshot; - set: (value: ActiveTrailSnapshot | null) => void; - subscribe: (listener: () => void) => () => void; -} - -export function createActiveTrailStore(): ActiveTrailStore { - let current: ActiveTrailSnapshot = EMPTY_ACTIVE_TRAIL_SNAPSHOT; - const listeners = new Set<() => void>(); - return { - get: () => current, - set: (value) => { - const next = value ?? EMPTY_ACTIVE_TRAIL_SNAPSHOT; - if (areActiveTrailSnapshotsEqual(next, current)) { - return; - } - current = next; - for (const listener of listeners) { - listener(); - } - }, - subscribe: (listener) => { - listeners.add(listener); - return () => { - listeners.delete(listener); - }; - }, - }; -} - -// --------------------------------------------------------------------------- -// macOS-Dock-style magnification math -// -// The rail magnifies the tick nearest the pointer and tapers neighbours off with -// a Gaussian falloff. Everything here is pure and finite-safe so the same formula -// works for 1 message or 1000: the component just feeds it measured geometry and -// the pointer position. Each function is unit-tested in messageTrail.logic.test.ts. -// --------------------------------------------------------------------------- - -/** Clamp `value` into `[min, max]`, finite-safe (returns `min` if the range is inverted). */ -export function clampNumber(value: number, min: number, max: number): number { - if (!Number.isFinite(value)) { - return min; - } - if (max < min) { - return min; - } - return value < min ? min : value > max ? max : value; -} - -/** Fixed vertical layout of the ticks; widths never affect these positions. */ -export interface TrailGeometry { - /** Vertical centre (px) of the first tick. */ - startY: number; - /** Centre-to-centre distance (px); 0 when there is a single (or degenerate) tick. */ - spacing: number; - /** Vertical centre (px) of every tick, in order. */ - centerYs: number[]; - /** - * Total content height (px) the ticks occupy = `2*padding + (count-1)*spacing`. - * The rail caps its viewport below this and scrolls; deriving it from the count - * (never from the measured viewport) is what keeps geometry free of any - * size→layout→size ResizeObserver feedback loop. - */ - contentHeight: number; -} - -/** - * Lay the ticks out top-down at a fixed `spacingPx`, in their own content space. - * The layout depends only on the message count — never on the measured viewport — - * so the rail can cap + scroll its viewport without feeding height back into the - * layout. Returns `null` for N=0 so the caller can skip all pointer handling. - */ -export function computeTrailGeometry(input: { - count: number; - spacingPx?: number; - paddingPx?: number; -}): TrailGeometry | null { - const count = input.count; - const spacing = count <= 1 ? 0 : (input.spacingPx ?? 10); - const padding = input.paddingPx ?? 12; - if (count <= 0) { - return null; - } - const centerYs: number[] = []; - for (let i = 0; i < count; i += 1) { - centerYs.push(padding + i * spacing); - } - return { - startY: padding, - spacing, - centerYs, - contentHeight: 2 * padding + (count - 1) * spacing, - }; -} - -/** - * Gaussian sigma tied to tick density so the focus radius stays ~1.5 ticks - * whether the rail is sparse or dense: `clamp(spacing*1.5, min(spacing*2, 8), 22)`. - * Irrelevant when `spacing === 0` (single tick) — callers skip the Gaussian then. - */ -export function computeSigma(spacing: number): number { - return clampNumber(spacing * 1.5, Math.min(spacing * 2, 8), 22); -} - -/** Per-tick Gaussian weight in `[0, 1]`; exactly `1` for the tick under the pointer. */ -export function computeGaussianWeights( - centerYs: readonly number[], - pointerY: number, - sigma: number, -): number[] { - if (sigma <= 0) { - return centerYs.map((centerY) => (centerY === pointerY ? 1 : 0)); - } - const twoSigmaSquared = 2 * sigma * sigma; - return centerYs.map((centerY) => { - const distance = centerY - pointerY; - return Math.exp(-(distance * distance) / twoSigmaSquared); - }); -} - -/** Resolved width/opacity for one tick. */ -export interface TickStyle { - width: number; - opacity: number; -} - -/** - * Map Gaussian weights to width only — opacity stays a fixed per-state colour. - * Width lerps `baseW → effectiveMaxW` with the weight (the Dock size effect), but - * the colour never follows the cursor: each tick keeps its state opacity (anchor - * dark, everything else at `restOpacity`); the visible-in-viewport mid tone is - * layered on afterwards by the caller. Only the size changes under the pointer. - */ -export function computeTickStyles( - weights: readonly number[], - currentAnchorIndex: number | null, - baseW: number, - effectiveMaxW: number, - restOpacity: number, - anchorOpacity: number, -): TickStyle[] { - return weights.map((weight, index) => ({ - width: baseW + (effectiveMaxW - baseW) * weight, - opacity: index === currentAnchorIndex ? anchorOpacity : restOpacity, - })); -} - -/** Rest-state styles (pointer away): all `baseW`, anchor brightened. */ -export function computeRestStyles( - count: number, - currentAnchorIndex: number | null, - baseW: number, - restOpacity: number, - anchorOpacity: number, -): TickStyle[] { - const styles: TickStyle[] = []; - for (let i = 0; i < count; i += 1) { - styles.push({ width: baseW, opacity: i === currentAnchorIndex ? anchorOpacity : restOpacity }); - } - return styles; -} - -/** - * Index of the tick nearest `pointerY`. Clamps `pointerY` into the tick range - * first so positions above/below the rail resolve to the first/last tick rather - * than out-of-range. Always returns `0` for a single/degenerate rail. Finite-safe. - */ -export function computeFocusedIndex(pointerY: number, geometry: TrailGeometry): number { - const count = geometry.centerYs.length; - if (count <= 1 || geometry.spacing === 0) { - return 0; - } - if (!Number.isFinite(pointerY)) { - return 0; - } - const endY = geometry.startY + (count - 1) * geometry.spacing; - const clampedY = clampNumber(pointerY, geometry.startY, endY); - const raw = Math.round((clampedY - geometry.startY) / geometry.spacing); - return clampNumber(raw, 0, count - 1); -} - -/** - * Keep the focused-message tooltip fully on-screen by clamping its vertical centre - * into `[tooltipH/2 + margin, railH - tooltipH/2 - margin]` (caller keeps a - * `translateY(-50%)`). Range-safe when the rail is shorter than the tooltip. - */ -export function clampTooltipTop( - centerY: number, - tooltipH: number, - railH: number, - margin = 4, -): number { - const half = tooltipH / 2 + margin; - return clampNumber(centerY, half, Math.max(half, railH - half)); -} diff --git a/apps/web/src/components/chat/rightDockPaneMeta.tsx b/apps/web/src/components/chat/rightDockPaneMeta.tsx deleted file mode 100644 index d505259f5..000000000 --- a/apps/web/src/components/chat/rightDockPaneMeta.tsx +++ /dev/null @@ -1,63 +0,0 @@ -// FILE: rightDockPaneMeta.tsx -// Purpose: Shared semantic metadata (icon + label) for right-dock pane kinds. -// Layer: Chat right-dock UI primitives -// Exports: per-kind meta map, ordered add-menu kinds, and pane label/icon resolvers. - -import type { LucideIcon } from "~/lib/icons"; -import { DiffIcon, FoldersIcon, InfoIcon, TerminalIcon } from "~/lib/icons"; -import { - RIGHT_DOCK_PANE_KINDS, - type RightDockPane, - type RightDockPaneKind, -} from "~/rightDockStore.logic"; -import { SurfaceChipIcon } from "./chatHeaderControls"; - -export interface RightDockPaneMeta { - label: string; - Icon: LucideIcon; -} - -export const RIGHT_DOCK_PANE_META: Record = { - diff: { label: "Diff", Icon: DiffIcon }, - explorer: { label: "Explorer", Icon: FoldersIcon }, - terminal: { label: "Terminal", Icon: TerminalIcon }, -}; - -// Neutral fallback for any pane kind we no longer recognize (e.g. stale -// persisted state). Persisted dock state is sanitized on rehydrate, so this is -// only a defensive guard to keep a single bad pane from crashing render. -const FALLBACK_RIGHT_DOCK_PANE_META: RightDockPaneMeta = { - label: "Panel", - Icon: InfoIcon, -}; - -// Always resolve pane meta through this helper instead of indexing the map -// directly, so an unknown kind degrades gracefully rather than throwing. -export function getRightDockPaneMeta(kind: RightDockPaneKind): RightDockPaneMeta { - return RIGHT_DOCK_PANE_META[kind] ?? FALLBACK_RIGHT_DOCK_PANE_META; -} - -// Add-menu / quick triggers follow the canonical kind order from the single -// source of truth, so they stay in sync as kinds are added or removed. The -// "file" kind is intentionally excluded: single-file preview tabs are opened by -// clicking a file reference in chat, while the add menu offers the richer -// "explorer" pane (file tree + search + viewer) in its place. -export const RIGHT_DOCK_ADD_MENU_KINDS: readonly RightDockPaneKind[] = RIGHT_DOCK_PANE_KINDS; - -// Resolves a tab label, preferring caller-provided per-pane overrides (e.g. the -// embedded sidechat thread title) before falling back to the kind label. -export function resolveRightDockPaneLabel( - pane: RightDockPane, - overrides?: Record, -): string { - return overrides?.[pane.id] ?? getRightDockPaneMeta(pane.kind).label; -} - -// Resolves a tab glyph: file panes show the per-file-type icon (matching the -// pane header and explorer rows), every other pane uses its kind icon. The file -// glyph inherits the tab's muted foreground color (colorMode="inherit") instead -// of its extension color, so dock tabs read like the changed-file rows rather -// than carrying a loud per-type tint. -export function resolveRightDockPaneIcon(pane: RightDockPane) { - return ; -} diff --git a/apps/web/src/components/chat/userMessageTerminalContexts.test.ts b/apps/web/src/components/chat/userMessageTerminalContexts.test.ts deleted file mode 100644 index 110119501..000000000 --- a/apps/web/src/components/chat/userMessageTerminalContexts.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { - buildInlineTerminalContextText, - formatInlineTerminalContextLabel, - textContainsInlineTerminalContextLabels, -} from "./userMessageTerminalContexts"; - -describe("userMessageTerminalContexts", () => { - it("builds plain inline terminal text labels", () => { - expect( - buildInlineTerminalContextText([ - { header: "Terminal 1 lines 12-13" }, - { header: "Terminal 2 line 4" }, - ]), - ).toBe("@terminal-1:12-13 @terminal-2:4"); - }); - - it("formats individual inline terminal labels compactly", () => { - expect(formatInlineTerminalContextLabel("Terminal 1 lines 12-13")).toBe("@terminal-1:12-13"); - expect(formatInlineTerminalContextLabel("Terminal 2 line 4")).toBe("@terminal-2:4"); - }); - - it("detects inline terminal labels embedded in user message text", () => { - expect( - textContainsInlineTerminalContextLabels("yo @terminal-1:12-13 whats up", [ - { header: "Terminal 1 lines 12-13" }, - ]), - ).toBe(true); - expect( - textContainsInlineTerminalContextLabels("yo whats up", [ - { header: "Terminal 1 lines 12-13" }, - ]), - ).toBe(false); - }); -}); diff --git a/apps/web/src/components/chat/userMessageTerminalContexts.ts b/apps/web/src/components/chat/userMessageTerminalContexts.ts deleted file mode 100644 index 978210a53..000000000 --- a/apps/web/src/components/chat/userMessageTerminalContexts.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { formatInlineTerminalContextLabel as formatInlineTerminalContextSelectionLabel } from "~/lib/terminalContext"; - -const TERMINAL_CONTEXT_HEADER_PATTERN = /^(.*?)\s+line(?:s)?\s+(\d+)(?:-(\d+))?$/i; - -export function buildInlineTerminalContextText( - contexts: ReadonlyArray<{ - header: string; - }>, -): string { - return contexts - .map((context) => context.header.trim()) - .filter((header) => header.length > 0) - .map(formatInlineTerminalContextLabel) - .join(" "); -} - -export function formatInlineTerminalContextLabel(header: string): string { - const trimmedHeader = header.trim(); - const match = TERMINAL_CONTEXT_HEADER_PATTERN.exec(trimmedHeader); - if (!match) { - return `@${trimmedHeader.toLowerCase().replace(/\s+/g, "-")}`; - } - - const lineStart = Number.parseInt(match[2] ?? "", 10); - const lineEnd = Number.parseInt(match[3] ?? match[2] ?? "", 10); - if (!Number.isFinite(lineStart) || !Number.isFinite(lineEnd)) { - return `@${trimmedHeader.toLowerCase().replace(/\s+/g, "-")}`; - } - - return formatInlineTerminalContextSelectionLabel({ - terminalLabel: match[1]?.trim() || "terminal", - lineStart, - lineEnd, - }); -} - -export function textContainsInlineTerminalContextLabels( - text: string, - contexts: ReadonlyArray<{ - header: string; - }>, -): boolean { - let searchStartIndex = 0; - - for (const context of contexts) { - const label = formatInlineTerminalContextLabel(context.header); - const matchIndex = text.indexOf(label, searchStartIndex); - if (matchIndex === -1) { - return false; - } - searchStartIndex = matchIndex + label.length; - } - - return true; -} diff --git a/apps/web/src/components/chat/workspaceExplorer.tsx b/apps/web/src/components/chat/workspaceExplorer.tsx deleted file mode 100644 index de5f29646..000000000 --- a/apps/web/src/components/chat/workspaceExplorer.tsx +++ /dev/null @@ -1,745 +0,0 @@ -// FILE: workspaceExplorer.tsx -// Purpose: Shared workspace file-tree explorer + file-search building blocks used -// by both the full editor view and the right-dock explorer pane. -// Layer: Chat workspace-browsing UI primitives -// Exports: WorkspaceFilesSidebar, WorkspaceSearchSidebar, WorkspaceExplorerSidebar, -// ExplorerActivityBarButton, useExplorerEntryPrefetch, setFileReferenceDragData. - -import type { ProjectEntry, ProjectFileSystemEntry } from "@t3tools/contracts"; -import { useDebouncedValue } from "@tanstack/react-pacer"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { - type ComponentPropsWithoutRef, - type DragEvent as ReactDragEvent, - type KeyboardEvent as ReactKeyboardEvent, - type MouseEvent as ReactMouseEvent, - type ReactNode, - forwardRef, - useCallback, -} from "react"; - -import { - CHAT_FILE_REFERENCE_DRAG_TYPE, - formatChatFileReference, - type ChatFileReference, -} from "~/lib/chatReferences"; -import { splitRepoRelativePath } from "~/lib/diffRendering"; -import { showFileReferenceContextMenu } from "~/lib/fileReferenceContextMenu"; -import { - projectListDirectoriesQueryOptions, - projectReadFileQueryOptions, - projectSearchEntriesQueryOptions, -} from "~/lib/projectReactQuery"; -import { getSyntaxHighlighterPromise, getSyntaxLanguageForPath } from "~/lib/syntaxHighlighting"; -import { cn } from "~/lib/utils"; -import { Skeleton } from "../ui/skeleton"; -import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible"; -import { DisclosureChevron } from "../ui/DisclosureChevron"; -import { SearchInput } from "../ui/search-input"; -import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; -import { EXPLORER_ROW_PROPS, useExplorerListNavigation } from "./explorerListNavigation"; -import { FileEntryIcon } from "./FileEntryIcon"; -import { fileRowClassName, fileRowIndentStyle } from "./fileRowStyles"; -import { PanelStateMessage } from "./PanelStateMessage"; - -const EXPLORER_HIDDEN_DIRECTORY_NAMES = new Set([ - ".cache", - ".next", - ".nuxt", - ".parcel-cache", - ".pnpm-store", - ".svelte-kit", - ".turbo", - ".vite", - ".yarn", - "build", - "coverage", - "dist", - "node_modules", - "out", - "target", -]); - -// Mirrors the composer mention search: debounce keystrokes so they don't fan -// out into fuzzy-search RPCs, and cap results to keep the sidebar light. -const EXPLORER_SEARCH_QUERY_DEBOUNCE_MS = 120; -const EXPLORER_SEARCH_RESULTS_LIMIT = 80; -const EMPTY_WORKSPACE_SEARCH_FILE_MATCHES: ReadonlyArray = []; - -// Default sidebar shell: a full-height column in the editor's wide row layout -// that collapses to a stacked block on narrow viewports. Surfaces with a fixed -// horizontal layout (e.g. the right dock) override this via `containerClassName`. -const EXPLORER_SIDEBAR_CONTAINER_CLASS = - "flex min-h-[11rem] w-full shrink-0 flex-col border-b border-border/65 bg-[var(--color-background-surface)] lg:h-full lg:w-56 lg:border-b-0 lg:border-r"; - -// Marks the drag payload so the chat composer can accept it as a reference. -export function setFileReferenceDragData(dataTransfer: DataTransfer, path: string): void { - dataTransfer.effectAllowed = "copy"; - dataTransfer.setData(CHAT_FILE_REFERENCE_DRAG_TYPE, formatChatFileReference({ path })); - dataTransfer.setData("text/plain", path); -} - -function shouldShowExplorerEntry(entry: ProjectFileSystemEntry): boolean { - if (entry.kind !== "directory") { - return true; - } - if (entry.name.startsWith(".chitauri") || entry.name.startsWith(".synara")) { - return false; - } - return !EXPLORER_HIDDEN_DIRECTORY_NAMES.has(entry.name); -} - -/** - * Warms caches for an explorer entry before it is clicked: directory listings - * for folders, file contents plus the matching syntax highlighter for files. - */ -export function useExplorerEntryPrefetch(cwd: string | null) { - const queryClient = useQueryClient(); - return useCallback( - (entry: Pick) => { - if (!cwd) { - return; - } - if (entry.kind === "directory") { - void queryClient.prefetchQuery( - projectListDirectoriesQueryOptions({ - cwd, - relativePath: entry.path, - includeFiles: true, - }), - ); - return; - } - void queryClient.prefetchQuery( - projectReadFileQueryOptions({ cwd, relativePath: entry.path }), - ); - void getSyntaxHighlighterPromise(getSyntaxLanguageForPath(entry.path)).catch(() => undefined); - }, - [cwd, queryClient], - ); -} - -// Forwards its ref and spreads incoming props so directory rows can act as the -// Collapsible trigger (Base UI injects onClick/aria/data + ref onto this element). -const ExplorerRow = forwardRef< - HTMLButtonElement, - { - entry: ProjectFileSystemEntry; - depth: number; - selected: boolean; - expanded: boolean; - onSelectFile: (path: string) => void; - onPrefetchEntry: (entry: ProjectFileSystemEntry) => void; - onEntryContextMenu: (entry: ProjectFileSystemEntry, position: { x: number; y: number }) => void; - } & ComponentPropsWithoutRef<"button"> ->(function ExplorerRow( - { - entry, - depth, - selected, - expanded, - onSelectFile, - onPrefetchEntry, - onEntryContextMenu, - className, - onClick, - ...rest - }, - ref, -) { - const isDirectory = entry.kind === "directory"; - // Directory rows are the Collapsible trigger: chain Base UI's injected onClick - // (which toggles open/close) and skip file selection. File rows open the preview. - const handleClick = useCallback( - (event: ReactMouseEvent) => { - onClick?.(event); - if (isDirectory) { - return; - } - onSelectFile(entry.path); - }, - [entry.path, isDirectory, onClick, onSelectFile], - ); - const handlePrefetch = useCallback(() => { - onPrefetchEntry(entry); - }, [entry, onPrefetchEntry]); - const handleContextMenu = useCallback( - (event: ReactMouseEvent) => { - event.preventDefault(); - onEntryContextMenu(entry, { x: event.clientX, y: event.clientY }); - }, - [entry, onEntryContextMenu], - ); - const handleDragStart = useCallback( - (event: ReactDragEvent) => { - setFileReferenceDragData(event.dataTransfer, entry.path); - }, - [entry.path], - ); - - return ( - - ); -}); - -const EXPLORER_SKELETON_ROW_WIDTHS = ["w-9/12", "w-6/12", "w-7/12"]; - -function ExplorerLoadingRows(props: { depth: number }) { - return ( -
- {EXPLORER_SKELETON_ROW_WIDTHS.map((width) => ( -
- - -
- ))} -
- ); -} - -function WorkspaceDirectory(props: { - cwd: string; - relativePath: string | null; - depth: number; - selectedFilePath: string | null; - expandedDirectories: ReadonlySet; - onSelectFile: (path: string) => void; - onToggleDirectory: (path: string) => void; - onPrefetchEntry: (entry: ProjectFileSystemEntry) => void; - onEntryContextMenu: (entry: ProjectFileSystemEntry, position: { x: number; y: number }) => void; -}) { - const query = useQuery( - projectListDirectoriesQueryOptions({ - cwd: props.cwd, - relativePath: props.relativePath, - includeFiles: true, - }), - ); - - if (query.isLoading && !query.data) { - return ; - } - - if (query.error) { - return ( -

- {query.error instanceof Error ? query.error.message : "Could not load directory."} -

- ); - } - - return ( - <> - {(query.data?.entries ?? []).filter(shouldShowExplorerEntry).map((entry) => { - if (entry.kind !== "directory") { - return ( - - ); - } - const expanded = props.expandedDirectories.has(entry.path); - return ( - props.onToggleDirectory(entry.path)} - > - - } - /> - {/* Keep children mounted only while open (plus the closing transition Base UI - manages) so the height animation plays and lazy listings stay cached. */} - - - - - ); - })} - - ); -} - -// Opening the file-reference context menu from a tree row (full entry) or a -// search-result row (path only). Both wrap the same menu, so they live here -// instead of being re-declared in every sidebar that renders these rows. -function useTreeEntryContextMenu( - onReferenceInChat: ((reference: ChatFileReference) => void) | undefined, -) { - return useCallback( - (entry: ProjectFileSystemEntry, position: { x: number; y: number }) => { - void showFileReferenceContextMenu({ path: entry.path, position, onReferenceInChat }); - }, - [onReferenceInChat], - ); -} - -function useResultEntryContextMenu( - onReferenceInChat: ((reference: ChatFileReference) => void) | undefined, -) { - return useCallback( - (path: string, position: { x: number; y: number }) => { - void showFileReferenceContextMenu({ path, position, onReferenceInChat }); - }, - [onReferenceInChat], - ); -} - -// Scrollable file-tree body, shared by the standalone files sidebar and the -// combined explorer sidebar (which shows it whenever the search box is empty). -function WorkspaceFilesTreeBody(props: { - workspaceRoot: string | null; - selectedFilePath: string | null; - expandedDirectories: ReadonlySet; - onSelectFile: (path: string) => void; - onToggleDirectory: (path: string) => void; - onPrefetchEntry: (entry: ProjectFileSystemEntry) => void; - onEntryContextMenu: (entry: ProjectFileSystemEntry, position: { x: number; y: number }) => void; -}) { - return ( -
- {props.workspaceRoot ? ( - - ) : ( - -

No workspace.

-
- )} -
- ); -} - -export function WorkspaceFilesSidebar(props: { - workspaceRoot: string | null; - selectedFilePath: string | null; - expandedDirectories: ReadonlySet; - containerClassName?: string; - onSelectFile: (path: string) => void; - onToggleDirectory: (path: string) => void; - onReferenceInChat: ((reference: ChatFileReference) => void) | undefined; -}) { - const prefetchEntry = useExplorerEntryPrefetch(props.workspaceRoot); - const handleEntryContextMenu = useTreeEntryContextMenu(props.onReferenceInChat); - const handleListKeyDown = useExplorerListNavigation(); - return ( - - ); -} - -function WorkspaceSearchResultRow(props: { - entry: ProjectEntry; - selected: boolean; - onSelectFile: (path: string) => void; - onPrefetchEntry: (entry: Pick) => void; - onEntryContextMenu: (path: string, position: { x: number; y: number }) => void; -}) { - const { entry, onEntryContextMenu, onPrefetchEntry, onSelectFile } = props; - const { dir, name } = splitRepoRelativePath(entry.path); - const handlePrefetch = useCallback(() => { - onPrefetchEntry(entry); - }, [entry, onPrefetchEntry]); - - return ( - - ); -} - -interface WorkspaceFileSearchState { - // Trimmed live input — drives the "is the box empty?" decision (tree vs results). - inputQuery: string; - fileMatches: ReadonlyArray; - searchResultsPending: boolean; - searchResultsCurrent: boolean; - isFetching: boolean; - error: Error | null; - truncated: boolean; -} - -// Fuzzy file-name search shared by the standalone search sidebar and the -// combined explorer sidebar: debounce keystrokes, then expose the matches plus -// the freshness flags both surfaces need to gate selection on stale results. -function useWorkspaceFileSearch( - workspaceRoot: string | null, - query: string, -): WorkspaceFileSearchState { - const [debouncedQuery] = useDebouncedValue(query, { - wait: EXPLORER_SEARCH_QUERY_DEBOUNCE_MS, - }); - const inputQuery = query.trim(); - const trimmedQuery = debouncedQuery.trim(); - const entriesQuery = useQuery( - projectSearchEntriesQueryOptions({ - cwd: workspaceRoot, - query: trimmedQuery, - kind: "file", - limit: EXPLORER_SEARCH_RESULTS_LIMIT, - }), - ); - // Results are tied to the debounced query. While the user is ahead of that - // query, keep old results non-selectable so Enter cannot open a stale match. - const searchResultsPending = inputQuery !== trimmedQuery || entriesQuery.isPlaceholderData; - const searchResultsCurrent = !searchResultsPending; - const fileMatches = searchResultsCurrent - ? (entriesQuery.data?.entries ?? EMPTY_WORKSPACE_SEARCH_FILE_MATCHES) - : EMPTY_WORKSPACE_SEARCH_FILE_MATCHES; - return { - inputQuery, - fileMatches, - searchResultsPending, - searchResultsCurrent, - isFetching: entriesQuery.isFetching, - error: searchResultsCurrent ? entriesQuery.error : null, - truncated: entriesQuery.data?.truncated ?? false, - }; -} - -// Search-box header: a fixed, full-width input that selects the top match on -// Enter and clears (returning to the tree, in the combined sidebar) on Escape. -function WorkspaceSearchInputHeader(props: { - query: string; - search: WorkspaceFileSearchState; - autoFocus?: boolean; - onQueryChange: (query: string) => void; - onSelectFile: (path: string) => void; -}) { - const { onQueryChange, onSelectFile, query, search } = props; - const handleInputKeyDown = useCallback( - (event: ReactKeyboardEvent) => { - if (event.key === "Enter") { - event.preventDefault(); - if (!search.searchResultsCurrent) { - return; - } - const topMatch = search.fileMatches[0]; - if (topMatch) { - onSelectFile(topMatch.path); - } - return; - } - if (event.key === "Escape" && query.length > 0) { - event.stopPropagation(); - onQueryChange(""); - } - }, - [onQueryChange, onSelectFile, query.length, search.fileMatches, search.searchResultsCurrent], - ); - - return ( -
- onQueryChange(event.target.value)} - onKeyDown={handleInputKeyDown} - /> -
- ); -} - -// Scrollable search-results body (matches list + truncation hint). Callers only -// mount it once the query is non-empty, so the empty-query state lives outside. -function WorkspaceSearchResultsBody(props: { - workspaceRoot: string | null; - search: WorkspaceFileSearchState; - selectedFilePath: string | null; - onSelectFile: (path: string) => void; - onPrefetchEntry: (entry: Pick) => void; - onEntryContextMenu: (path: string, position: { x: number; y: number }) => void; -}) { - const { fileMatches } = props.search; - return ( - <> -
- {!props.workspaceRoot ? ( - -

No workspace.

-
- ) : props.search.searchResultsCurrent && props.search.error ? ( - -

- {props.search.error instanceof Error - ? props.search.error.message - : "Could not search files."} -

-
- ) : fileMatches.length === 0 ? ( - props.search.searchResultsPending || props.search.isFetching ? ( - - ) : ( - -

No matching files.

-
- ) - ) : ( - fileMatches.map((entry) => ( - - )) - )} -
- {fileMatches.length > 0 && props.search.truncated ? ( -

- Showing the top matches. Refine the search to narrow them down. -

- ) : null} - - ); -} - -export function WorkspaceSearchSidebar(props: { - workspaceRoot: string | null; - query: string; - onQueryChange: (query: string) => void; - selectedFilePath: string | null; - containerClassName?: string; - onSelectFile: (path: string) => void; - onReferenceInChat: ((reference: ChatFileReference) => void) | undefined; -}) { - const prefetchEntry = useExplorerEntryPrefetch(props.workspaceRoot); - const handleEntryContextMenu = useResultEntryContextMenu(props.onReferenceInChat); - const handleListKeyDown = useExplorerListNavigation(); - const search = useWorkspaceFileSearch(props.workspaceRoot, props.query); - - return ( - - ); -} - -// Combined explorer: one panel with a fixed search box on top that shows the -// full file tree while empty and switches to fuzzy file-name results as soon as -// the user types — no separate Files/Search activity rail needed. -export function WorkspaceExplorerSidebar(props: { - workspaceRoot: string | null; - selectedFilePath: string | null; - expandedDirectories: ReadonlySet; - query: string; - onQueryChange: (query: string) => void; - containerClassName?: string; - onSelectFile: (path: string) => void; - onToggleDirectory: (path: string) => void; - onReferenceInChat: ((reference: ChatFileReference) => void) | undefined; -}) { - const prefetchEntry = useExplorerEntryPrefetch(props.workspaceRoot); - const handleTreeEntryContextMenu = useTreeEntryContextMenu(props.onReferenceInChat); - const handleResultEntryContextMenu = useResultEntryContextMenu(props.onReferenceInChat); - const handleListKeyDown = useExplorerListNavigation(); - const search = useWorkspaceFileSearch(props.workspaceRoot, props.query); - - return ( - - ); -} - -export function ExplorerActivityBarButton(props: { - label: string; - active: boolean; - onClick: () => void; - children: ReactNode; -}) { - const button = ( - - ); - - return ( - - - {props.label} - - ); -} diff --git a/apps/web/src/components/composer-nodes/index.tsx b/apps/web/src/components/composer-nodes/index.tsx index c1fa09d3c..fa404fc58 100644 --- a/apps/web/src/components/composer-nodes/index.tsx +++ b/apps/web/src/components/composer-nodes/index.tsx @@ -6,7 +6,6 @@ * - ComposerSkillNode: Skill mentions ($skill or /skill) * - ComposerSlashCommandNode: app-level slash commands (/automation) * - ComposerAgentMentionNode: Agent mentions (@alias(task)) - * - ComposerTerminalContextNode: Terminal context blocks */ import { @@ -22,10 +21,6 @@ import { import type { ReactElement } from "react"; import { renderToStaticMarkup } from "react-dom/server"; -import { - INLINE_TERMINAL_CONTEXT_PLACEHOLDER, - type TerminalContextDraft, -} from "~/lib/terminalContext"; import { formatComposerMentionToken } from "~/lib/composerMentions"; import { basenameOfPath } from "~/file-icons"; import { createCentralIconElement } from "~/lib/central-icons"; @@ -44,7 +39,6 @@ import { import { AGENT_ROBOT_ICON_NAME, ClockIcon } from "~/lib/icons"; import type { ComposerSlashCommand } from "~/composerSlashCommands"; import { InlineLinkChip } from "../InlineLinkChip"; -import { ComposerPendingTerminalContextChip } from "../chat/ComposerPendingTerminalContexts"; import { createMentionChipIconElement, type MentionChipKind } from "../chat/MentionChipIcon"; // ── Serialized Types ────────────────────────────────────────────────── @@ -96,15 +90,6 @@ export type SerializedComposerLinkNode = Spread< SerializedLexicalNode >; -export type SerializedComposerTerminalContextNode = Spread< - { - context: TerminalContextDraft; - type: "composer-terminal-context"; - version: 1; - }, - SerializedLexicalNode ->; - // ── Helper Functions ────────────────────────────────────────────────── // Shared boilerplate for the imperative Lexical chip hosts: clear prior content @@ -574,79 +559,12 @@ export function $createComposerLinkNode(url: string): ComposerLinkNode { return $applyNodeReplacement(new ComposerLinkNode(url)); } -// ── ComposerTerminalContextNode ─────────────────────────────────────── - -function ComposerTerminalContextDecorator(props: { context: TerminalContextDraft }) { - return ; -} - -export class ComposerTerminalContextNode extends DecoratorNode { - __context: TerminalContextDraft; - - static override getType(): string { - return "composer-terminal-context"; - } - - static override clone(node: ComposerTerminalContextNode): ComposerTerminalContextNode { - return new ComposerTerminalContextNode(node.__context, node.__key); - } - - static override importJSON( - serializedNode: SerializedComposerTerminalContextNode, - ): ComposerTerminalContextNode { - return $createComposerTerminalContextNode(serializedNode.context); - } - - constructor(context: TerminalContextDraft, key?: NodeKey) { - super(key); - this.__context = context; - } - - override exportJSON(): SerializedComposerTerminalContextNode { - return { - ...super.exportJSON(), - context: this.__context, - type: "composer-terminal-context", - version: 1, - }; - } - - override createDOM(): HTMLElement { - const dom = document.createElement("span"); - dom.className = COMPOSER_INLINE_DECORATOR_HOST_CLASS_NAME; - return dom; - } - - override updateDOM(): false { - return false; - } - - override getTextContent(): string { - return INLINE_TERMINAL_CONTEXT_PLACEHOLDER; - } - - override isInline(): true { - return true; - } - - override decorate(): ReactElement { - return ; - } -} - -export function $createComposerTerminalContextNode( - context: TerminalContextDraft, -): ComposerTerminalContextNode { - return $applyNodeReplacement(new ComposerTerminalContextNode(context)); -} - // ── Type Guards & Utilities ─────────────────────────────────────────── export type ComposerInlineTokenNode = | ComposerMentionNode | ComposerSkillNode | ComposerSlashCommandNode - | ComposerTerminalContextNode | ComposerAgentMentionNode | ComposerLinkNode; @@ -657,7 +575,6 @@ export function isComposerInlineTokenNode( candidate instanceof ComposerMentionNode || candidate instanceof ComposerSkillNode || candidate instanceof ComposerSlashCommandNode || - candidate instanceof ComposerTerminalContextNode || candidate instanceof ComposerAgentMentionNode || candidate instanceof ComposerLinkNode ); @@ -668,7 +585,6 @@ export const COMPOSER_NODE_CLASSES = [ ComposerMentionNode, ComposerSkillNode, ComposerSlashCommandNode, - ComposerTerminalContextNode, ComposerAgentMentionNode, ComposerLinkNode, ] as const; diff --git a/apps/web/src/components/diffPanelSelectors.ts b/apps/web/src/components/diffPanelSelectors.ts deleted file mode 100644 index 4ed1e6c10..000000000 --- a/apps/web/src/components/diffPanelSelectors.ts +++ /dev/null @@ -1,187 +0,0 @@ -// FILE: diffPanelSelectors.ts -// Purpose: Lightweight Zustand selectors for the diff panel — avoid subscribing to the -// full thread (messages/activities) when only catalog or live-refresh signals change. -// Layer: Diff panel data - -import type { MessageId, ThreadId, TurnId } from "@t3tools/contracts"; - -import type { AppState } from "../store"; -import { collectByIds } from "../threadDerivation"; -import type { ChatMessage, Thread, ThreadShell, TurnDiffSummary } from "../types"; -import { resolveDiffPanelRepoLiveRefresh } from "./DiffPanel.logic"; - -const EMPTY_TURN_DIFF_SUMMARIES: TurnDiffSummary[] = []; -const EMPTY_TURN_DIFF_IDS: TurnId[] = []; -const EMPTY_TURN_DIFF_MAP: Record = {}; -const EMPTY_MESSAGES: ChatMessage[] = []; -const EMPTY_ACTIVITIES: Thread["activities"] = []; -const EMPTY_MESSAGE_IDS: MessageId[] = []; -const EMPTY_ACTIVITY_IDS: string[] = []; -const EMPTY_MESSAGE_MAP: Record = {}; -const EMPTY_ACTIVITY_MAP: Record = {}; - -export type DiffPanelThreadCatalog = { - id: ThreadId; - projectId: Thread["projectId"]; - envMode: Thread["envMode"]; - worktreePath: string | null; - branch: string | null; - turnDiffSummaries: TurnDiffSummary[]; -}; - -export function toDiffPanelThreadCatalog(thread: Thread): DiffPanelThreadCatalog { - return { - id: thread.id, - projectId: thread.projectId, - envMode: thread.envMode, - worktreePath: thread.worktreePath, - branch: thread.branch, - turnDiffSummaries: thread.turnDiffSummaries, - }; -} - -export function createDiffPanelThreadCatalogSelector( - threadId: ThreadId | null | undefined, -): (state: AppState) => DiffPanelThreadCatalog | undefined { - let previousShell: ThreadShell | undefined; - let previousTurnDiffSummaries: TurnDiffSummary[] | undefined; - let previousCatalog: DiffPanelThreadCatalog | undefined; - - return (state) => { - if (!threadId) { - return undefined; - } - - const shell = state.threadShellById?.[threadId]; - if (!shell) { - return undefined; - } - - const turnDiffIds = state.turnDiffIdsByThreadId?.[threadId]; - const turnDiffSummaryById = state.turnDiffSummaryByThreadId?.[threadId]; - const turnDiffSummaries = collectByIds( - turnDiffIds ?? EMPTY_TURN_DIFF_IDS, - turnDiffSummaryById ?? EMPTY_TURN_DIFF_MAP, - EMPTY_TURN_DIFF_SUMMARIES, - ); - - if ( - shell === previousShell && - turnDiffSummaries === previousTurnDiffSummaries && - previousCatalog - ) { - return previousCatalog; - } - - previousShell = shell; - previousTurnDiffSummaries = turnDiffSummaries; - previousCatalog = { - id: shell.id, - projectId: shell.projectId, - envMode: shell.envMode, - worktreePath: shell.worktreePath, - branch: shell.branch, - turnDiffSummaries, - }; - return previousCatalog; - }; -} - -function resolveLatestTurnAssistantStreaming(input: { - latestTurnId: TurnId | null; - latestTurnCompletedAt: string | null | undefined; - messageIds: readonly MessageId[] | undefined; - messageById: Record | undefined; -}): boolean { - if (!input.latestTurnId || input.latestTurnCompletedAt != null) { - return false; - } - - const messageIds = input.messageIds ?? EMPTY_MESSAGE_IDS; - const messageById = input.messageById ?? EMPTY_MESSAGE_MAP; - for (let index = messageIds.length - 1; index >= 0; index -= 1) { - const message = messageById[messageIds[index]!]; - if (!message || message.turnId !== input.latestTurnId) { - continue; - } - if (message.role === "assistant") { - return message.streaming === true; - } - } - return false; -} - -function buildDiffPanelRepoLiveRefreshKey(input: { - latestTurn: Thread["latestTurn"]; - session: Thread["session"]; - activityIds: readonly string[] | undefined; - latestTurnAssistantStreaming: boolean; -}): string { - return [ - input.latestTurn?.turnId ?? "", - input.latestTurn?.startedAt ?? "", - input.latestTurn?.completedAt ?? "", - input.latestTurn?.state ?? "", - input.session?.orchestrationStatus ?? "", - input.session?.activeTurnId ?? "", - input.activityIds?.length ?? 0, - input.activityIds?.at(-1) ?? "", - input.latestTurnAssistantStreaming ? "1" : "0", - ].join("|"); -} - -/** Boolean selector: only re-renders when live repo-diff polling should start or stop. */ -export function createDiffPanelRepoLiveRefreshSelector( - threadId: ThreadId | null | undefined, -): (state: AppState) => boolean { - let previousKey: string | undefined; - let previousShouldPoll = false; - - return (state) => { - if (!threadId) { - return false; - } - - const turnState = state.threadTurnStateById?.[threadId]; - const latestTurn = turnState?.latestTurn ?? null; - const session = state.threadSessionById?.[threadId] ?? null; - const messageIds = state.messageIdsByThreadId?.[threadId]; - const messageById = state.messageByThreadId?.[threadId]; - const activityIds = state.activityIdsByThreadId?.[threadId]; - const latestTurnAssistantStreaming = resolveLatestTurnAssistantStreaming({ - latestTurnId: latestTurn?.turnId ?? null, - latestTurnCompletedAt: latestTurn?.completedAt, - messageIds, - messageById, - }); - const key = buildDiffPanelRepoLiveRefreshKey({ - latestTurn, - session, - activityIds, - latestTurnAssistantStreaming, - }); - - if (key === previousKey) { - return previousShouldPoll; - } - - previousKey = key; - const messages = collectByIds( - messageIds ?? EMPTY_MESSAGE_IDS, - messageById ?? EMPTY_MESSAGE_MAP, - EMPTY_MESSAGES, - ); - const activities = collectByIds( - activityIds ?? EMPTY_ACTIVITY_IDS, - state.activityByThreadId?.[threadId] ?? EMPTY_ACTIVITY_MAP, - EMPTY_ACTIVITIES, - ); - previousShouldPoll = resolveDiffPanelRepoLiveRefresh({ - latestTurn, - session, - messages, - activities, - }); - return previousShouldPoll; - }; -} diff --git a/apps/web/src/components/research/ResearchDocumentView.tsx b/apps/web/src/components/research/ResearchDocumentView.tsx index 17a4305b6..e5bec9088 100644 --- a/apps/web/src/components/research/ResearchDocumentView.tsx +++ b/apps/web/src/components/research/ResearchDocumentView.tsx @@ -87,7 +87,7 @@ function ReferenceRail({ references }: { references: readonly ResearchReference[ key={reference.id} type="button" onClick={() => void openReference(reference)} - className="group flex min-h-11 w-full items-start gap-2.5 rounded-[8px] px-2.5 py-2 text-left transition-[background-color,scale] duration-press ease-out hover:bg-hover active:scale-[0.96] motion-reduce:transition-none" + className="group flex min-h-11 w-full items-start gap-2.5 rounded-lg px-2.5 py-2 text-left transition-[background-color,scale] duration-press ease-out hover:bg-hover active:scale-[0.96] motion-reduce:transition-none" > diff --git a/apps/web/src/components/settings/SkillsSettingsPanel.tsx b/apps/web/src/components/settings/SkillsSettingsPanel.tsx deleted file mode 100644 index ae0e391de..000000000 --- a/apps/web/src/components/settings/SkillsSettingsPanel.tsx +++ /dev/null @@ -1,204 +0,0 @@ -// FILE: SkillsSettingsPanel.tsx -// Purpose: Settings → Skills panel. Lists every skill from the unified cross-provider -// catalog (~/.teacode/skills plus each provider's skills folder), shows which provider -// a skill comes from, and lets the user enable/disable each one. Disabled skills are -// hidden from the composer skill picker on every provider. - -import type { ProviderKind, ServerSettings } from "@t3tools/contracts"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { useMemo } from "react"; - -import { ProviderIcon } from "~/components/ProviderIcon"; -import { SettingsRow, SettingsSection } from "~/components/settings/SettingsPanelPrimitives"; -import { Switch } from "~/components/ui/switch"; -import { SkillCubeIcon } from "~/lib/icons"; -import { ensureNativeApi } from "~/nativeApi"; -import { - providerDiscoveryQueryKeys, - skillsCatalogQueryOptions, -} from "~/lib/providerDiscoveryReactQuery"; -import { serverQueryKeys, serverSettingsQueryOptions } from "~/lib/serverReactQuery"; -import { - buildSettingsSkillGroups, - buildSettingsSkillSections, - providerDisplayName, - settingsSkillNameKey, -} from "./skillsSettingsModel"; - -function SkillProviderStack({ providers }: { providers: ReadonlyArray }) { - if (providers.length === 0) { - return null; - } - - const label = providers.map(providerDisplayName).join(", "); - const stackLabel = `Provider ${providers.length === 1 ? "copy" : "copies"}: ${label}`; - return ( - - {providers.map((provider) => ( - - - - ))} - - ); -} - -export function SkillsSettingsPanel() { - const queryClient = useQueryClient(); - const catalogQuery = useQuery(skillsCatalogQueryOptions()); - const serverSettingsQuery = useQuery(serverSettingsQueryOptions()); - - const disabledSkillNames = useMemo( - () => - new Set( - (serverSettingsQuery.data?.skills.disabled ?? []).map((name) => settingsSkillNameKey(name)), - ), - [serverSettingsQuery.data?.skills.disabled], - ); - - const skillGroups = useMemo( - () => buildSettingsSkillGroups(catalogQuery.data?.skills ?? []), - [catalogQuery.data?.skills], - ); - const skillSections = useMemo(() => { - return buildSettingsSkillSections(catalogQuery.data?.skills ?? []); - }, [catalogQuery.data?.skills]); - - const setSkillEnabled = (skillName: string, enabled: boolean) => { - // Read through the query cache (not the render closure) so rapid toggles - // build on each other instead of clobbering the previous patch. - const latestSettings = queryClient.getQueryData(serverQueryKeys.settings()); - const currentDisabled = latestSettings?.skills.disabled ?? [...disabledSkillNames]; - const key = settingsSkillNameKey(skillName); - const next = new Set(currentDisabled.map((name) => settingsSkillNameKey(name))); - if (enabled) { - next.delete(key); - } else { - next.add(key); - } - const disabled = [...next].sort(); - if (latestSettings) { - // Optimistic flip; a failed patch invalidates back to the server state. - queryClient.setQueryData(serverQueryKeys.settings(), { - ...latestSettings, - skills: { disabled }, - }); - } - void ensureNativeApi() - .server.updateSettings({ skills: { disabled } }) - .then((nextSettings) => { - queryClient.setQueryData(serverQueryKeys.settings(), nextSettings); - // Composer skill pickers are served filtered by these toggles. - void queryClient.invalidateQueries({ queryKey: providerDiscoveryQueryKeys.all }); - }) - .catch(() => { - void queryClient.invalidateQueries({ queryKey: serverQueryKeys.settings() }); - }); - }; - - const totalSkills = skillGroups.length; - const enabledSkills = skillGroups.filter((group) => !disabledSkillNames.has(group.key)).length; - const chitauriSkillsDir = catalogQuery.data?.chitauriSkillsDir; - - return ( -
- - - {chitauriSkillsDir} - - ) : null - } - control={ - - {catalogQuery.isLoading - ? "Scanning…" - : `${enabledSkills} of ${totalSkills} skill${totalSkills === 1 ? "" : "s"} enabled`} - - } - /> - - - {catalogQuery.isError ? ( - - - - ) : null} - - {!catalogQuery.isLoading && !catalogQuery.isError && totalSkills === 0 ? ( - - - - ) : null} - - {skillSections.map((section) => { - return ( - - {section.groups.map((group) => { - const enabled = !disabledSkillNames.has(group.key); - return ( - - - ); - })} -
- ); -} diff --git a/apps/web/src/components/settings/skillsSettingsModel.test.ts b/apps/web/src/components/settings/skillsSettingsModel.test.ts deleted file mode 100644 index 8466f04f3..000000000 --- a/apps/web/src/components/settings/skillsSettingsModel.test.ts +++ /dev/null @@ -1,93 +0,0 @@ -// FILE: skillsSettingsModel.test.ts -// Purpose: Locks down Settings -> Skills grouping for duplicate provider skill copies. -// Layer: Web settings logic tests - -import type { ProviderSkillDescriptor } from "@t3tools/contracts"; -import { describe, expect, it } from "vitest"; - -import { buildSettingsSkillGroups, buildSettingsSkillSections } from "./skillsSettingsModel"; - -function skill(partial: Partial): ProviderSkillDescriptor { - return { - name: "example", - enabled: true, - path: "/tmp/example/SKILL.md", - ...partial, - }; -} - -describe("buildSettingsSkillGroups", () => { - it("renders duplicate provider copies as one shared skill group", () => { - const groups = buildSettingsSkillGroups([ - skill({ - name: "check-code", - description: "Codex copy", - path: "/Users/test/.codex/skills/check-code/SKILL.md", - scope: "codex", - }), - skill({ - name: "check-code", - description: "Claude copy", - path: "/Users/test/.claude/skills/check-code/SKILL.md", - scope: "claude", - }), - skill({ - name: "cursor-only", - path: "/Users/test/.cursor/skills/cursor-only/SKILL.md", - scope: "cursor", - }), - ]); - - const shared = groups.find((group) => group.key === "check-code"); - expect(shared?.section).toBe("shared"); - expect(shared?.providers).toEqual(["codex", "claudeAgent"]); - expect(shared?.sources.map((source) => source.origin)).toEqual(["codex", "claude"]); - expect(shared?.sources.map((source) => source.skill.path)).toEqual([ - "/Users/test/.codex/skills/check-code/SKILL.md", - "/Users/test/.claude/skills/check-code/SKILL.md", - ]); - - const cursorOnly = groups.find((group) => group.key === "cursor-only"); - expect(cursorOnly?.section).toBe("cursor"); - expect(cursorOnly?.providers).toEqual(["cursor"]); - }); - - it("does not show provider icons for shared alias-only skills", () => { - const groups = buildSettingsSkillGroups([ - skill({ - name: "portable-review", - description: "Shared standard copy", - path: "/Users/test/.agents/skills/portable-review/SKILL.md", - scope: "agents", - }), - ]); - - expect(groups[0]?.providers).toEqual([]); - expect(groups[0]?.section).toBe("agents"); - }); -}); - -describe("buildSettingsSkillSections", () => { - it("places shared skill groups before provider-only sections", () => { - const sections = buildSettingsSkillSections([ - skill({ - name: "logic-consolidator", - path: "/Users/test/.codex/skills/logic-consolidator/SKILL.md", - scope: "codex", - }), - skill({ - name: "logic-consolidator", - path: "/Users/test/.claude/skills/logic-consolidator/SKILL.md", - scope: "claude", - }), - skill({ - name: "cursor-only", - path: "/Users/test/.cursor/skills/cursor-only/SKILL.md", - scope: "cursor", - }), - ]); - - expect(sections.map((section) => section.title)).toEqual(["Shared skills", "From Cursor"]); - expect(sections[0]?.groups.map((group) => group.key)).toEqual(["logic-consolidator"]); - }); -}); diff --git a/apps/web/src/components/settings/skillsSettingsModel.ts b/apps/web/src/components/settings/skillsSettingsModel.ts deleted file mode 100644 index e1e4ddc2a..000000000 --- a/apps/web/src/components/settings/skillsSettingsModel.ts +++ /dev/null @@ -1,198 +0,0 @@ -// FILE: skillsSettingsModel.ts -// Purpose: Groups duplicate skill copies for Settings -> Skills so shared names render once. -// Layer: Settings UI logic -// Exports: origin metadata, canonical skill grouping, and section ordering helpers. - -import type { ProviderKind, ProviderSkillDescriptor } from "@t3tools/contracts"; -import { PROVIDER_DISPLAY_NAMES } from "@t3tools/contracts"; - -export interface SkillOriginInfo { - readonly label: string; - readonly provider: ProviderKind | null; -} - -export interface SettingsSkillSource { - readonly skill: ProviderSkillDescriptor; - readonly origin: string; - readonly originInfo: SkillOriginInfo; -} - -export interface SettingsSkillGroup { - readonly key: string; - readonly displayName: string; - readonly description: string; - readonly primarySkill: ProviderSkillDescriptor; - readonly providers: ReadonlyArray; - readonly sources: ReadonlyArray; - readonly section: string; -} - -export interface SettingsSkillSection { - readonly key: string; - readonly title: string; - readonly groups: ReadonlyArray; -} - -const SHARED_SKILLS_SECTION = "shared"; -const PERSONAL_ORIGIN = "personal"; -export const ORIGIN_SECTION_ORDER = [ - "chitauri", - "codex", - "claude", - "cursor", - "grok", - "kilo", - "opencode", - "pi", - "agents", - "project", -] as const; -export const PROVIDER_STACK_ORDER: readonly ProviderKind[] = [ - "codex", - "claudeAgent", - "cursor", - "grok", - "kilo", - "opencode", - "pi", -] as const; - -export function skillOriginInfo(scope: string | undefined): SkillOriginInfo { - switch (scope) { - case "chitauri": - case "synara": - return { label: "TeaCode", provider: null }; - case "codex": - return { label: PROVIDER_DISPLAY_NAMES.codex, provider: "codex" }; - case "claude": - return { label: PROVIDER_DISPLAY_NAMES.claudeAgent, provider: "claudeAgent" }; - case "cursor": - return { label: PROVIDER_DISPLAY_NAMES.cursor, provider: "cursor" }; - case "grok": - return { label: PROVIDER_DISPLAY_NAMES.grok, provider: "grok" }; - case "kilo": - return { label: PROVIDER_DISPLAY_NAMES.kilo, provider: "kilo" }; - case "opencode": - return { label: PROVIDER_DISPLAY_NAMES.opencode, provider: "opencode" }; - case "pi": - return { label: PROVIDER_DISPLAY_NAMES.pi, provider: "pi" }; - case "agents": - return { label: "Shared (.agents)", provider: null }; - case "project": - return { label: "Project", provider: null }; - default: - return { label: scope ?? "Personal", provider: null }; - } -} - -export function providersForSkillOrigin(origin: string): ProviderKind[] { - const provider = skillOriginInfo(origin).provider; - return provider ? [provider] : []; -} - -export function settingsSkillNameKey(name: string): string { - return name.trim().toLowerCase(); -} - -export function skillDisplayName(skill: ProviderSkillDescriptor): string { - return skill.interface?.displayName ?? skill.name; -} - -export function providerDisplayName(provider: ProviderKind): string { - return PROVIDER_DISPLAY_NAMES[provider]; -} - -export function sortProviderStack(providers: ReadonlyArray): ProviderKind[] { - return [...providers].sort( - (left, right) => PROVIDER_STACK_ORDER.indexOf(left) - PROVIDER_STACK_ORDER.indexOf(right), - ); -} - -function originRank(origin: string): number { - const index = (ORIGIN_SECTION_ORDER as readonly string[]).indexOf(origin); - return index >= 0 ? index : ORIGIN_SECTION_ORDER.length; -} - -function sourceSortKey(source: SettingsSkillSource): string { - return `${originRank(source.origin).toString().padStart(2, "0")}\u0000${source.skill.path}`; -} - -function sectionTitle(section: string): string { - if (section === SHARED_SKILLS_SECTION) { - return "Shared skills"; - } - return `From ${skillOriginInfo(section).label}`; -} - -function sectionRank(section: string): number { - if (section === SHARED_SKILLS_SECTION) { - return -1; - } - return originRank(section); -} - -// Creates one canonical row per normalized skill name. Duplicate provider copies -// stay visible as sources instead of letting the first origin hide the rest. -export function buildSettingsSkillGroups( - skills: ReadonlyArray, -): SettingsSkillGroup[] { - const groups = new Map(); - for (const skill of skills) { - const key = settingsSkillNameKey(skill.name); - const origin = skill.scope ?? PERSONAL_ORIGIN; - const source: SettingsSkillSource = { - skill, - origin, - originInfo: skillOriginInfo(origin), - }; - groups.set(key, [...(groups.get(key) ?? []), source]); - } - - return [...groups.entries()] - .map(([key, unsortedSources]): SettingsSkillGroup | null => { - const sources = [...unsortedSources].sort((left, right) => - sourceSortKey(left).localeCompare(sourceSortKey(right)), - ); - const primarySkill = sources[0]?.skill; - if (!primarySkill) { - return null; - } - const providers = sortProviderStack( - sources - .flatMap((source) => providersForSkillOrigin(source.origin)) - .filter((provider, index, all) => all.indexOf(provider) === index), - ); - const section = - sources.length > 1 ? SHARED_SKILLS_SECTION : (sources[0]?.origin ?? PERSONAL_ORIGIN); - const description = - primarySkill.interface?.shortDescription ?? primarySkill.description ?? "No description."; - return { - key, - displayName: skillDisplayName(primarySkill), - description, - primarySkill, - providers, - sources, - section, - } satisfies SettingsSkillGroup; - }) - .filter((group): group is SettingsSkillGroup => group !== null) - .sort((left, right) => left.displayName.localeCompare(right.displayName)); -} - -export function buildSettingsSkillSections( - skills: ReadonlyArray, -): SettingsSkillSection[] { - const sections = new Map(); - for (const group of buildSettingsSkillGroups(skills)) { - sections.set(group.section, [...(sections.get(group.section) ?? []), group]); - } - - return [...sections.entries()] - .map(([key, groups]) => ({ - key, - title: sectionTitle(key), - groups, - })) - .sort((left, right) => sectionRank(left.key) - sectionRank(right.key)); -} diff --git a/apps/web/src/components/terminal/LazyThreadTerminalDrawer.tsx b/apps/web/src/components/terminal/LazyThreadTerminalDrawer.tsx deleted file mode 100644 index 41b62a666..000000000 --- a/apps/web/src/components/terminal/LazyThreadTerminalDrawer.tsx +++ /dev/null @@ -1,70 +0,0 @@ -// FILE: LazyThreadTerminalDrawer.tsx -// Purpose: The single code-split boundary for the xterm terminal stack. -// Layer: Chat terminal workspace UI -// Exports: LazyThreadTerminalDrawer (default + named) -// -// Why: `ThreadTerminalDrawer` statically imports `@xterm/xterm`, seven xterm -// addons and `xterm.css` (~794 kB raw). Importing it from ChatView and -// DockTerminalPane pinned all of it into the `/_chat/$threadId` static -// import closure, so every thread paid for the terminal even when no -// terminal was ever opened. Every render site already sits behind a -// terminal-open gate, so the drawer is a natural lazy boundary. -// -// Safety: terminal runtimes (the xterm instance + its PTY subscription) live in -// the module-level `terminalRuntimeRegistry`, not in React state. Unmounting -// the drawer calls `detach`, which parks the wrapper element in a hidden -// container and keeps the entry alive; only an explicit `dispose` tears a -// PTY down. Deferring the mount is therefore state-preserving. -// -// No flash: the Suspense fallback renders the drawer's own shell geometry -// (`terminalDrawerShellClassName`) at the same height, so the swap to the -// real drawer is a pure content replacement with no layout shift. - -import { Suspense, lazy } from "react"; - -// `import type` (never a bare `type` specifier) so the heavy module is fully -// erased at build time and only the dynamic `import()` below can pull it in. -import type { ThreadTerminalDrawerProps } from "../ThreadTerminalDrawer"; -import { terminalDrawerShellClassName } from "./terminalDrawerShell"; -import { clampTerminalDrawerHeight } from "./useTerminalDrawerHeight"; - -const ThreadTerminalDrawer = lazy(() => import("../ThreadTerminalDrawer")); - -function ThreadTerminalDrawerFallback({ - height, - presentationMode, -}: Pick) { - return ( -
- ); - })} -
- - ); -} diff --git a/apps/web/src/components/terminal/TerminalIdentityIcon.tsx b/apps/web/src/components/terminal/TerminalIdentityIcon.tsx deleted file mode 100644 index 4eef03c97..000000000 --- a/apps/web/src/components/terminal/TerminalIdentityIcon.tsx +++ /dev/null @@ -1,28 +0,0 @@ -// FILE: TerminalIdentityIcon.tsx -// Purpose: Renders a terminal/provider icon without extra activity chrome. -// Layer: Terminal presentation primitive -// Depends on: shared terminal icon keys plus local provider/icon components. - -import type { TerminalIconKey } from "@t3tools/shared/terminalThreads"; - -import { TerminalSquare } from "~/lib/icons"; -import { cn } from "~/lib/utils"; - -import { ClaudeAI, OpenAI } from "../Icons"; - -interface TerminalIdentityIconProps { - iconKey: TerminalIconKey; - className?: string; -} - -// Keep provider branding reusable across every terminal surface. -export default function TerminalIdentityIcon({ iconKey, className }: TerminalIdentityIconProps) { - const IconComponent = - iconKey === "openai" ? OpenAI : iconKey === "claude" ? ClaudeAI : TerminalSquare; - - return ( - - - - ); -} diff --git a/apps/web/src/components/terminal/TerminalLayout.ts b/apps/web/src/components/terminal/TerminalLayout.ts deleted file mode 100644 index 0eb8fcde3..000000000 --- a/apps/web/src/components/terminal/TerminalLayout.ts +++ /dev/null @@ -1,211 +0,0 @@ -// FILE: TerminalLayout.ts -// Purpose: Pure layout resolution for terminal pane tabs, pane trees, and visual identities. -// Layer: Terminal view-model helpers -// Depends on: shared terminal identity logic plus terminal pane-tree helpers. - -import { - type ResolvedTerminalVisualIdentity, - type TerminalCliKind, -} from "@t3tools/shared/terminalThreads"; - -import { resolveTerminalVisualIdentityMap } from "../../terminalVisualIdentity"; -import { - collectTerminalIdsFromLayout, - findFirstTerminalIdInLayout, - normalizeTerminalPaneGroup, -} from "../../terminalPaneLayout"; -import { - DEFAULT_THREAD_TERMINAL_ID, - MAX_TERMINALS_PER_GROUP, - type ThreadTerminalGroup, - type ThreadTerminalLayoutNode, -} from "../../types"; - -export interface ResolvedTerminalGroupLayout { - id: string; - activeTerminalId: string; - layout: ThreadTerminalLayoutNode; - terminalIds: string[]; -} - -export interface ResolvedThreadTerminalLayout { - normalizedTerminalIds: string[]; - resolvedActiveTerminalId: string; - resolvedActiveGroupId: string; - resolvedTerminalGroups: ResolvedTerminalGroupLayout[]; - activeGroupLayout: ThreadTerminalLayoutNode; - visibleTerminalIds: string[]; - hasTerminalSidebar: boolean; - isSplitView: boolean; - showGroupHeaders: boolean; - hasReachedSplitLimit: boolean; - terminalVisualIdentityById: ReadonlyMap; -} - -function assignUniqueGroupId(groupId: string, usedGroupIds: Set): string { - if (!usedGroupIds.has(groupId)) { - usedGroupIds.add(groupId); - return groupId; - } - let suffix = 2; - while (usedGroupIds.has(`${groupId}-${suffix}`)) { - suffix += 1; - } - const uniqueGroupId = `${groupId}-${suffix}`; - usedGroupIds.add(uniqueGroupId); - return uniqueGroupId; -} - -function normalizeTerminalIds(terminalIds: string[]): string[] { - const cleaned = [...new Set(terminalIds.map((id) => id.trim()).filter((id) => id.length > 0))]; - return cleaned.length > 0 ? cleaned : [DEFAULT_THREAD_TERMINAL_ID]; -} - -function resolveTerminalGroups(input: { - normalizedTerminalIds: string[]; - terminalGroups: ThreadTerminalGroup[]; -}): ResolvedTerminalGroupLayout[] { - const assignedTerminalIds = new Set(); - const usedGroupIds = new Set(); - const nextGroups: ResolvedTerminalGroupLayout[] = []; - - for (const terminalGroup of input.terminalGroups) { - const normalizedGroup = normalizeTerminalPaneGroup(terminalGroup, input.normalizedTerminalIds); - if (!normalizedGroup) continue; - const groupTerminalIds = collectTerminalIdsFromLayout(normalizedGroup.layout).filter( - (terminalId) => { - if (assignedTerminalIds.has(terminalId)) return false; - return true; - }, - ); - if (groupTerminalIds.length === 0) continue; - const filteredGroup = normalizeTerminalPaneGroup(normalizedGroup, groupTerminalIds); - if (!filteredGroup) continue; - const groupId = assignUniqueGroupId(filteredGroup.id, usedGroupIds); - collectTerminalIdsFromLayout(filteredGroup.layout).forEach((terminalId) => { - assignedTerminalIds.add(terminalId); - }); - nextGroups.push({ - ...filteredGroup, - id: groupId, - terminalIds: collectTerminalIdsFromLayout(filteredGroup.layout), - }); - } - - for (const terminalId of input.normalizedTerminalIds) { - if (assignedTerminalIds.has(terminalId)) continue; - const id = assignUniqueGroupId(`group-${terminalId}`, usedGroupIds); - nextGroups.push({ - id, - activeTerminalId: terminalId, - layout: { - type: "terminal", - paneId: `pane-${terminalId}`, - terminalIds: [terminalId], - activeTerminalId: terminalId, - }, - terminalIds: [terminalId], - }); - } - - if (nextGroups.length > 0) { - return nextGroups; - } - - return [ - { - id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, - activeTerminalId: DEFAULT_THREAD_TERMINAL_ID, - layout: { - type: "terminal", - paneId: `pane-${DEFAULT_THREAD_TERMINAL_ID}`, - terminalIds: [DEFAULT_THREAD_TERMINAL_ID], - activeTerminalId: DEFAULT_THREAD_TERMINAL_ID, - }, - terminalIds: [DEFAULT_THREAD_TERMINAL_ID], - }, - ]; -} - -function resolveActiveGroup(input: { - activeTerminalGroupId: string; - activeTerminalId: string; - resolvedTerminalGroups: ResolvedTerminalGroupLayout[]; -}): ResolvedTerminalGroupLayout { - return ( - input.resolvedTerminalGroups.find( - (terminalGroup) => terminalGroup.id === input.activeTerminalGroupId, - ) ?? - input.resolvedTerminalGroups.find((terminalGroup) => - terminalGroup.terminalIds.includes(input.activeTerminalId), - ) ?? - input.resolvedTerminalGroups[0] ?? { - id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, - activeTerminalId: DEFAULT_THREAD_TERMINAL_ID, - layout: { - type: "terminal", - paneId: `pane-${DEFAULT_THREAD_TERMINAL_ID}`, - terminalIds: [DEFAULT_THREAD_TERMINAL_ID], - activeTerminalId: DEFAULT_THREAD_TERMINAL_ID, - }, - terminalIds: [DEFAULT_THREAD_TERMINAL_ID], - } - ); -} - -export function resolveThreadTerminalLayout(input: { - activeTerminalGroupId: string; - activeTerminalId: string; - runningTerminalIds: string[]; - terminalAttentionStatesById: Record; - terminalCliKindsById: Record; - terminalGroups: ThreadTerminalGroup[]; - terminalIds: string[]; - terminalLabelsById: Record; - terminalTitleOverridesById: Record; -}): ResolvedThreadTerminalLayout { - const normalizedTerminalIds = normalizeTerminalIds(input.terminalIds); - const resolvedTerminalGroups = resolveTerminalGroups({ - normalizedTerminalIds, - terminalGroups: input.terminalGroups, - }); - const activeGroup = resolveActiveGroup({ - activeTerminalGroupId: input.activeTerminalGroupId, - activeTerminalId: input.activeTerminalId, - resolvedTerminalGroups, - }); - const resolvedActiveTerminalId = activeGroup.terminalIds.includes(input.activeTerminalId) - ? input.activeTerminalId - : activeGroup.terminalIds.includes(activeGroup.activeTerminalId) - ? activeGroup.activeTerminalId - : findFirstTerminalIdInLayout(activeGroup.layout); - const visibleTerminalIds = activeGroup.terminalIds; - const hasTerminalSidebar = false; - const isSplitView = visibleTerminalIds.length > 1; - const showGroupHeaders = - resolvedTerminalGroups.length > 1 || - resolvedTerminalGroups.some((terminalGroup) => terminalGroup.terminalIds.length > 1); - const hasReachedSplitLimit = visibleTerminalIds.length >= MAX_TERMINALS_PER_GROUP; - const terminalVisualIdentityById = resolveTerminalVisualIdentityMap({ - terminalIds: normalizedTerminalIds, - runningTerminalIds: input.runningTerminalIds, - terminalAttentionStatesById: input.terminalAttentionStatesById, - terminalCliKindsById: input.terminalCliKindsById, - terminalLabelsById: input.terminalLabelsById, - terminalTitleOverridesById: input.terminalTitleOverridesById, - }); - - return { - normalizedTerminalIds, - resolvedActiveTerminalId, - resolvedActiveGroupId: activeGroup.id, - resolvedTerminalGroups, - activeGroupLayout: activeGroup.layout, - visibleTerminalIds, - hasTerminalSidebar, - isSplitView, - showGroupHeaders, - hasReachedSplitLimit, - terminalVisualIdentityById, - }; -} diff --git a/apps/web/src/components/terminal/TerminalViewportPane.tsx b/apps/web/src/components/terminal/TerminalViewportPane.tsx deleted file mode 100644 index cafdb9bb8..000000000 --- a/apps/web/src/components/terminal/TerminalViewportPane.tsx +++ /dev/null @@ -1,389 +0,0 @@ -// FILE: TerminalViewportPane.tsx -// Purpose: Renders the active terminal pane tree with nested splits and pane-local tab strips. -// Layer: Terminal presentation components -// Depends on: caller-provided viewport renderer so xterm lifecycle can stay external. -// -// Note: pane-tab activate and close buttons are intentionally raw