diff --git a/apps/desktop/src/electron/ElectronMenu.ts b/apps/desktop/src/electron/ElectronMenu.ts index 540326346edf8..a9a13204c7801 100644 --- a/apps/desktop/src/electron/ElectronMenu.ts +++ b/apps/desktop/src/electron/ElectronMenu.ts @@ -84,6 +84,9 @@ function normalizeContextMenuItems(source: readonly ContextMenuItem[]): ContextM disabled: sourceItem.disabled === true, ...(sourceItem.separatorBefore === true ? { separatorBefore: true } : {}), ...(typeof sourceItem.checked === "boolean" ? { checked: sourceItem.checked } : {}), + ...(typeof sourceItem.accelerator === "string" + ? { accelerator: sourceItem.accelerator } + : {}), }; if (sourceItem.children) { @@ -171,6 +174,8 @@ export const make = Effect.gen(function* () { label: item.label, enabled: !item.disabled, ...(typeof item.checked === "boolean" ? { type: "checkbox", checked: item.checked } : {}), + // Shown beside the label for learning; the app's own handler runs the key. + ...(item.accelerator ? { accelerator: item.accelerator, registerAccelerator: false } : {}), }; if (item.children && item.children.length > 0) { itemOption.submenu = buildTemplate(item.children, complete); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index f80b66b1bcbc7..96f2ba891491d 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -126,6 +126,8 @@ export const PREVIEW_RECORDING_STOP_CHANNEL = "desktop:preview-recording-stop"; export const PREVIEW_RECORDING_SAVE_CHANNEL = "desktop:preview-recording-save"; export const PREVIEW_STATE_CHANGE_CHANNEL = "desktop:preview-state-change"; export const PREVIEW_POINTER_EVENT_CHANNEL = "desktop:preview-pointer-event"; +export const PREVIEW_SET_FORWARDED_KEY_CHORDS_CHANNEL = "desktop:preview-set-forwarded-key-chords"; +export const PREVIEW_FORWARDED_KEY_CHORD_CHANNEL = "desktop:preview-forwarded-key-chord"; export const PREVIEW_OPEN_LINK_CHANNEL = "desktop:preview-open-link"; export const PREVIEW_FIND_RESULT_CHANNEL = "desktop:preview-find-result"; diff --git a/apps/desktop/src/ipc/methods/preview.ts b/apps/desktop/src/ipc/methods/preview.ts index 757e1e463f538..51fc579d2c8ff 100644 --- a/apps/desktop/src/ipc/methods/preview.ts +++ b/apps/desktop/src/ipc/methods/preview.ts @@ -1,6 +1,7 @@ import { DesktopPreviewAnnotationSendEnabledInputSchema, DesktopPreviewAnnotationThemeInputSchema, + DesktopPreviewForwardedShortcutsInputSchema, DesktopPreviewAnnotationVoiceStateSchema, DesktopPreviewPickElementInputSchema, DesktopPreviewArtifactInputSchema, @@ -402,6 +403,16 @@ const setAnnotationTheme = DesktopIpc.makeIpcMethod({ }), }); +const setForwardedKeyChords = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.PREVIEW_SET_FORWARDED_KEY_CHORDS_CHANNEL, + payload: DesktopPreviewForwardedShortcutsInputSchema, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.preview.setForwardedKeyChords")(function* ({ shortcuts }) { + const manager = yield* PreviewManager.PreviewManager; + yield* manager.setForwardedKeyChords(shortcuts); + }), +}); + const pickElement = DesktopIpc.makeIpcMethod({ channel: IpcChannels.PREVIEW_PICK_ELEMENT_CHANNEL, payload: DesktopPreviewPickElementInputSchema, @@ -504,6 +515,7 @@ export const methods = [ clearCache, getPreviewConfig, setAnnotationTheme, + setForwardedKeyChords, setAnnotationSendEnabled, pickElement, cancelPickElement, diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 3005c0dff4b34..4f1623226def6 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -438,6 +438,20 @@ contextBridge.exposeInMainWorld("desktopBridge", { ipcRenderer.invoke(IpcChannels.PREVIEW_CLEAR_CACHE_CHANNEL, { environmentId, profileId }), getPreviewConfig: (environmentId, profileId) => ipcRenderer.invoke(IpcChannels.PREVIEW_GET_CONFIG_CHANNEL, { environmentId, profileId }), + setForwardedKeyChords: (shortcuts) => + ipcRenderer.invoke(IpcChannels.PREVIEW_SET_FORWARDED_KEY_CHORDS_CHANNEL, { shortcuts }), + onForwardedKeyChord: (listener) => { + const wrappedListener = (_event: Electron.IpcRendererEvent, input: unknown) => { + if (typeof input !== "object" || input === null) return; + listener(input as Parameters[0]); + }; + ipcRenderer.on(IpcChannels.PREVIEW_FORWARDED_KEY_CHORD_CHANNEL, wrappedListener); + return () => + ipcRenderer.removeListener( + IpcChannels.PREVIEW_FORWARDED_KEY_CHORD_CHANNEL, + wrappedListener, + ); + }, setAnnotationTheme: (theme) => ipcRenderer.invoke(IpcChannels.PREVIEW_SET_ANNOTATION_THEME_CHANNEL, { theme }), setAnnotationSendEnabled: (tabId, enabled) => diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index 2091ff3c7f8f2..cf22921690631 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -992,6 +992,22 @@ describe("PreviewManager", () => { ); expect(focus).toHaveBeenCalledOnce(); expect(send).toHaveBeenCalledTimes(2); + yield* manager.setForwardedKeyChords([ + { key: "z", metaKey: true, ctrlKey: false, altKey: true, shiftKey: false }, + ]); + const layoutPreventDefault = vi.fn(); + beforeInput( + { preventDefault: layoutPreventDefault } as never, + { ...key, key: "z", code: "KeyY", alt: true, shift: false } as never, + ); + expect(layoutPreventDefault).toHaveBeenCalledOnce(); + layoutPreventDefault.mockClear(); + beforeInput( + { preventDefault: layoutPreventDefault } as never, + { ...key, key: "y", code: "KeyZ", alt: true, shift: false } as never, + ); + expect(layoutPreventDefault).not.toHaveBeenCalled(); + yield* manager.setForwardedKeyChords([]); send.mockClear(); yield* manager.setForwardedShortcuts([{ command: "view.reopenClosed", shortcut }]); diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index 32ac876996c81..6a69bfa0d4a1d 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -7,6 +7,7 @@ import { DESKTOP_PREVIEW_RECORDING_CAPTURE_TRIGGER, } from "@t3tools/contracts"; import type { + DesktopForwardedShortcut, DesktopPreviewAnnotationTheme, DesktopPreviewAnnotationVoiceConfig, DesktopPreviewAnnotationVoiceState, @@ -67,6 +68,7 @@ import * as DesktopRendererHistory from "../telemetry/DesktopRendererHistory.ts" import { MENU_ACTION_CHANNEL, PREVIEW_ANNOTATION_VOICE_EVENT_CHANNEL, + PREVIEW_FORWARDED_KEY_CHORD_CHANNEL, PREVIEW_PICTURE_IN_PICTURE_FRAME_CHANNEL, } from "../ipc/channels.ts"; import * as DesktopBrowserHost from "./DesktopBrowserHost.ts"; @@ -506,6 +508,28 @@ export const previewWindowOpenAction = (details: { : "navigate"; }; +const isForwardedShortcut = ( + input: Electron.Input, + shortcuts: ReadonlyArray, + platform: NodeJS.Platform, +): boolean => { + if (input.type !== "keyDown" || shortcuts.length === 0) return false; + return shortcuts.some((shortcut) => + matchesKeybindingShortcut( + { + key: input.key, + code: input.code, + metaKey: input.meta, + ctrlKey: input.control, + altKey: input.alt, + shiftKey: input.shift, + }, + { ...shortcut, modKey: false }, + platform === "darwin" ? "MacIntel" : platform, + ), + ); +}; + const isPreviewRefreshShortcut = (input: Electron.Input, platform: NodeJS.Platform): boolean => input.type === "keyDown" && input.key.toLowerCase() === "r" && @@ -589,6 +613,9 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function new Set(), ); const pickSessionsRef = yield* Ref.make>(new Map()); + // Read synchronously in before-input-event, which must decide on the spot + // whether the page sees a key, so a plain variable rather than a Ref. + let forwardedKeyChords: ReadonlyArray = []; const controlSessionsRef = yield* SynchronizedRef.make< ReadonlyMap >(new Map()); @@ -1613,6 +1640,27 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function } return; } + // App chords (the layout map) go to the app window, not the page. + if (isForwardedShortcut(input, forwardedKeyChords, hostPlatform)) { + event.preventDefault(); + const forwardedKey = { + key: input.key, + code: input.code, + metaKey: input.meta, + ctrlKey: input.control, + altKey: input.alt, + shiftKey: input.shift, + }; + runFork( + Ref.get(mainWindowRef).pipe( + Effect.map((window) => { + if (Option.isNone(window) || window.value.isDestroyed()) return; + window.value.webContents.send(PREVIEW_FORWARDED_KEY_CHORD_CHANNEL, forwardedKey); + }), + ), + ); + return; + } if (isPreviewRefreshShortcut(input, hostPlatform)) { event.preventDefault(); runFork( @@ -3730,6 +3778,10 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function setShown, setCaptureDrawn, setColorScheme, + setForwardedKeyChords: (shortcuts: ReadonlyArray) => + Effect.sync(() => { + forwardedKeyChords = shortcuts; + }), setMainWindow, setForwardedShortcuts: (shortcuts: ReadonlyArray) => Effect.sync(() => { @@ -3965,6 +4017,10 @@ export class PreviewManager extends Context.Service< tabId: string, enabled: boolean, ) => Effect.Effect; + /** Chords a focused preview webview hands back to the app window. */ + readonly setForwardedKeyChords: ( + shortcuts: ReadonlyArray, + ) => Effect.Effect; readonly pickElement: ( tabId: string, voice?: DesktopPreviewAnnotationVoiceConfig, @@ -4138,6 +4194,7 @@ export const make = Effect.gen(function* PreviewManagerMake() { }, ), setAnnotationTheme: operations.setAnnotationTheme, + setForwardedKeyChords: operations.setForwardedKeyChords, setAnnotationSendEnabled: operations.setAnnotationSendEnabled, pickElement: operations.pickElement, updateAnnotationVoice: operations.updateAnnotationVoice, diff --git a/apps/desktop/src/window/DesktopApplicationMenu.ts b/apps/desktop/src/window/DesktopApplicationMenu.ts index b4025ebfde8ef..3b001e9543dd5 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.ts @@ -210,7 +210,8 @@ export const make = Effect.gen(function* () { { role: "services" }, { type: "separator" }, { role: "hide", label: `Hide ${environment.displayName}` }, - { role: "hideOthers" }, + // alt+cmd+H focuses the pane on the left; Hide Others keeps its item. + { role: "hideOthers", accelerator: "" }, { role: "unhide" }, { type: "separator" }, { role: "quit", label: `Quit ${environment.displayName}` }, @@ -287,9 +288,10 @@ export const make = Effect.gen(function* () { accelerator: "Shift+CmdOrCtrl+R", click: mainContentsClick("forceReload"), }, + // alt+cmd+I and J belong to the layout keys on macOS. { label: "Toggle Developer Tools", - accelerator: environment.platform === "darwin" ? "Alt+Command+I" : "Ctrl+Shift+I", + accelerator: environment.platform === "darwin" ? "Alt+Shift+Command+I" : "Ctrl+Shift+I", click: mainContentsClick("toggleDevTools"), }, { type: "separator" }, diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index e58c5c01f29f1..ada4069538ca6 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -1,7 +1,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; import * as Deferred from "effect/Deferred"; -import { DesktopSnapShotId } from "@t3tools/contracts"; +import { DEFAULT_CLIENT_SETTINGS, DesktopSnapShotId } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; @@ -46,7 +46,7 @@ import * as ElectronMenu from "../electron/ElectronMenu.ts"; import * as ElectronShell from "../electron/ElectronShell.ts"; import * as ElectronTheme from "../electron/ElectronTheme.ts"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; -import { SNAP_SHOT_EVENT_CHANNEL } from "../ipc/channels.ts"; +import { MENU_ACTION_CHANNEL, SNAP_SHOT_EVENT_CHANNEL } from "../ipc/channels.ts"; import * as DesktopServerExposure from "../backend/DesktopServerExposure.ts"; import * as DesktopWindow from "./DesktopWindow.ts"; import { @@ -163,9 +163,7 @@ function makeFakeBrowserWindow() { }; } -const layerDesktopClientSettings = Layer.mock(DesktopClientSettings.DesktopClientSettings)({ - get: Effect.succeedNone, -}); +const layerDesktopClientSettings = DesktopClientSettings.layerTest(); const layerElectronApp = Layer.mock(ElectronApp.ElectronApp)({ quit: Effect.void, @@ -380,6 +378,7 @@ function layerTest(input: { ), ), Layer.merge(DesktopState.layer), + Layer.merge(layerDesktopClientSettings), ); } @@ -1340,20 +1339,48 @@ describe("DesktopWindow", () => { if (!swipe) return; const history = fakeWindow.navigationHistory; - swipe({}, "left"); + yield* Effect.promise(() => Promise.resolve(swipe({}, "left"))); assert.equal(history.goBack.mock.calls.length, 1); assert.equal(history.goForward.mock.calls.length, 0); - swipe({}, "right"); + yield* Effect.promise(() => Promise.resolve(swipe({}, "right"))); assert.equal(history.goForward.mock.calls.length, 1); - swipe({}, "up"); - swipe({}, "down"); + yield* Effect.promise(() => Promise.resolve(swipe({}, "up"))); + yield* Effect.promise(() => Promise.resolve(swipe({}, "down"))); history.canGoBack.mockReturnValue(false); history.canGoForward.mockReturnValue(false); - swipe({}, "left"); - swipe({}, "right"); + yield* Effect.promise(() => Promise.resolve(swipe({}, "left"))); + yield* Effect.promise(() => Promise.resolve(swipe({}, "right"))); assert.equal(history.goBack.mock.calls.length, 1); assert.equal(history.goForward.mock.calls.length, 1); + assert.equal( + fakeWindow.send.mock.calls.filter(([channel]) => channel === MENU_ACTION_CHANNEL).length, + 0, + ); + const settings = yield* DesktopClientSettings.DesktopClientSettings; + yield* settings.set({ + ...DEFAULT_CLIENT_SETTINGS, + layoutThreeFingerSwipe: true, + scrollingWorkspacesEnabled: true, + }); + history.canGoBack.mockReturnValue(true); + history.canGoForward.mockReturnValue(true); + for (const direction of ["left", "right", "up", "down"]) { + yield* Effect.promise(() => Promise.resolve(swipe({}, direction))); + assert.deepEqual(fakeWindow.send.mock.lastCall, [ + MENU_ACTION_CHANNEL, + `layout-swipe-${direction}`, + ]); + } + assert.equal(history.goBack.mock.calls.length, 1); + assert.equal(history.goForward.mock.calls.length, 1); + yield* settings.set({ ...DEFAULT_CLIENT_SETTINGS, layoutThreeFingerSwipe: true }); + fakeWindow.send.mockClear(); + yield* Effect.promise(() => Promise.resolve(swipe({}, "left"))); + yield* Effect.promise(() => Promise.resolve(swipe({}, "right"))); + assert.equal(history.goBack.mock.calls.length, 2); + assert.equal(history.goForward.mock.calls.length, 2); + assert.equal(fakeWindow.send.mock.calls.length, 0); }).pipe(Effect.provide(layer)); }), ); diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index 3919259a607ad..322ede46cab1f 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -834,11 +834,26 @@ export const make = Effect.gen(function* () { if (environment.platform === "darwin") { // Mouse drivers can deliver Back/Forward as macOS swipe gestures instead of buttons. - window.on("swipe", (_event, direction) => { - const history = window.webContents.navigationHistory; - if (direction === "left" && history.canGoBack()) history.goBack(); - else if (direction === "right" && history.canGoForward()) history.goForward(); - }); + window.on("swipe", (_event, direction) => + runPromise( + Effect.gen(function* () { + const settings = yield* clientSettings.get.pipe( + Effect.orElseSucceed(() => Option.none()), + ); + if ( + Option.isSome(settings) && + settings.value.layoutThreeFingerSwipe && + settings.value.scrollingWorkspacesEnabled + ) { + window.webContents.send(MENU_ACTION_CHANNEL, `layout-swipe-${direction}`); + return; + } + const history = window.webContents.navigationHistory; + if (direction === "left" && history.canGoBack()) history.goBack(); + else if (direction === "right" && history.canGoForward()) history.goForward(); + }), + ), + ); window.on("enter-full-screen", () => { window.webContents.send(WINDOW_FULLSCREEN_STATE_CHANNEL, true); }); diff --git a/apps/mobile/generated-uniwind-default-theme-variables.json b/apps/mobile/generated-uniwind-default-theme-variables.json index a4c0e9949da55..051fe8b79213e 100644 --- a/apps/mobile/generated-uniwind-default-theme-variables.json +++ b/apps/mobile/generated-uniwind-default-theme-variables.json @@ -29,16 +29,16 @@ "--color-inline-skill-background": "#f4f4f4", "--color-inline-skill-border": "rgba(27, 78, 216, 0.42)", "--color-inline-skill-foreground": "#181818", - "--color-primary": "#1b4ed8", - "--color-prominent-action": "#272727", - "--color-prominent-action-foreground": "#fcfcfc", + "--color-primary": "#111111", + "--color-prominent-action": "#111111", + "--color-prominent-action-foreground": "#ffffff", "--color-primary-foreground": "#ffffff", - "--color-primary-text": "#1b4ed8", + "--color-primary-text": "#111111", "--color-primary-shadow": "#000000", "--color-secondary": "#fafafa", "--color-secondary-foreground": "#272727", "--color-secondary-border": "#e4e4e4", - "--color-switch-active-track": "#1b4ed8", + "--color-switch-active-track": "#111111", "--color-switch-active-thumb": "#ffffff", "--color-switch-inactive-track": "#fafafa", "--color-switch-inactive-thumb": "#727272", @@ -66,7 +66,7 @@ "--color-status-bar": "#fcfcfc", "--color-md-body": "#272727", "--color-md-strong": "#272727", - "--color-md-link": "#1b4ed8", + "--color-md-link": "#111111", "--color-md-blockquote-border": "#e4e4e4", "--color-md-blockquote-bg": "#fafafa", "--color-md-code-bg": "#ffffff", @@ -79,7 +79,7 @@ "--color-user-bubble": "#efefef", "--color-user-bubble-foreground": "#272727", "--color-user-bubble-foreground-muted": "rgba(39, 39, 39, 0.78)", - "--color-user-bubble-skill-foreground": "#1b4ed8", + "--color-user-bubble-skill-foreground": "#111111", "--color-backdrop": "rgba(0, 0, 0, 0.22)", "--color-drawer": "#fafafa", "--color-drawer-foreground": "#272727", @@ -91,48 +91,48 @@ "--color-chevron": "rgba(114, 114, 114, 0.42)" }, "dark": { - "--color-screen": "#0a0a0a", - "--color-sheet": "rgba(10, 10, 10, 0.98)", - "--color-sheet-solid": "#0a0a0a", - "--color-card": "#111111", - "--color-grouped-card": "#1b1b1b", - "--color-card-alt": "#111111", - "--color-card-translucent": "rgba(17, 17, 17, 0.8)", - "--color-thread-canvas": "#0a0a0a", + "--color-screen": "#151515", + "--color-sheet": "rgba(21, 21, 21, 0.98)", + "--color-sheet-solid": "#151515", + "--color-card": "#1d1d1d", + "--color-grouped-card": "#1d1d1d", + "--color-card-alt": "#1d1d1d", + "--color-card-translucent": "rgba(29, 29, 29, 0.8)", + "--color-thread-canvas": "#151515", "--color-thread-selected": "#1b1b1b", "--color-thread-selected-foreground": "#f3f3f3", "--color-thread-selected-foreground-muted": "#a3a3a3", "--color-thread-hover": "#131313", "--color-row-hover": "#141414", - "--color-composer-panel": "rgba(10, 10, 10, 0.92)", - "--color-composer-surface": "rgba(27, 27, 27, 0.9)", - "--color-composer-border": "rgba(25, 25, 25, 0.8)", + "--color-composer-panel": "rgba(21, 21, 21, 0.92)", + "--color-composer-surface": "rgba(29, 29, 29, 0.9)", + "--color-composer-border": "rgba(36, 36, 36, 0.46)", "--color-foreground": "#f5f5f5", - "--color-foreground-secondary": "#838383", - "--color-foreground-muted": "#838383", - "--color-foreground-tertiary": "#818181", - "--color-border": "#191919", + "--color-foreground-secondary": "#8a8a8a", + "--color-foreground-muted": "#8a8a8a", + "--color-foreground-tertiary": "#8a8a8a", + "--color-border": "#242424", "--color-focus": "#346bf1", - "--color-border-subtle": "rgba(25, 25, 25, 0.7)", - "--color-separator": "rgba(25, 25, 25, 0.55)", - "--color-subtle": "#111111", - "--color-subtle-strong": "#111111", + "--color-border-subtle": "rgba(36, 36, 36, 0.7)", + "--color-separator": "rgba(36, 36, 36, 0.55)", + "--color-subtle": "#1d1d1d", + "--color-subtle-strong": "#1d1d1d", "--color-inline-skill-background": "#141414", "--color-inline-skill-border": "rgba(52, 107, 241, 0.42)", "--color-inline-skill-foreground": "#f5f5f5", - "--color-primary": "#346bf1", - "--color-prominent-action": "#f5f5f5", - "--color-prominent-action-foreground": "#0a0a0a", - "--color-primary-foreground": "#ffffff", - "--color-primary-text": "#4c7df3", + "--color-primary": "#eeeeee", + "--color-prominent-action": "#eeeeee", + "--color-prominent-action-foreground": "#111111", + "--color-primary-foreground": "#111111", + "--color-primary-text": "#eeeeee", "--color-primary-shadow": "#000000", - "--color-secondary": "#111111", + "--color-secondary": "#1d1d1d", "--color-secondary-foreground": "#f5f5f5", - "--color-secondary-border": "#191919", - "--color-switch-active-track": "#346bf1", - "--color-switch-active-thumb": "#ffffff", - "--color-switch-inactive-track": "#111111", - "--color-switch-inactive-thumb": "#818181", + "--color-secondary-border": "#242424", + "--color-switch-active-track": "#eeeeee", + "--color-switch-active-thumb": "#111111", + "--color-switch-inactive-track": "#1d1d1d", + "--color-switch-inactive-thumb": "#8a8a8a", "--color-warning": "#312108", "--color-warning-border": "rgba(254, 154, 0, 0.32)", "--color-warning-foreground": "#ffb900", @@ -141,44 +141,44 @@ "--color-danger-foreground": "#ff6467", "--color-update": "#121b34", "--color-update-foreground": "#51a2ff", - "--color-input": "#111111", + "--color-input": "#1d1d1d", "--color-input-border": "#1e1e1e", "--color-sidebar-search": "#0a0a0a", - "--color-placeholder": "#838383", + "--color-placeholder": "#848484", "--color-icon": "#f5f5f5", - "--color-icon-muted": "#818181", - "--color-icon-subtle": "#818181", - "--color-header": "rgba(10, 10, 10, 0.97)", + "--color-icon-muted": "#8a8a8a", + "--color-icon-subtle": "#8a8a8a", + "--color-header": "#000000", "--color-header-foreground": "#f5f5f5", "--color-header-border": "#191919", - "--color-glass-surface": "rgba(17, 17, 17, 0.74)", - "--color-glass-fallback": "rgba(27, 27, 27, 0.9)", - "--color-glass-tint": "rgba(17, 17, 17, 0.22)", - "--color-status-bar": "#0a0a0a", + "--color-glass-surface": "rgba(29, 29, 29, 0.74)", + "--color-glass-fallback": "rgba(29, 29, 29, 0.9)", + "--color-glass-tint": "rgba(29, 29, 29, 0.22)", + "--color-status-bar": "#000000", "--color-md-body": "#f5f5f5", "--color-md-strong": "#f5f5f5", - "--color-md-link": "#3b70f1", - "--color-md-blockquote-border": "#191919", - "--color-md-blockquote-bg": "#111111", + "--color-md-link": "#eeeeee", + "--color-md-blockquote-border": "#242424", + "--color-md-blockquote-bg": "#1d1d1d", "--color-md-code-bg": "#111111", "--color-md-code-text": "#f5f5f5", "--color-md-user-code-bg": "rgba(245, 245, 245, 0.18)", "--color-md-user-code-text": "#f5f5f5", "--color-md-user-fence-bg": "rgba(0, 0, 0, 0.28)", "--color-md-user-fence-text": "#f5f5f5", - "--color-md-hr": "#191919", - "--color-user-bubble": "#161616", + "--color-md-hr": "#242424", + "--color-user-bubble": "#1d1d1d", "--color-user-bubble-foreground": "#f5f5f5", "--color-user-bubble-foreground-muted": "rgba(245, 245, 245, 0.78)", - "--color-user-bubble-skill-foreground": "#4678f2", + "--color-user-bubble-skill-foreground": "#eeeeee", "--color-backdrop": "rgba(0, 0, 0, 0.48)", "--color-drawer": "#000000", "--color-drawer-foreground": "#f3f3f3", "--color-drawer-foreground-muted": "#a3a3a3", "--color-drawer-border": "#141414", "--color-drawer-shadow": "rgba(0, 0, 0, 0.32)", - "--color-dot-separator": "rgba(129, 129, 129, 0.35)", + "--color-dot-separator": "rgba(138, 138, 138, 0.35)", "--color-wordmark": "#f5f5f5", - "--color-chevron": "rgba(129, 129, 129, 0.42)" + "--color-chevron": "rgba(138, 138, 138, 0.42)" } } diff --git a/apps/mobile/generated-uniwind-theme-names.json b/apps/mobile/generated-uniwind-theme-names.json index 4ec9f01f8ee7d..3c57fa34358c6 100644 --- a/apps/mobile/generated-uniwind-theme-names.json +++ b/apps/mobile/generated-uniwind-theme-names.json @@ -1,4 +1,6 @@ [ + "t3-code-classic-light", + "t3-code-classic-dark", "t3-chat-light", "t3-chat-dark", "grove-light", diff --git a/apps/mobile/generated-uniwind-themes.css b/apps/mobile/generated-uniwind-themes.css index 95e467b0731d4..beec53ad18966 100644 --- a/apps/mobile/generated-uniwind-themes.css +++ b/apps/mobile/generated-uniwind-themes.css @@ -31,16 +31,16 @@ --color-inline-skill-background: #f4f4f4; --color-inline-skill-border: rgba(27, 78, 216, 0.42); --color-inline-skill-foreground: #181818; - --color-primary: #1b4ed8; - --color-prominent-action: #272727; - --color-prominent-action-foreground: #fcfcfc; + --color-primary: #111111; + --color-prominent-action: #111111; + --color-prominent-action-foreground: #ffffff; --color-primary-foreground: #ffffff; - --color-primary-text: #1b4ed8; + --color-primary-text: #111111; --color-primary-shadow: #000000; --color-secondary: #fafafa; --color-secondary-foreground: #272727; --color-secondary-border: #e4e4e4; - --color-switch-active-track: #1b4ed8; + --color-switch-active-track: #111111; --color-switch-active-thumb: #ffffff; --color-switch-inactive-track: #fafafa; --color-switch-inactive-thumb: #727272; @@ -68,7 +68,7 @@ --color-status-bar: #fcfcfc; --color-md-body: #272727; --color-md-strong: #272727; - --color-md-link: #1b4ed8; + --color-md-link: #111111; --color-md-blockquote-border: #e4e4e4; --color-md-blockquote-bg: #fafafa; --color-md-code-bg: #ffffff; @@ -81,7 +81,7 @@ --color-user-bubble: #efefef; --color-user-bubble-foreground: #272727; --color-user-bubble-foreground-muted: rgba(39, 39, 39, 0.78); - --color-user-bubble-skill-foreground: #1b4ed8; + --color-user-bubble-skill-foreground: #111111; --color-backdrop: rgba(0, 0, 0, 0.22); --color-drawer: #fafafa; --color-drawer-foreground: #272727; @@ -165,95 +165,421 @@ } @variant dark { - --color-screen: #0a0a0a; - --color-sheet: rgba(10, 10, 10, 0.98); - --color-sheet-solid: #0a0a0a; + --color-screen: #151515; + --color-sheet: rgba(21, 21, 21, 0.98); + --color-sheet-solid: #151515; + --color-card: #1d1d1d; + --color-grouped-card: #1d1d1d; + --color-card-alt: #1d1d1d; + --color-card-translucent: rgba(29, 29, 29, 0.8); + --color-thread-canvas: #151515; + --color-thread-selected: #1b1b1b; + --color-thread-selected-foreground: #f3f3f3; + --color-thread-selected-foreground-muted: #a3a3a3; + --color-thread-hover: #131313; + --color-row-hover: #141414; + --color-composer-panel: rgba(21, 21, 21, 0.92); + --color-composer-surface: rgba(29, 29, 29, 0.9); + --color-composer-border: rgba(36, 36, 36, 0.46); + --color-foreground: #f5f5f5; + --color-foreground-secondary: #8a8a8a; + --color-foreground-muted: #8a8a8a; + --color-foreground-tertiary: #8a8a8a; + --color-border: #242424; + --color-focus: #346bf1; + --color-border-subtle: rgba(36, 36, 36, 0.7); + --color-separator: rgba(36, 36, 36, 0.55); + --color-subtle: #1d1d1d; + --color-subtle-strong: #1d1d1d; + --color-inline-skill-background: #141414; + --color-inline-skill-border: rgba(52, 107, 241, 0.42); + --color-inline-skill-foreground: #f5f5f5; + --color-primary: #eeeeee; + --color-prominent-action: #eeeeee; + --color-prominent-action-foreground: #111111; + --color-primary-foreground: #111111; + --color-primary-text: #eeeeee; + --color-primary-shadow: #000000; + --color-secondary: #1d1d1d; + --color-secondary-foreground: #f5f5f5; + --color-secondary-border: #242424; + --color-switch-active-track: #eeeeee; + --color-switch-active-thumb: #111111; + --color-switch-inactive-track: #1d1d1d; + --color-switch-inactive-thumb: #8a8a8a; + --color-warning: #312108; + --color-warning-border: rgba(254, 154, 0, 0.32); + --color-warning-foreground: #ffb900; + --color-danger: #301214; + --color-danger-border: rgba(251, 65, 74, 0.32); + --color-danger-foreground: #ff6467; + --color-update: #121b34; + --color-update-foreground: #51a2ff; + --color-input: #1d1d1d; + --color-input-border: #1e1e1e; + --color-sidebar-search: #0a0a0a; + --color-placeholder: #848484; + --color-icon: #f5f5f5; + --color-icon-muted: #8a8a8a; + --color-icon-subtle: #8a8a8a; + --color-header: #000000; + --color-header-foreground: #f5f5f5; + --color-header-border: #191919; + --color-glass-surface: rgba(29, 29, 29, 0.74); + --color-glass-fallback: rgba(29, 29, 29, 0.9); + --color-glass-tint: rgba(29, 29, 29, 0.22); + --color-status-bar: #000000; + --color-md-body: #f5f5f5; + --color-md-strong: #f5f5f5; + --color-md-link: #eeeeee; + --color-md-blockquote-border: #242424; + --color-md-blockquote-bg: #1d1d1d; + --color-md-code-bg: #111111; + --color-md-code-text: #f5f5f5; + --color-md-user-code-bg: rgba(245, 245, 245, 0.18); + --color-md-user-code-text: #f5f5f5; + --color-md-user-fence-bg: rgba(0, 0, 0, 0.28); + --color-md-user-fence-text: #f5f5f5; + --color-md-hr: #242424; + --color-user-bubble: #1d1d1d; + --color-user-bubble-foreground: #f5f5f5; + --color-user-bubble-foreground-muted: rgba(245, 245, 245, 0.78); + --color-user-bubble-skill-foreground: #eeeeee; + --color-backdrop: rgba(0, 0, 0, 0.48); + --color-drawer: #000000; + --color-drawer-foreground: #f3f3f3; + --color-drawer-foreground-muted: #a3a3a3; + --color-drawer-border: #141414; + --color-drawer-shadow: rgba(0, 0, 0, 0.32); + --color-dot-separator: rgba(138, 138, 138, 0.35); + --color-wordmark: #f5f5f5; + --color-chevron: rgba(138, 138, 138, 0.42); + --color-adaptive-amber-50-950-a40: oklch(27.9% 0.077 45.635 / 40%); + --color-adaptive-amber-200-900-a60: oklch(41.4% 0.112 45.904 / 60%); + --color-adaptive-amber-500-a12-a16: oklch(76.9% 0.188 70.08 / 16%); + --color-adaptive-amber-500-a10-400-a10: oklch(82.8% 0.189 84.429 / 10%); + --color-adaptive-amber-500-a25-400-a25: oklch(82.8% 0.189 84.429 / 25%); + --color-adaptive-amber-700-300: oklch(87.9% 0.169 91.605); + --color-adaptive-amber-700-400: oklch(82.8% 0.189 84.429); + --color-adaptive-amber-800-200: oklch(92.4% 0.12 95.746); + --color-adaptive-blue-50-blue-400-a14: oklch(70.7% 0.165 254.624 / 14%); + --color-adaptive-blue-300-a50-blue-400-a28: oklch(70.7% 0.165 254.624 / 28%); + --color-adaptive-blue-500-a20-blue-400-a15: oklch(70.7% 0.165 254.624 / 15%); + --color-adaptive-blue-500-400: oklch(70.7% 0.165 254.624); + --color-adaptive-blue-600-400: oklch(70.7% 0.165 254.624); + --color-adaptive-black-a10-a25: rgb(0 0 0 / 25%); + --color-adaptive-black-a15-a35: rgb(0 0 0 / 35%); + --color-adaptive-black-a2p5-white-a2p5: rgb(255 255 255 / 2.5%); + --color-adaptive-emerald-500-a12-a16: oklch(69.6% 0.17 162.48 / 16%); + --color-adaptive-emerald-600-400: oklch(76.5% 0.177 163.223); + --color-adaptive-emerald-700-300: oklch(84.5% 0.143 164.978); + --color-adaptive-fuchsia-500-a50-300-a40: oklch(83.3% 0.145 321.434 / 40%); + --color-adaptive-fuchsia-600-300: oklch(83.3% 0.145 321.434); + --color-adaptive-indigo-500-a12-a16: oklch(58.5% 0.233 277.117 / 16%); + --color-adaptive-indigo-600-300: oklch(78.5% 0.115 274.713); + --color-adaptive-indigo-700-300: oklch(78.5% 0.115 274.713); + --color-adaptive-neutral-100-900: oklch(20.5% 0 none); + --color-adaptive-neutral-200-700-a60: oklch(37.1% 0 none / 60%); + --color-adaptive-neutral-200-800: oklch(26.9% 0 none); + --color-adaptive-neutral-200-a70-white-a8: rgb(255 255 255 / 8%); + --color-adaptive-neutral-200-white-a6: rgb(255 255 255 / 6%); + --color-adaptive-neutral-200-white-a8: rgb(255 255 255 / 8%); + --color-adaptive-neutral-200-a80-white-a8: rgb(255 255 255 / 8%); + --color-adaptive-neutral-300-a60-white-a12: rgb(255 255 255 / 12%); + --color-adaptive-neutral-400-500: oklch(55.6% 0 none); + --color-adaptive-neutral-400-a60-500-a60: oklch(55.6% 0 none / 60%); + --color-adaptive-neutral-400-a80-500-a80: oklch(55.6% 0 none / 80%); + --color-adaptive-neutral-500-a10-a16: oklch(55.6% 0 none / 16%); + --color-adaptive-neutral-500-400: oklch(70.8% 0 none); + --color-adaptive-neutral-500-500: oklch(55.6% 0 none); + --color-adaptive-neutral-600-300: oklch(87% 0 none); + --color-adaptive-neutral-600-400: oklch(70.8% 0 none); + --color-adaptive-neutral-950-50: oklch(98.5% 0 none); + --color-adaptive-neutral-950-a5-white-a5: rgb(255 255 255 / 5%); + --color-adaptive-neutral-950-a10-white-a10: rgb(255 255 255 / 10%); + --color-adaptive-red-50-950-a80: oklch(25.8% 0.092 26.042 / 80%); + --color-adaptive-red-200-800: oklch(44.4% 0.177 26.899); + --color-adaptive-red-600-a80-400-a80: oklch(70.4% 0.191 22.216 / 80%); + --color-adaptive-red-700-300: oklch(80.8% 0.114 19.571); + --color-adaptive-rose-100-500-a18: oklch(64.5% 0.246 16.439 / 18%); + --color-adaptive-rose-100-a80-500-a12: oklch(64.5% 0.246 16.439 / 12%); + --color-adaptive-rose-300-a70-400-a28: oklch(71.2% 0.194 13.428 / 28%); + --color-adaptive-rose-500-a12-a16: oklch(64.5% 0.246 16.439 / 16%); + --color-adaptive-rose-500-400: oklch(71.2% 0.194 13.428); + --color-adaptive-rose-600-400: oklch(71.2% 0.194 13.428); + --color-adaptive-rose-700-300: oklch(81% 0.117 11.638); + --color-adaptive-sky-500-a12-a16: oklch(68.5% 0.169 237.323 / 16%); + --color-adaptive-sky-500-a10-400-a10: oklch(74.6% 0.16 232.661 / 10%); + --color-adaptive-sky-500-a25-400-a25: oklch(74.6% 0.16 232.661 / 25%); + --color-adaptive-sky-600-400: oklch(74.6% 0.16 232.661); + --color-adaptive-sky-700-300: oklch(82.8% 0.111 230.318); + --color-adaptive-violet-500-a12-a16: oklch(60.6% 0.25 292.717 / 16%); + --color-adaptive-violet-600-400: oklch(70.2% 0.183 293.541); + --color-adaptive-violet-700-300: oklch(81.1% 0.111 293.571); + --color-adaptive-white-neutral-950-a70: oklch(14.5% 0 none / 70%); + --color-adaptive-zinc-500-a12-a16: oklch(55.2% 0.016 285.938 / 16%); + --color-adaptive-zinc-500-400: oklch(70.5% 0.015 286.067); + --color-adaptive-zinc-600-300: oklch(87.1% 0.006 286.286); + --color-clerk-page: #0a0a0a; + --color-clerk-foreground: #f5f5f5; + --color-clerk-foreground-muted: #818181; + --color-clerk-border: #191919; + --color-clerk-danger: #ff6467; + } + + @variant t3-code-classic-light { + --color-screen: #fcfcfc; + --color-sheet: rgba(250, 250, 250, 0.98); + --color-sheet-solid: #fafafa; + --color-card: #ffffff; + --color-grouped-card: #f4f4f5; + --color-card-alt: #fdfdfd; + --color-card-translucent: rgba(255, 255, 255, 0.8); + --color-thread-canvas: #fcfcfc; + --color-thread-selected: #ffffff; + --color-thread-selected-foreground: #18181b; + --color-thread-selected-foreground-muted: #71717b; + --color-thread-hover: #fcfcfc; + --color-row-hover: #f4f4f5; + --color-composer-panel: rgba(252, 252, 252, 0.88); + --color-composer-surface: rgba(244, 244, 245, 0.94); + --color-composer-border: rgba(228, 228, 231, 0.8); + --color-foreground: #18181b; + --color-foreground-secondary: #6f6f79; + --color-foreground-muted: #6f6f79; + --color-foreground-tertiary: #71717b; + --color-border: #e4e4e7; + --color-focus: #1b4ed8; + --color-border-subtle: rgba(228, 228, 231, 0.7); + --color-separator: rgba(228, 228, 231, 0.55); + --color-subtle: #fafafa; + --color-subtle-strong: #fafafa; + --color-inline-skill-background: #f4f4f5; + --color-inline-skill-border: rgba(27, 78, 216, 0.42); + --color-inline-skill-foreground: #18181b; + --color-primary: #1b4ed8; + --color-prominent-action: #1b4ed8; + --color-prominent-action-foreground: #ffffff; + --color-primary-foreground: #ffffff; + --color-primary-text: #1b4ed8; + --color-primary-shadow: #000000; + --color-secondary: #fafafa; + --color-secondary-foreground: #27272a; + --color-secondary-border: #e4e4e7; + --color-switch-active-track: #1b4ed8; + --color-switch-active-thumb: #ffffff; + --color-switch-inactive-track: #fafafa; + --color-switch-inactive-thumb: #71717b; + --color-warning: #fcf4e8; + --color-warning-border: rgba(254, 154, 0, 0.32); + --color-warning-foreground: #bb4d00; + --color-danger: #fcebec; + --color-danger-border: rgba(251, 44, 54, 0.32); + --color-danger-foreground: #c10007; + --color-update: #e0e6f7; + --color-update-foreground: #1b4ed8; + --color-input: #ffffff; + --color-input-border: #d4d4d8; + --color-sidebar-search: #f4f4f5; + --color-placeholder: #6f6f79; + --color-icon: #18181b; + --color-icon-muted: #71717b; + --color-icon-subtle: #71717b; + --color-header: rgba(250, 250, 250, 0.97); + --color-header-foreground: #18181b; + --color-header-border: #e4e4e7; + --color-glass-surface: rgba(255, 255, 255, 0.74); + --color-glass-fallback: rgba(244, 244, 245, 0.94); + --color-glass-tint: rgba(255, 255, 255, 0.22); + --color-status-bar: #fafafa; + --color-md-body: #18181b; + --color-md-strong: #18181b; + --color-md-link: #1b4ed8; + --color-md-blockquote-border: #e4e4e7; + --color-md-blockquote-bg: #fafafa; + --color-md-code-bg: #ffffff; + --color-md-code-text: #18181b; + --color-md-user-code-bg: rgba(24, 24, 27, 0.18); + --color-md-user-code-text: #18181b; + --color-md-user-fence-bg: rgba(0, 0, 0, 0.16); + --color-md-user-fence-text: #18181b; + --color-md-hr: #e4e4e7; + --color-user-bubble: #ebebed; + --color-user-bubble-foreground: #18181b; + --color-user-bubble-foreground-muted: rgba(24, 24, 27, 0.78); + --color-user-bubble-skill-foreground: #1b4ed8; + --color-backdrop: rgba(0, 0, 0, 0.22); + --color-drawer: #fafafa; + --color-drawer-foreground: #18181b; + --color-drawer-foreground-muted: #71717b; + --color-drawer-border: #e4e4e7; + --color-drawer-shadow: rgba(0, 0, 0, 0.12); + --color-dot-separator: rgba(113, 113, 123, 0.35); + --color-wordmark: #18181b; + --color-chevron: rgba(113, 113, 123, 0.42); + --color-adaptive-amber-50-950-a40: oklch(98.7% 0.022 95.277); + --color-adaptive-amber-200-900-a60: oklch(92.4% 0.12 95.746); + --color-adaptive-amber-500-a12-a16: oklch(76.9% 0.188 70.08 / 12%); + --color-adaptive-amber-500-a10-400-a10: oklch(76.9% 0.188 70.08 / 10%); + --color-adaptive-amber-500-a25-400-a25: oklch(76.9% 0.188 70.08 / 25%); + --color-adaptive-amber-700-300: oklch(55.5% 0.163 48.998); + --color-adaptive-amber-700-400: oklch(55.5% 0.163 48.998); + --color-adaptive-amber-800-200: oklch(47.3% 0.137 46.201); + --color-adaptive-blue-50-blue-400-a14: oklch(97% 0.014 254.604); + --color-adaptive-blue-300-a50-blue-400-a28: oklch(80.9% 0.105 251.813 / 50%); + --color-adaptive-blue-500-a20-blue-400-a15: oklch(62.3% 0.214 259.815 / 20%); + --color-adaptive-blue-500-400: oklch(62.3% 0.214 259.815); + --color-adaptive-blue-600-400: oklch(54.6% 0.245 262.881); + --color-adaptive-black-a10-a25: rgb(0 0 0 / 10%); + --color-adaptive-black-a15-a35: rgb(0 0 0 / 15%); + --color-adaptive-black-a2p5-white-a2p5: rgb(0 0 0 / 2.5%); + --color-adaptive-emerald-500-a12-a16: oklch(69.6% 0.17 162.48 / 12%); + --color-adaptive-emerald-600-400: oklch(59.6% 0.145 163.225); + --color-adaptive-emerald-700-300: oklch(50.8% 0.118 165.612); + --color-adaptive-fuchsia-500-a50-300-a40: oklch(66.7% 0.295 322.15 / 50%); + --color-adaptive-fuchsia-600-300: oklch(59.1% 0.293 322.896); + --color-adaptive-indigo-500-a12-a16: oklch(58.5% 0.233 277.117 / 12%); + --color-adaptive-indigo-600-300: oklch(51.1% 0.262 276.966); + --color-adaptive-indigo-700-300: oklch(45.7% 0.24 277.023); + --color-adaptive-neutral-100-900: oklch(97% 0 none); + --color-adaptive-neutral-200-700-a60: oklch(92.2% 0 none); + --color-adaptive-neutral-200-800: oklch(92.2% 0 none); + --color-adaptive-neutral-200-a70-white-a8: oklch(92.2% 0 none / 70%); + --color-adaptive-neutral-200-white-a6: oklch(92.2% 0 none); + --color-adaptive-neutral-200-white-a8: oklch(92.2% 0 none); + --color-adaptive-neutral-200-a80-white-a8: oklch(92.2% 0 none / 80%); + --color-adaptive-neutral-300-a60-white-a12: oklch(87% 0 none / 60%); + --color-adaptive-neutral-400-500: oklch(70.8% 0 none); + --color-adaptive-neutral-400-a60-500-a60: oklch(70.8% 0 none / 60%); + --color-adaptive-neutral-400-a80-500-a80: oklch(70.8% 0 none / 80%); + --color-adaptive-neutral-500-a10-a16: oklch(55.6% 0 none / 10%); + --color-adaptive-neutral-500-400: oklch(55.6% 0 none); + --color-adaptive-neutral-500-500: oklch(55.6% 0 none); + --color-adaptive-neutral-600-300: oklch(43.9% 0 none); + --color-adaptive-neutral-600-400: oklch(43.9% 0 none); + --color-adaptive-neutral-950-50: oklch(14.5% 0 none); + --color-adaptive-neutral-950-a5-white-a5: oklch(14.5% 0 none / 5%); + --color-adaptive-neutral-950-a10-white-a10: oklch(14.5% 0 none / 10%); + --color-adaptive-red-50-950-a80: oklch(97.1% 0.013 17.38); + --color-adaptive-red-200-800: oklch(88.5% 0.062 18.334); + --color-adaptive-red-600-a80-400-a80: oklch(57.7% 0.245 27.325 / 80%); + --color-adaptive-red-700-300: oklch(50.5% 0.213 27.518); + --color-adaptive-rose-100-500-a18: oklch(94.1% 0.03 12.58); + --color-adaptive-rose-100-a80-500-a12: oklch(94.1% 0.03 12.58 / 80%); + --color-adaptive-rose-300-a70-400-a28: oklch(81% 0.117 11.638 / 70%); + --color-adaptive-rose-500-a12-a16: oklch(64.5% 0.246 16.439 / 12%); + --color-adaptive-rose-500-400: oklch(64.5% 0.246 16.439); + --color-adaptive-rose-600-400: oklch(58.6% 0.253 17.585); + --color-adaptive-rose-700-300: oklch(51.4% 0.222 16.935); + --color-adaptive-sky-500-a12-a16: oklch(68.5% 0.169 237.323 / 12%); + --color-adaptive-sky-500-a10-400-a10: oklch(68.5% 0.169 237.323 / 10%); + --color-adaptive-sky-500-a25-400-a25: oklch(68.5% 0.169 237.323 / 25%); + --color-adaptive-sky-600-400: oklch(58.8% 0.158 241.966); + --color-adaptive-sky-700-300: oklch(50% 0.134 242.749); + --color-adaptive-violet-500-a12-a16: oklch(60.6% 0.25 292.717 / 12%); + --color-adaptive-violet-600-400: oklch(54.1% 0.281 293.009); + --color-adaptive-violet-700-300: oklch(49.1% 0.27 292.581); + --color-adaptive-white-neutral-950-a70: #fff; + --color-adaptive-zinc-500-a12-a16: oklch(55.2% 0.016 285.938 / 12%); + --color-adaptive-zinc-500-400: oklch(55.2% 0.016 285.938); + --color-adaptive-zinc-600-300: oklch(44.2% 0.017 285.786); + --color-clerk-page: #fcfcfc; + --color-clerk-foreground: #272727; + --color-clerk-foreground-muted: #727272; + --color-clerk-border: #e4e4e4; + --color-clerk-danger: #c10007; + } + + @variant t3-code-classic-dark { + --color-screen: #0e0e0e; + --color-sheet: rgba(14, 14, 14, 0.98); + --color-sheet-solid: #0e0e0e; --color-card: #111111; --color-grouped-card: #1b1b1b; --color-card-alt: #111111; --color-card-translucent: rgba(17, 17, 17, 0.8); - --color-thread-canvas: #0a0a0a; + --color-thread-canvas: #0e0e0e; --color-thread-selected: #1b1b1b; - --color-thread-selected-foreground: #f3f3f3; + --color-thread-selected-foreground: #f1f3f7; --color-thread-selected-foreground-muted: #a3a3a3; - --color-thread-hover: #131313; - --color-row-hover: #141414; - --color-composer-panel: rgba(10, 10, 10, 0.92); + --color-thread-hover: #131314; + --color-row-hover: rgba(255, 255, 255, 0.04); + --color-composer-panel: rgba(14, 14, 14, 0.92); --color-composer-surface: rgba(27, 27, 27, 0.9); - --color-composer-border: rgba(25, 25, 25, 0.8); + --color-composer-border: rgba(255, 255, 255, 0.8); --color-foreground: #f5f5f5; - --color-foreground-secondary: #838383; - --color-foreground-muted: #838383; - --color-foreground-tertiary: #818181; - --color-border: #191919; + --color-foreground-secondary: #a1a1a1; + --color-foreground-muted: #a1a1a1; + --color-foreground-tertiary: #a1a1a1; + --color-border: rgba(255, 255, 255, 0.08); --color-focus: #346bf1; - --color-border-subtle: rgba(25, 25, 25, 0.7); - --color-separator: rgba(25, 25, 25, 0.55); - --color-subtle: #111111; - --color-subtle-strong: #111111; - --color-inline-skill-background: #141414; + --color-border-subtle: rgba(255, 255, 255, 0.08); + --color-separator: rgba(255, 255, 255, 0.08); + --color-subtle: #080808; + --color-subtle-strong: #080808; + --color-inline-skill-background: #0a0a0a; --color-inline-skill-border: rgba(52, 107, 241, 0.42); --color-inline-skill-foreground: #f5f5f5; --color-primary: #346bf1; - --color-prominent-action: #f5f5f5; - --color-prominent-action-foreground: #0a0a0a; + --color-prominent-action: #346bf1; + --color-prominent-action-foreground: #ffffff; --color-primary-foreground: #ffffff; --color-primary-text: #4c7df3; --color-primary-shadow: #000000; - --color-secondary: #111111; + --color-secondary: #080808; --color-secondary-foreground: #f5f5f5; - --color-secondary-border: #191919; + --color-secondary-border: rgba(255, 255, 255, 0.08); --color-switch-active-track: #346bf1; --color-switch-active-thumb: #ffffff; - --color-switch-inactive-track: #111111; - --color-switch-inactive-thumb: #818181; - --color-warning: #312108; + --color-switch-inactive-track: #080808; + --color-switch-inactive-thumb: #a1a1a1; + --color-warning: #291900; --color-warning-border: rgba(254, 154, 0, 0.32); --color-warning-foreground: #ffb900; - --color-danger: #301214; + --color-danger: #280a0c; --color-danger-border: rgba(251, 65, 74, 0.32); --color-danger-foreground: #ff6467; --color-update: #121b34; --color-update-foreground: #51a2ff; --color-input: #111111; - --color-input-border: #1e1e1e; + --color-input-border: rgba(255, 255, 255, 0.11); --color-sidebar-search: #0a0a0a; - --color-placeholder: #838383; + --color-placeholder: #a1a1a1; --color-icon: #f5f5f5; - --color-icon-muted: #818181; - --color-icon-subtle: #818181; - --color-header: rgba(10, 10, 10, 0.97); + --color-icon-muted: #a1a1a1; + --color-icon-subtle: #a1a1a1; + --color-header: #000000; --color-header-foreground: #f5f5f5; - --color-header-border: #191919; + --color-header-border: rgba(255, 255, 255, 0.08); --color-glass-surface: rgba(17, 17, 17, 0.74); --color-glass-fallback: rgba(27, 27, 27, 0.9); --color-glass-tint: rgba(17, 17, 17, 0.22); - --color-status-bar: #0a0a0a; + --color-status-bar: #000000; --color-md-body: #f5f5f5; --color-md-strong: #f5f5f5; - --color-md-link: #3b70f1; - --color-md-blockquote-border: #191919; - --color-md-blockquote-bg: #111111; - --color-md-code-bg: #111111; + --color-md-link: #3e73f2; + --color-md-blockquote-border: rgba(255, 255, 255, 0.08); + --color-md-blockquote-bg: #080808; + --color-md-code-bg: #101010; --color-md-code-text: #f5f5f5; --color-md-user-code-bg: rgba(245, 245, 245, 0.18); --color-md-user-code-text: #f5f5f5; --color-md-user-fence-bg: rgba(0, 0, 0, 0.28); --color-md-user-fence-text: #f5f5f5; - --color-md-hr: #191919; - --color-user-bubble: #161616; + --color-md-hr: rgba(255, 255, 255, 0.08); + --color-user-bubble: #1e1e1e; --color-user-bubble-foreground: #f5f5f5; --color-user-bubble-foreground-muted: rgba(245, 245, 245, 0.78); - --color-user-bubble-skill-foreground: #4678f2; + --color-user-bubble-skill-foreground: #507ff3; --color-backdrop: rgba(0, 0, 0, 0.48); --color-drawer: #000000; - --color-drawer-foreground: #f3f3f3; + --color-drawer-foreground: #f1f3f7; --color-drawer-foreground-muted: #a3a3a3; - --color-drawer-border: #141414; + --color-drawer-border: rgba(255, 255, 255, 0.08); --color-drawer-shadow: rgba(0, 0, 0, 0.32); - --color-dot-separator: rgba(129, 129, 129, 0.35); + --color-dot-separator: rgba(161, 161, 161, 0.35); --color-wordmark: #f5f5f5; - --color-chevron: rgba(129, 129, 129, 0.42); + --color-chevron: rgba(161, 161, 161, 0.42); --color-adaptive-amber-50-950-a40: oklch(27.9% 0.077 45.635 / 40%); --color-adaptive-amber-200-900-a60: oklch(41.4% 0.112 45.904 / 60%); --color-adaptive-amber-500-a12-a16: oklch(76.9% 0.188 70.08 / 16%); @@ -548,13 +874,13 @@ --color-icon: #f9f8fb; --color-icon-muted: #d4c7e1; --color-icon-subtle: #e7d0dd; - --color-header: rgba(31, 26, 36, 0.97); + --color-header: #0d090d; --color-header-foreground: #f9f8fb; --color-header-border: #27242c; --color-glass-surface: rgba(41, 35, 45, 0.74); --color-glass-fallback: rgba(41, 35, 45, 0.9); --color-glass-tint: rgba(41, 35, 45, 0.22); - --color-status-bar: #1f1a24; + --color-status-bar: #0d090d; --color-md-body: #f9f8fb; --color-md-strong: #f9f8fb; --color-md-link: #c66290; @@ -874,13 +1200,13 @@ --color-icon: #fffaff; --color-icon-muted: #919595; --color-icon-subtle: #919595; - --color-header: rgba(27, 40, 33, 0.97); + --color-header: #121e18; --color-header-foreground: #fffaff; --color-header-border: #36654c; --color-glass-surface: rgba(68, 77, 73, 0.74); --color-glass-fallback: rgba(37, 62, 49, 0.9); --color-glass-tint: rgba(68, 77, 73, 0.22); - --color-status-bar: #1b2821; + --color-status-bar: #121e18; --color-md-body: #fffaff; --color-md-strong: #fffaff; --color-md-link: #e3b34e; @@ -1200,13 +1526,13 @@ --color-icon: #fffaff; --color-icon-muted: #8d8f97; --color-icon-subtle: #8d8f97; - --color-header: rgba(23, 33, 43, 0.97); + --color-header: #111920; --color-header-foreground: #fffaff; --color-header-border: #36566f; --color-glass-surface: rgba(65, 72, 81, 0.74); --color-glass-fallback: rgba(35, 53, 68, 0.9); --color-glass-tint: rgba(65, 72, 81, 0.22); - --color-status-bar: #17212b; + --color-status-bar: #111920; --color-md-body: #fffaff; --color-md-strong: #fffaff; --color-md-link: #5bd0d6; @@ -1526,13 +1852,13 @@ --color-icon: #fffaff; --color-icon-muted: #968e8f; --color-icon-subtle: #968e8f; - --color-header: rgba(41, 30, 26, 0.97); + --color-header: #1f1611; --color-header-foreground: #fffaff; --color-header-border: #6e4934; --color-glass-surface: rgba(79, 69, 67, 0.74); --color-glass-fallback: rgba(67, 46, 35, 0.9); --color-glass-tint: rgba(79, 69, 67, 0.22); - --color-status-bar: #291e1a; + --color-status-bar: #1f1611; --color-md-body: #fffaff; --color-md-strong: #fffaff; --color-md-link: #f78a7a; @@ -1852,13 +2178,13 @@ --color-icon: #fffaff; --color-icon-muted: #8e8a95; --color-icon-subtle: #8e8a95; - --color-header: rgba(29, 25, 41, 0.97); + --color-header: #15121f; --color-header-foreground: #fffaff; --color-header-border: #4a3c70; --color-glass-surface: rgba(69, 66, 80, 0.74); --color-glass-fallback: rgba(45, 38, 67, 0.9); --color-glass-tint: rgba(69, 66, 80, 0.22); - --color-status-bar: #1d1929; + --color-status-bar: #15121f; --color-md-body: #fffaff; --color-md-strong: #fffaff; --color-md-link: #f099d8; diff --git a/apps/mobile/scripts/generate-uniwind-themes.mts b/apps/mobile/scripts/generate-uniwind-themes.mts index 7f087395ad3c6..8a0f586e67424 100644 --- a/apps/mobile/scripts/generate-uniwind-themes.mts +++ b/apps/mobile/scripts/generate-uniwind-themes.mts @@ -3,11 +3,15 @@ import * as NodeFS from "node:fs"; import * as NodePath from "node:path"; import tailwindColors from "tailwindcss/colors"; -import { BUILT_IN_THEME_IDS, type BuiltInThemeId } from "@t3tools/shared/themePalettes"; +import { + BUILT_IN_THEME_IDS, + T3_CODE_DARK_THEME_COLORS, + T3_CODE_LIGHT_THEME_COLORS, + type BuiltInThemeId, +} from "@t3tools/shared/themePalettes"; import { createMobileThemeVariables, - getMobileThemeColors, getMobileThemeVariables, DEFAULT_MOBILE_THEME_ID, type MobileThemeAppearance, @@ -178,7 +182,7 @@ const variablesFor = (themeId: BuiltInThemeId, appearance: MobileThemeAppearance // Profile pages must match Clerk's build-time palette even with a named app theme. const clerkColorsFor = (appearance: MobileThemeAppearance) => { const variables = createMobileThemeVariables( - getMobileThemeColors(DEFAULT_MOBILE_THEME_ID, appearance), + appearance === "dark" ? T3_CODE_DARK_THEME_COLORS : T3_CODE_LIGHT_THEME_COLORS, appearance, ); return { diff --git a/apps/mobile/scripts/generate-uniwind-themes.test.ts b/apps/mobile/scripts/generate-uniwind-themes.test.ts index 3648d96e18bb4..3bc939df6be2b 100644 --- a/apps/mobile/scripts/generate-uniwind-themes.test.ts +++ b/apps/mobile/scripts/generate-uniwind-themes.test.ts @@ -22,6 +22,24 @@ describe("generate mobile Uniwind themes", () => { const lightVariables = themes.get("light")!; for (const [name, variables] of themes) { expect([...variables.keys()].sort(), name).toEqual([...lightVariables.keys()].sort()); + if (name === "dark") { + for (const [role, color] of Object.entries({ + "--color-header": "#000000", + "--color-thread-canvas": "#151515", + "--color-sheet-solid": "#151515", + "--color-card-alt": "#1d1d1d", + "--color-grouped-card": "#1d1d1d", + "--color-border": "#242424", + "--color-primary": "#eeeeee", + "--color-primary-foreground": "#111111", + })) { + expect(variables.get(role), role).toBe(color); + } + } + if (name === "light") { + expect(variables.get("--color-primary")).toBe("#111111"); + expect(variables.get("--color-primary-foreground")).toBe("#ffffff"); + } const isDark = name === "dark" || name.endsWith("-dark"); expect( Object.fromEntries( diff --git a/apps/mobile/src/features/home/ThreadListFilters.tsx b/apps/mobile/src/features/home/ThreadListFilters.tsx index 196e6a86480b5..0c01db4850017 100644 --- a/apps/mobile/src/features/home/ThreadListFilters.tsx +++ b/apps/mobile/src/features/home/ThreadListFilters.tsx @@ -1,6 +1,7 @@ import { Pressable, ScrollView } from "react-native"; import { SymbolView } from "../../components/ui/AppSymbol"; import { ListFilterMenu } from "../layout/workspace/ListFilterMenu"; +import { THREAD_LIST_V2_SIDEBAR_ROW_STYLE } from "../threads/thread-list-v2-row-appearance"; import type { EnvironmentId } from "@t3tools/contracts"; import type { HomeListFilterMenuEnvironment, @@ -28,7 +29,16 @@ export function ThreadListFilters(props: { horizontal showsHorizontalScrollIndicator={false} style={{ marginHorizontal: -props.gutter, overflow: "visible" }} - contentContainerStyle={{ paddingHorizontal: 20, paddingTop: 2, paddingBottom: 10, gap: 8 }} + // Same inset as the rows below: phone rows use `px-5`, sidebar rows the card padding. + contentContainerClassName={props.gutter ? undefined : "px-5"} + contentContainerStyle={{ + ...(props.gutter + ? { paddingHorizontal: props.gutter + THREAD_LIST_V2_SIDEBAR_ROW_STYLE.paddingHorizontal } + : {}), + paddingTop: 16, + paddingBottom: 10, + gap: 8, + }} > JSON.stringify([field, value]); function choices(field: string, options: Readonly>, selected: string) { return Object.entries(options).map(([value, title]) => ({ @@ -42,6 +43,7 @@ function choices(field: string, options: Readonly>, selec } type ProjectOrder = readonly { + readonly label: string; readonly memberProjectRefs: readonly ScopedProjectRef[]; readonly pinned: boolean; readonly archived: boolean; @@ -51,6 +53,7 @@ const sameProjectOrder = (previous: ProjectOrder, next: ProjectOrder) => previous.every((group, index) => { const members = next[index]!.memberProjectRefs; return ( + group.label === next[index]!.label && group.pinned === next[index]!.pinned && group.archived === next[index]!.archived && group.memberProjectRefs.length === members.length && @@ -76,6 +79,7 @@ const sidebarProjectOrderFamily = Atom.family((mode: SidebarProjectGroupingMode) pendingTasks: [], projectSortOrder: "updated_at", }).map((scope) => ({ + label: scope.title, memberProjectRefs: scope.projectRefs, pinned: scope.pinned, archived: scope.archived, @@ -83,12 +87,9 @@ const sidebarProjectOrderFamily = Atom.family((mode: SidebarProjectGroupingMode) ).pipe(Atom.withEquality(sameProjectOrder)), ); -function useProjectsInSidebarOrder( - projects: readonly Project[], -) { +export function usePullRequestProjectGroups() { const { sidebarProjectGroupingMode } = useMobileProjectGroupingSettings(); - const scopes = useAtomValue(sidebarProjectOrderFamily(sidebarProjectGroupingMode)); - return sortProjectsByGroupOrder(projects, scopes); + return useAtomValue(sidebarProjectOrderFamily(sidebarProjectGroupingMode)); } export interface PullRequestFilterMenu { @@ -117,7 +118,7 @@ export function usePullRequestListFilters(props: { readonly onSearch: (kind: "labels" | "authors") => void; }) { const { value } = props; - const projects = useProjectsInSidebarOrder(props.projects); + const projects = sortProjectsByGroupOrder(props.projects, usePullRequestProjectGroups()); const selectedLabels = value.filters.labels?.flat() ?? []; const selectedLabelSet = new Set(selectedLabels); const author = value.filters.author; @@ -368,11 +369,16 @@ export function PullRequestListFilters(props: { horizontal showsHorizontalScrollIndicator={false} style={{ overflow: "visible" }} - // In the Android sidebar the first pill lines up with the rows' content instead - // (8dp row margin + 12dp padding). + // The first pill lines up with the rows' content: iOS rows use `px-4` on phone and the + // card padding in the sidebar; Android sidebar rows sit at 8dp margin + 12dp padding. + contentContainerClassName={Platform.OS === "ios" && !props.sidebar ? "px-4" : undefined} contentContainerStyle={ - props.sidebar && Platform.OS === "android" - ? { ...ROW_STYLE, paddingHorizontal: 20 } + props.sidebar + ? { + ...ROW_STYLE, + paddingHorizontal: + Platform.OS === "android" ? 20 : THREAD_LIST_V2_SIDEBAR_ROW_STYLE.paddingHorizontal, + } : ROW_STYLE } > diff --git a/apps/mobile/src/features/pull-requests/PullRequestsScreen.tsx b/apps/mobile/src/features/pull-requests/PullRequestsScreen.tsx index d0672f064064a..57c4c881a6ba9 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestsScreen.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestsScreen.tsx @@ -1,3 +1,4 @@ +import { pullRequestProjectFilterQueries } from "@t3tools/client-runtime/pull-request-project-filter"; import { usePaneRole } from "../../components/ui/Pane"; import { Button } from "../../components/ui/Button"; import { @@ -27,6 +28,7 @@ import { PullRequestListRow } from "./PullRequestListRow"; import { PullRequestListFilters, usePullRequestListFilters, + usePullRequestProjectGroups, type PullRequestFilterMenu, } from "./PullRequestListFilters"; import { usePullRequestList } from "./usePullRequestList"; @@ -145,23 +147,42 @@ export function PullRequestsScreen( () => withoutScratchProjectScope(savedControls, allProjects), [allProjects, savedControls], ); - const targets = useMemo( - () => - environments - .filter( - (environment) => - configs.get(environment.environmentId) !== undefined && - (environmentId === null || environment.environmentId === environmentId) && - (!controls.projectEnvironmentId || - environment.environmentId === controls.projectEnvironmentId), - ) - .sort((left, right) => left.environmentId.localeCompare(right.environmentId)) - .map((environment) => ({ - environmentId: environment.environmentId, - input: pullRequestListInput(controls, query), - })), - [environments, environmentId, configs, controls, query], - ); + const projectGroups = usePullRequestProjectGroups(); + const projects = pullRequestProjectChoices(allProjects, { + environmentId, + controls, + groups: projectGroups, + servesPullRequests: (id) => configs.get(id)?.environment.capabilities.pullRequests === true, + }); + const selectedProject = controls.projectId + ? { id: controls.projectId, environmentId: controls.projectEnvironmentId } + : undefined; + const projectRefs = + selectedProject === undefined + ? undefined + : (projects.find( + (project) => + project.id === selectedProject.id && + project.environmentId === selectedProject.environmentId, + )?.memberProjectRefs ?? []); + const targets = pullRequestProjectFilterQueries( + projects, + environments + .filter( + (environment) => + configs.get(environment.environmentId) !== undefined && + (environmentId === null || environment.environmentId === environmentId), + ) + .map((environment) => environment.environmentId) + .sort(), + selectedProject, + ).map(({ environmentId: targetEnvironmentId, projectIds }) => ({ + environmentId: targetEnvironmentId, + input: { + ...pullRequestListInput({ ...controls, projectId: undefined }, query), + ...(projectIds ? { projectIds } : {}), + }, + })); const result = usePullRequestList(targets); const connectedTargets = targets.filter((target) => environments.some( @@ -187,8 +208,9 @@ export function PullRequestsScreen( query, result.data.localSearchHosts, new Set(connectedKey.split("\n")), + projectRefs, ), - [result.entries, controls, query, result.data, connectedKey], + [result.entries, controls, query, result.data, connectedKey, projectRefs], ); const facets = pullRequestListFacets(result.entries); const [knownFacets, setKnownFacets] = useState({ environmentId, source: facets, ...facets }); @@ -216,14 +238,6 @@ export function PullRequestsScreen( } const labelColors = knownFacets.environmentId === environmentId ? knownFacets.labelColors : facets.labelColors; - const projects = pullRequestProjectChoices(allProjects, { - environmentId, - controls, - environmentLabels: new Map( - environments.map((item) => [item.environmentId, item.environmentLabel]), - ), - servesPullRequests: (id) => configs.get(id)?.environment.capabilities.pullRequests === true, - }); const filters = usePullRequestListFilters({ environments: environments.map((item) => ({ id: String(item.environmentId), diff --git a/apps/mobile/src/features/pull-requests/pullRequestList.test.ts b/apps/mobile/src/features/pull-requests/pullRequestList.test.ts index b6f47b6533ee2..79ef814bb28a0 100644 --- a/apps/mobile/src/features/pull-requests/pullRequestList.test.ts +++ b/apps/mobile/src/features/pull-requests/pullRequestList.test.ts @@ -240,6 +240,23 @@ describe("all-environment PR list", () => { merged.localSearchHosts, ).map((row) => row.environmentId), ).toEqual([mbp]); + expect( + filterPullRequestList( + [ + ...merged.entries, + { ...entry(3, { projectId: ProjectId.make("unrelated") }), environmentId: mbp }, + ], + { ...DEFAULT_PR_LIST_CONTROLS, projectId: "project", projectEnvironmentId: mbp }, + merged.viewers, + "", + merged.localSearchHosts, + undefined, + [cups, mbp].map((environmentId) => ({ + environmentId, + projectId: ProjectId.make("project"), + })), + ).map((row) => row.environmentId), + ).toEqual([mbp, cups]); }); }); @@ -422,18 +439,20 @@ describe("pullRequestProjectChoices", () => { pullRequestProjectChoices(projects, { environmentId: null, controls: { ...DEFAULT_PR_LIST_CONTROLS, ...scope }, - environmentLabels: new Map([ - [laptop, "laptop"], - [desktop, "desktop"], - ]), + groups: [ + { + label: "t3code", + memberProjectRefs: projects.map((project) => ({ + environmentId: project.environmentId, + projectId: project.id, + })), + }, + ], servesPullRequests, }).map(({ id, environmentId, title }) => ({ id, environmentId, title })); - it("lists each repository once per environment that serves pull requests", () => { - expect(choices(() => true)).toEqual([ - { id: "main", environmentId: desktop, title: "t3code · desktop" }, - { id: "main", environmentId: laptop, title: "t3code · laptop" }, - ]); + it("lists one sidebar group across environments that serve pull requests", () => { + expect(choices(() => true)).toEqual([{ id: "main", environmentId: laptop, title: "t3code" }]); expect(choices((environmentId) => environmentId === desktop)).toEqual([ { id: "main", environmentId: desktop, title: "t3code" }, ]); @@ -442,8 +461,7 @@ describe("pullRequestProjectChoices", () => { it("keeps the saved scope when its environment stops serving or has not loaded", () => { const scope = { projectId: "worktree", projectEnvironmentId: laptop }; expect(choices((environmentId) => environmentId === desktop, scope)).toEqual([ - { id: "main", environmentId: desktop, title: "t3code · desktop" }, - { id: "worktree", environmentId: laptop, title: "t3code · laptop" }, + { id: "worktree", environmentId: laptop, title: "t3code" }, ]); expect(choices(() => false, scope)).toEqual([ { id: "worktree", environmentId: laptop, title: "t3code" }, diff --git a/apps/mobile/src/features/pull-requests/pullRequestList.ts b/apps/mobile/src/features/pull-requests/pullRequestList.ts index 5a4572dbde803..ae298b7d361f4 100644 --- a/apps/mobile/src/features/pull-requests/pullRequestList.ts +++ b/apps/mobile/src/features/pull-requests/pullRequestList.ts @@ -1,4 +1,7 @@ -import { pullRequestFilterProjects } from "@t3tools/client-runtime/pull-request-project-filter"; +import { + pullRequestFilterProjects, + type PullRequestProjectFilterGroup, +} from "@t3tools/client-runtime/pull-request-project-filter"; import { parsePullRequestQuery } from "@t3tools/client-runtime/pull-request-query"; import { ProjectId, @@ -9,6 +12,7 @@ import { type PullRequestListInput, type PullRequestListResult, type PullRequestListStatsInput, + type ScopedProjectRef, } from "@t3tools/contracts"; import type { PullRequestListControls } from "../../persistence/mobile-preferences"; import { compareTimestamps } from "../../lib/time"; @@ -45,7 +49,7 @@ export function pullRequestProjectChoices< input: { readonly environmentId: string | null; readonly controls: PullRequestListControls; - readonly environmentLabels: ReadonlyMap; + readonly groups: ReadonlyArray; readonly servesPullRequests: (environmentId: EnvironmentId) => boolean; }, ) { @@ -59,7 +63,7 @@ export function pullRequestProjectChoices< (input.environmentId === null || project.environmentId === input.environmentId) && (input.servesPullRequests(project.environmentId) || isSelected(project)), ), - input.environmentLabels, + input.groups, projects.find(isSelected), ); } @@ -406,6 +410,7 @@ export function filterPullRequestList< query: string, localSearchHosts: ReadonlySet, connectedEnvironmentIds?: ReadonlySet, + projectRefs?: ReadonlyArray, ) { const normalize = (value: string) => value.toLowerCase(); const parsed = parsePullRequestQuery(query); @@ -413,9 +418,18 @@ export function filterPullRequestList< const filters = { ...controls.filters, ...parsed.filters }; const filtered = entries.filter((entry) => { if (controls.state !== "all" && entry.state !== controls.state) return false; - if (controls.projectId && entry.projectId !== controls.projectId) return false; - if (controls.projectEnvironmentId && entry.environmentId !== controls.projectEnvironmentId) - return false; + if (projectRefs !== undefined) { + if ( + !projectRefs.some( + (ref) => ref.projectId === entry.projectId && ref.environmentId === entry.environmentId, + ) + ) + return false; + } else { + if (controls.projectId && entry.projectId !== controls.projectId) return false; + if (controls.projectEnvironmentId && entry.environmentId !== controls.projectEnvironmentId) + return false; + } const viewerKey = entry.environmentId ? `${entry.environmentId} ${entry.host}` : entry.host; const viewer = viewers[viewerKey]; if (controls.involvement === "reviewing" && !entry.viewerReviewRequested) return false; diff --git a/apps/mobile/src/features/usage/UsageLimitsSection.tsx b/apps/mobile/src/features/usage/UsageLimitsSection.tsx index be4bf0c929d11..316820bdfd6bc 100644 --- a/apps/mobile/src/features/usage/UsageLimitsSection.tsx +++ b/apps/mobile/src/features/usage/UsageLimitsSection.tsx @@ -174,7 +174,10 @@ export function AccountLimits(props: { const providerColor = useBarColor(props.driver); const color = normalizeProviderAccentColor(props.accentColor) ?? providerColor; if (!limits) return null; - const notice = limitsNotice(limits); + const notice = + limits.unavailable?.reason === "unsupported" + ? "Usage unavailable for this API connection" + : limitsNotice(limits); const externalUsage = limits.externalUsage; const padding = dense ? "px-4 py-3" : "p-4"; return ( diff --git a/apps/mobile/src/lib/composerChipPresentation.test.ts b/apps/mobile/src/lib/composerChipPresentation.test.ts index 21474f6de7e96..f6c9c414b46ce 100644 --- a/apps/mobile/src/lib/composerChipPresentation.test.ts +++ b/apps/mobile/src/lib/composerChipPresentation.test.ts @@ -105,7 +105,7 @@ describe("contextChipAccent", () => { it("keeps an accent that already reads everywhere", () => { const theme = getMobileThemeVariables("t3-code", "dark"); - expect(contextChipAccent("#009f6e", theme, theme["--color-md-body"])).toBe("#009f6e"); + expect(contextChipAccent("#00b080", theme, theme["--color-md-body"])).toBe("#00b080"); }); }); diff --git a/apps/mobile/src/lib/materialYouTheme.android.ts b/apps/mobile/src/lib/materialYouTheme.android.ts index 311518428efb9..c1140f2eb925f 100644 --- a/apps/mobile/src/lib/materialYouTheme.android.ts +++ b/apps/mobile/src/lib/materialYouTheme.android.ts @@ -84,13 +84,13 @@ export function materialYouPaletteToMobileThemeVariables( "--color-icon": palette.onSurface, "--color-icon-muted": palette.onSurfaceVariant, "--color-icon-subtle": withAlpha(palette.onSurfaceVariant, 0.68), - "--color-header": palette.surfaceContainerHigh, + "--color-header": dark ? palette.surfaceContainerLowest : palette.surfaceContainerHigh, "--color-header-foreground": palette.onSurface, "--color-header-border": withAlpha(palette.outlineVariant, dark ? 0.36 : 0.48), "--color-glass-surface": withAlpha(palette.surfaceContainerHigh, dark ? 0.78 : 0.72), "--color-glass-fallback": withAlpha(palette.surfaceContainerHigh, dark ? 0.9 : 0.94), "--color-glass-tint": withAlpha(palette.primaryContainer, dark ? 0.24 : 0.18), - "--color-status-bar": palette.surfaceContainerLow, + "--color-status-bar": dark ? palette.surfaceContainerLowest : palette.surfaceContainerHigh, "--color-md-body": materialTone(palette.onSurfaceVariant, dark ? 90 : 10), "--color-md-strong": materialTone(palette.onSurfaceVariant, dark ? 90 : 10), "--color-md-link": palette.primary, diff --git a/apps/mobile/src/lib/mobileTheme.test.ts b/apps/mobile/src/lib/mobileTheme.test.ts index 0b35a5ba27d31..acefc6fc9c895 100644 --- a/apps/mobile/src/lib/mobileTheme.test.ts +++ b/apps/mobile/src/lib/mobileTheme.test.ts @@ -3,8 +3,6 @@ import { BUILT_IN_THEME_IDS, BUILT_IN_THEMES, T3_CHAT_THEME, - T3_CODE_LIGHT_THEME_COLORS, - T3_CODE_DARK_THEME_COLORS, MOBILE_THEME_IDS, getThemeColorsForAppearance, } from "@t3tools/shared/themePalettes"; @@ -19,6 +17,7 @@ import { createMobileThemeVariables, DEFAULT_MOBILE_THEME_ID, flattenThemeColor, + getMobileThemeColors, getMobileThemeVariables, resolveMobileThemeIds, themeColorWithAlpha, @@ -70,11 +69,10 @@ describe("mobile themes", () => { it.each(MOBILE_THEME_IDS)("uses the web color roles for %s in both appearances", (themeId) => { for (const appearance of ["light", "dark"] as const) { const theme = BUILT_IN_THEMES.find((candidate) => candidate.id === themeId); - const colors = theme - ? getThemeColorsForAppearance(theme, appearance)! - : appearance === "dark" - ? T3_CODE_DARK_THEME_COLORS - : T3_CODE_LIGHT_THEME_COLORS; + const colors = + theme && themeId !== "t3-code-classic" + ? getThemeColorsForAppearance(theme, appearance)! + : getMobileThemeColors(themeId, appearance); const variables = themeId === DEFAULT_MOBILE_THEME_ID ? readDefaultMobileThemeVariables(appearance) @@ -96,6 +94,12 @@ describe("mobile themes", () => { themeColorToNativeColor(colors.sidebarForeground), ); expect(variables["--color-primary"]).toBe(themeColorToNativeColor(colors.messageAction)); + if (themeId === DEFAULT_MOBILE_THEME_ID) { + expect(variables["--color-prominent-action"]).toBe(variables["--color-primary"]); + expect(variables["--color-prominent-action-foreground"]).toBe( + variables["--color-primary-foreground"], + ); + } if (themeId !== DEFAULT_MOBILE_THEME_ID) { expect(variables["--color-user-bubble"]).toBe( themeColorToNativeColor(colors.messageSurface), @@ -117,6 +121,8 @@ describe("mobile themes", () => { ["--color-primary-text", "--color-sheet-solid"], ["--color-foreground", "--color-sheet-solid"], ["--color-foreground-muted", "--color-sheet-solid"], + ["--color-foreground-secondary", "--color-screen"], + ["--color-foreground-tertiary", "--color-screen"], ["--color-primary-text", "--color-grouped-card"], ["--color-foreground", "--color-grouped-card"], ["--color-foreground-muted", "--color-grouped-card"], @@ -169,6 +175,12 @@ describe("mobile themes", () => { const variables = getMobileThemeRuntimeVariables(themeId, appearance, platform); const sheet = variables["--color-sheet-solid"]; const group = variables["--color-grouped-card"]; + if (appearance === "dark") { + expect( + relativeLuminance(flattenThemeColor(variables["--color-header"], sheet)), + `${platform}: chrome below the sheet`, + ).toBeLessThan(relativeLuminance(sheet)); + } expect( contrastRatio(group, sheet), `${platform} ${appearance}: grouped surface on sheet`, @@ -198,7 +210,7 @@ describe("mobile themes", () => { expect( contrastRatio(group, surface), `dark grouped surface on Android ${surface}`, - ).toBeGreaterThanOrEqual(1.2); + ).toBeGreaterThanOrEqual(1.025); } } } diff --git a/apps/mobile/src/lib/mobileTheme.ts b/apps/mobile/src/lib/mobileTheme.ts index 369a4e7f3fda9..1be8e8ede85a2 100644 --- a/apps/mobile/src/lib/mobileTheme.ts +++ b/apps/mobile/src/lib/mobileTheme.ts @@ -331,6 +331,9 @@ export function createMobileThemeVariables( ) { const c = nativeColors(colors); const groupedCard = themeColorToNativeColor(groupedCardColor); + // Keep the sidebar hue used by web chrome, below the native content sheet in dark mode. + const chrome = + appearance === "dark" ? flattenThemeColor(withAlpha(c.sidebar, 0.55), "#000000") : c.toolbar; const textSurfaces = [ c.canvas, c.surface, @@ -407,13 +410,13 @@ export function createMobileThemeVariables( "--color-icon": c.text, "--color-icon-muted": c.iconMuted, "--color-icon-subtle": c.secondaryLabel, - "--color-header": withAlpha(c.toolbar, 0.97), + "--color-header": appearance === "dark" ? chrome : withAlpha(chrome, 0.97), "--color-header-foreground": c.toolbarForeground, "--color-header-border": c.toolbarBorder, "--color-glass-surface": withAlpha(c.surfaceOverlay, 0.74), "--color-glass-fallback": themeColorWithAlpha(groupedCard, appearance === "dark" ? 0.9 : 0.94), "--color-glass-tint": withAlpha(c.surfaceOverlay, 0.22), - "--color-status-bar": c.canvas, + "--color-status-bar": chrome, "--color-md-body": c.text, "--color-md-strong": c.text, "--color-md-link": themeColorWithContrast(c.messageAction, c.canvas), @@ -453,10 +456,39 @@ export function getMobileThemeColors( appearance: MobileThemeAppearance, ): ThemeColors { if (themeId === DEFAULT_MOBILE_THEME_ID) { - return appearance === "dark" ? T3_CODE_DARK_THEME_COLORS : T3_CODE_LIGHT_THEME_COLORS; + return appearance === "dark" + ? { + ...T3_CODE_DARK_THEME_COLORS, + canvas: "#151515", + chrome: "#151515", + surface: "#1d1d1d", + surfaceRaised: "#1d1d1d", + surfaceOverlay: "#1d1d1d", + secondary: "#1d1d1d", + muted: "#1d1d1d", + border: "#242424", + textMuted: "#8a8a8a", + mutedForeground: "#8a8a8a", + secondaryLabel: "#8a8a8a", + iconMuted: "#8a8a8a", + messageSurface: "#1d1d1d", + messageAction: "#eeeeee", + messageActionForeground: "#111111", + } + : { + ...T3_CODE_LIGHT_THEME_COLORS, + messageAction: "#111111", + messageActionForeground: "#ffffff", + }; } const theme = BUILT_IN_THEMES.find((candidate) => candidate.id === themeId) ?? T3_CHAT_THEME; - return getThemeColorsForAppearance(theme, appearance) ?? theme.colors; + const colors = getThemeColorsForAppearance(theme, appearance) ?? theme.colors; + if (themeId === "t3-code-classic" && appearance === "dark") { + // Classic needs the same pane lift above its black chrome as on web. + const canvas = flattenThemeColor(themeColorWithAlpha(colors.canvas, 0.945), "#ffffff"); + return { ...colors, canvas, chrome: canvas }; + } + return colors; } export function getMobileThemeVariables( @@ -466,24 +498,23 @@ export function getMobileThemeVariables( ): MobileThemeVariables { const colors = getMobileThemeColors(themeId, appearance); const groupedCard = - themeId === DEFAULT_MOBILE_THEME_ID - ? appearance === "light" - ? colors.toolbarControlHover - : colors.sidebarRowActive - : themeId === "t3-chat" + themeId === DEFAULT_MOBILE_THEME_ID && appearance === "dark" + ? colors.surface + : themeId === DEFAULT_MOBILE_THEME_ID || themeId === "t3-code-classic" ? appearance === "light" - ? colors.toolbarControl - : colors.surface - : colors.muted; + ? colors.toolbarControlHover + : colors.sidebarRowActive + : themeId === "t3-chat" + ? appearance === "light" + ? colors.toolbarControl + : colors.surface + : colors.muted; const mobileColors = - themeId === DEFAULT_MOBILE_THEME_ID + themeId === DEFAULT_MOBILE_THEME_ID && appearance === "light" ? { ...colors, messageSurface: flattenThemeColor( - themeColorWithAlpha( - appearance === "dark" ? colors.sidebarRowActive : colors.border, - 0.3, - ), + themeColorWithAlpha(colors.border, 0.3), colors.messageSurface, ), } @@ -494,7 +525,7 @@ export function getMobileThemeVariables( groupedCard, themeId === "t3-chat" && appearance === "dark" ? [colors.messageActionForeground, colors.messageAction] - : themeId !== DEFAULT_MOBILE_THEME_ID && themeId !== "t3-chat" + : themeId !== "t3-chat" ? [colors.messageAction, colors.messageActionForeground] : undefined, ); diff --git a/apps/mobile/src/lib/mobileThemeVariables.ts b/apps/mobile/src/lib/mobileThemeVariables.ts index 48f697d545563..bab4e95f1e935 100644 --- a/apps/mobile/src/lib/mobileThemeVariables.ts +++ b/apps/mobile/src/lib/mobileThemeVariables.ts @@ -60,7 +60,13 @@ function platformThemeVariables( ? defaults[appearance] : getMobileThemeVariables(themeId, appearance); const frame = themeColorWithAlpha( - variables[usesDefaultPalette ? "--color-row-hover" : "--color-drawer"], + variables[ + appearance === "dark" + ? "--color-header" + : usesDefaultPalette + ? "--color-row-hover" + : "--color-drawer" + ], 1, ); if (platform === "ios" && usesDefaultPalette && appearance === "light") { @@ -74,16 +80,8 @@ function platformThemeVariables( } if (platform !== "android") return variables; - // Android paints the drawer as the page inside a lighter frame, so dark cards lift off both. const screen = variables["--color-screen"]; - const groupedCard = - appearance === "dark" - ? themeColorWithContrast( - variables["--color-grouped-card"], - [screen, flattenThemeColor(frame, screen)], - 1.2, - ) - : variables["--color-grouped-card"]; + const groupedCard = variables["--color-grouped-card"]; const readableOnCard = (role: (typeof CARD_TEXT_ROLES)[number]) => [role, themeColorWithContrast(variables[role], groupedCard)] as const; return { diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index ed85709f30059..5d675de93e0af 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -2424,6 +2424,7 @@ export function buildThreadFeed( foldedAnswerRunIdByMessageId.get(item.messageId) === item.runId ) continue; + if (item.type === "user_message" && item.retryOfRunId !== undefined) continue; if ( item.type === "run_interrupt_request" || (item.type === "run_interrupt_result" && diff --git a/apps/mobile/src/lib/useMobileNavigationTheme.ts b/apps/mobile/src/lib/useMobileNavigationTheme.ts index 54412e14cb2a8..23f727fbd1af9 100644 --- a/apps/mobile/src/lib/useMobileNavigationTheme.ts +++ b/apps/mobile/src/lib/useMobileNavigationTheme.ts @@ -15,7 +15,7 @@ export function useMobileNavigationTheme(surface: "screen" | "sidebar" = "screen ...base.colors, primary: variables["--color-primary-text"], background: variables[surface === "sidebar" ? "--color-drawer" : "--color-screen"], - card: variables[surface === "sidebar" ? "--color-drawer" : "--color-sheet-solid"], + card: variables[surface === "sidebar" ? "--color-drawer" : "--color-header"], text: variables[ surface === "sidebar" ? "--color-drawer-foreground" : "--color-header-foreground" ], diff --git a/apps/mobile/src/native/NativeColumnContent.ios.tsx b/apps/mobile/src/native/NativeColumnContent.ios.tsx index 30350b5117154..75f1ab80a00fd 100644 --- a/apps/mobile/src/native/NativeColumnContent.ios.tsx +++ b/apps/mobile/src/native/NativeColumnContent.ios.tsx @@ -9,30 +9,39 @@ import { NativeLayoutObserver } from "./NativeLayoutObserver"; export function NativeColumnContent(props: { readonly children: ReactNode; readonly primary?: boolean; + readonly insetTop?: boolean; readonly insetHorizontally?: boolean; readonly backgroundColor?: ColorValue; }) { const [metrics, setMetrics] = useState(null); return ( - + - {/* Observe the inset body. UIKit can reserve other columns in its safe area. */} - - - - - {props.children} - - + + {/* Descendants measure the inset body so they don't reserve the native bar twice. */} + + + + + {props.children} + + + diff --git a/apps/mobile/src/native/createV5StackNavigator.ios.tsx b/apps/mobile/src/native/createV5StackNavigator.ios.tsx index c4c4f80837bc6..a936ba11326a9 100644 --- a/apps/mobile/src/native/createV5StackNavigator.ios.tsx +++ b/apps/mobile/src/native/createV5StackNavigator.ios.tsx @@ -251,6 +251,7 @@ export function V5CardStackView( } /> T3 Connect authorization complete
-
-
-

${stageBrand}

-
+
+ Code + ${stage === "latest" ? "" : ``}
-
-

Browser authorization complete

+

You're connected

Return to your terminal to finish setting up T3 Connect. You can close this window.

diff --git a/apps/server/src/keybindings.test.ts b/apps/server/src/keybindings.test.ts index 7bff1a6823df1..d7fd343136883 100644 --- a/apps/server/src/keybindings.test.ts +++ b/apps/server/src/keybindings.test.ts @@ -13,6 +13,7 @@ import * as Stream from "effect/Stream"; import * as ServerConfig from "./config.ts"; import * as Keybindings from "./keybindings.ts"; import * as SharedKeybindings from "@t3tools/shared/keybindings"; +import { evaluateWhenNode } from "@t3tools/shared/keybindingMatching"; import { KeybindingsConfigError, MAX_KEYBINDINGS_COUNT } from "@t3tools/contracts"; const KeybindingsConfigJson = Schema.fromJsonString(KeybindingsConfig); @@ -340,7 +341,9 @@ 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 === "terminal.toggle" && entry.key === "mod+j"), + ); assert.isTrue(persisted.some((entry) => entry.command === "script.custom-action.run")); assert.isTrue( @@ -355,6 +358,81 @@ it.layer(NodeServices.layer)("keybindings", (it) => { ); }); + it.effect("replaces retired defaults received from an older server", () => + Effect.sync(() => { + const bindings = SharedKeybindings.mergeWithDefaultKeybindings( + SharedKeybindings.compileResolvedKeybindingsConfig([ + ...SharedKeybindings.RETIRED_DEFAULT_KEYBINDINGS, + { key: "alt+w", command: "rightPanel.close", when: "!terminalFocus" }, + ]), + ); + for (const [key, shiftKey, context, command] of [ + ["d", false, {}, "diff.toggle"], + ["\\", false, {}, "layout.splitRight"], + ["t", false, { rightPanelOpen: true }, "tab.new"], + ["w", false, {}, "tab.close"], + ["l", false, { previewFocus: true }, "preview.focusUrl"], + ["l", true, { assistantSelection: true }, "chat.citeSelection"], + ["l", true, {}, "composer.previousWorktree"], + ] as const) { + assert.equal( + bindings.findLast( + (binding) => + SharedKeybindings.matchesKeybindingShortcut( + { key, shiftKey, metaKey: true, ctrlKey: false, altKey: false }, + binding.shortcut, + "MacIntel", + ) && + (!binding.whenAst || evaluateWhenNode(binding.whenAst, context)), + )?.command, + command, + ); + } + assert.deepInclude( + bindings, + SharedKeybindings.compileResolvedKeybindingRule({ + key: "alt+w", + command: "rightPanel.close", + when: "!terminalFocus", + }), + ); + }), + ); + + it.effect("drops a retired default so its replacement takes the key", () => + Effect.gen(function* () { + const { keybindingsConfigPath } = yield* ServerConfig.ServerConfig; + yield* writeKeybindingsConfig(keybindingsConfigPath, [ + { key: "mod+d", command: "layout.splitRight", when: "!terminalFocus" }, + { key: "mod+shift+d", command: "layout.splitRight" }, + { key: "mod+w", command: "rightPanel.close", when: "(!terminalFocus)" }, + ]); + + yield* Effect.gen(function* () { + const keybindings = yield* Keybindings.Keybindings; + yield* keybindings.syncDefaultKeybindingsOnStartup; + }); + + const persisted = yield* readKeybindingsConfig(keybindingsConfigPath); + assert.isFalse( + persisted.some((entry) => entry.command === "layout.splitRight" && entry.key === "mod+d"), + ); + assert.isTrue( + persisted.some( + (entry) => entry.command === "layout.splitRight" && entry.key === "mod+shift+d", + ), + ); + assert.isTrue( + persisted.some((entry) => entry.command === "diff.toggle" && entry.key === "mod+d"), + ); + assert.deepInclude(persisted, { + key: "mod+w", + command: "rightPanel.close", + when: "(!terminalFocus)", + }); + }).pipe(Effect.provide(layerKeybindings())), + ); + it.effect("appends additional custom keybindings for the same command", () => Effect.gen(function* () { const { keybindingsConfigPath } = yield* ServerConfig.ServerConfig; diff --git a/apps/server/src/keybindings.ts b/apps/server/src/keybindings.ts index 49a7d85d4fa36..ce2bab8983b50 100644 --- a/apps/server/src/keybindings.ts +++ b/apps/server/src/keybindings.ts @@ -36,6 +36,7 @@ import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite"; import { fromJsonStringPretty, fromLenientJson } from "@t3tools/shared/schemaJson"; import { DEFAULT_KEYBINDINGS, + RETIRED_DEFAULT_KEYBINDINGS, mergeWithDefaultKeybindings, compileResolvedKeybindingRule, compileResolvedKeybindingsConfig, @@ -499,7 +500,18 @@ const make = Effect.gen(function* () { yield* Cache.invalidate(resolvedConfigCache, resolvedConfigCacheKey); return; } - const customConfig = runtimeConfig.keybindings; + const persistedConfig = runtimeConfig.keybindings; + // A retired default left in the file would shadow its replacement. + const customConfig = persistedConfig.filter( + (entry) => + !RETIRED_DEFAULT_KEYBINDINGS.some( + (retired) => + entry.command === retired.command && + entry.key === retired.key && + entry.when === retired.when, + ), + ); + const retiredRemoved = customConfig.length !== persistedConfig.length; const existingCommands = new Set(customConfig.map((entry) => entry.command)); const missingDefaults: KeybindingRule[] = []; const shortcutConflictWarnings: Array<{ @@ -569,7 +581,8 @@ const make = Effect.gen(function* () { commands: skippedDefaults.map((rule) => rule.command), }); } - if (defaultsToAppend.length > 0) { + // Dropping a retired default rewrites the file even with nothing to append. + if (defaultsToAppend.length > 0 || retiredRemoved) { yield* writeConfigAtomically([...defaultsToAppend, ...customConfig]); } const settledLateDefaults = pendingLateDefaults.filter( diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 0d1366dbd858e..c69f2dd03b34d 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -11,6 +11,7 @@ import type { ElicitationRequest, ElicitationResult, Query as ClaudeQuery, + SDKControlGetContextUsageResponse, SDKMessage, SDKResultMessage, SDKUserMessage, @@ -35,6 +36,7 @@ import { ChatImageAttachment, CheckpointId, ClaudeSettings, + type ContextBreakdown, EnvironmentId, MessageId, type ModelSelection, @@ -131,6 +133,13 @@ import { projectionFor, } from "../testkit/fixtures/shared.ts"; +const contextUsageUnsupported = Effect.fail( + new ClaudeAdapterV2.ClaudeAgentSdkQueryRunnerError({ + method: "getContextUsage", + cause: new Error("Unsupported control request subtype"), + }), +); + vi.mock("@anthropic-ai/claude-agent-sdk", async (importOriginal) => ({ ...(await importOriginal()), resolveSettings: vi.fn(), @@ -1135,6 +1144,7 @@ describe("ClaudeAdapterV2 Auto-accept edits", () => { readMcpResource: () => Effect.die("unexpected MCP resource read"), callMcpTool: () => Effect.die("unexpected MCP tool call"), listPermissionRules: Effect.die("unexpected permission rules read"), + getContextUsage: contextUsageUnsupported, stopTask: () => Effect.void, interrupt: Effect.void, close: Effect.void, @@ -1258,6 +1268,7 @@ describe("ClaudeAdapterV2 append-system-prompt-file launch argument", () => { readMcpResource: () => Effect.die("unexpected MCP resource read"), callMcpTool: () => Effect.die("unexpected MCP tool call"), listPermissionRules: Effect.die("unexpected permission rules read"), + getContextUsage: contextUsageUnsupported, stopTask: () => Effect.void, interrupt: Effect.void, close: Effect.void, @@ -1547,6 +1558,12 @@ describe("ClaudeAdapterV2 provider installation gate", () => { readMcpResource: () => Effect.die("unexpected MCP resource read"), callMcpTool: () => Effect.die("unexpected MCP tool call"), listPermissionRules: Effect.die("unexpected permission rules read"), + getContextUsage: Effect.fail( + new ClaudeAdapterV2.ClaudeAgentSdkQueryRunnerError({ + method: "getContextUsage", + cause: new Error("Unsupported control request subtype"), + }), + ), stopTask: () => Effect.void, interrupt: Effect.void, close: Effect.void, @@ -1735,6 +1752,7 @@ const captureSdkExecutablePaths = Effect.fn("captureSdkExecutablePaths")(functio readMcpResource: () => Effect.die("unexpected MCP resource read"), callMcpTool: () => Effect.die("unexpected MCP tool call"), listPermissionRules: Effect.die("unexpected permission rules read"), + getContextUsage: contextUsageUnsupported, stopTask: () => Effect.void, interrupt: Effect.void, close: Effect.void, @@ -1929,6 +1947,7 @@ describe("ClaudeAdapterV2 resume compaction", () => { readMcpResource: () => Effect.die("unexpected MCP resource read"), callMcpTool: () => Effect.die("unexpected MCP tool call"), listPermissionRules: Effect.die("unexpected permission rules read"), + getContextUsage: contextUsageUnsupported, stopTask: () => Effect.void, interrupt: Effect.void, close: Effect.void, @@ -2314,6 +2333,7 @@ describe("ClaudeAdapterV2 attachments", () => { readMcpResource: () => Effect.die("unexpected MCP resource read"), callMcpTool: () => Effect.die("unexpected MCP tool call"), listPermissionRules: Effect.die("unexpected permission rules read"), + getContextUsage: contextUsageUnsupported, stopTask: () => Effect.void, interrupt: Effect.void, close: Queue.shutdown(messages), @@ -2856,6 +2876,7 @@ describe("ClaudeAdapterV2 native fork", () => { readMcpResource: () => Effect.die("unexpected MCP resource read"), callMcpTool: () => Effect.die("unexpected MCP tool call"), listPermissionRules: Effect.die("unexpected permission rules read"), + getContextUsage: contextUsageUnsupported, stopTask: () => Effect.void, interrupt: Effect.void, close: Effect.void, @@ -3205,6 +3226,7 @@ describe("ClaudeAdapterV2 native session identity", () => { readMcpResource: () => Effect.die("unexpected MCP resource read"), callMcpTool: () => Effect.die("unexpected MCP tool call"), listPermissionRules: Effect.die("unexpected permission rules read"), + getContextUsage: contextUsageUnsupported, stopTask: () => Effect.void, interrupt: Effect.void, close: Effect.void, @@ -3549,6 +3571,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { "mcpServerStatus" | "readMcpResource" | "callMcpTool" > & Partial>; + readonly getContextUsage?: ClaudeAdapterV2.ClaudeAgentSdkQuerySession["getContextUsage"]; readonly environment?: NodeJS.ProcessEnv; readonly subagentLaunchToolUseId?: string; readonly subagentModel?: string; @@ -3687,6 +3710,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { readMcpResource: () => Effect.die("unexpected MCP resource read"), callMcpTool: () => Effect.die("unexpected MCP tool call"), listPermissionRules: Effect.die("unexpected permission rules read"), + getContextUsage: contextUsageUnsupported, stopTask: options?.stopTask ?? (() => Effect.void), interrupt: Effect.void, close: Queue.shutdown(processMessages), @@ -3730,6 +3754,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { callMcpTool: options?.mcp?.callMcpTool ?? (() => Effect.die("unexpected MCP tool call")), listPermissionRules: options?.mcp?.listPermissionRules ?? Effect.succeed([]), + getContextUsage: options?.getContextUsage ?? contextUsageUnsupported, stopTask: options?.stopTask ?? (() => Effect.void), interrupt: options?.interrupt ?? Effect.void, close: options?.close?.(sdkMessages) ?? Effect.void, @@ -9781,75 +9806,108 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); - it.effect("merges cumulative stream usage into the message_start counts", () => + const claudeContextUsage = { + categories: [ + { id: "system_prompt", name: "System prompt", tokens: 3_000, color: "", kind: "used" }, + { id: "system_tools", name: "System tools", tokens: 9_000, color: "", kind: "used" }, + { id: "mcp_tools", name: "MCP tools", tokens: 5_000, color: "", kind: "used" }, + { id: "mcp_tools_deferred", name: "MCP tools", tokens: 20_000, color: "", kind: "deferred" }, + { id: "mcp_instructions", name: "MCP instructions", tokens: 100, color: "", kind: "used" }, + { id: "memory_files", name: "Memory files", tokens: 1_200, color: "", kind: "used" }, + { id: "messages", name: "Messages", tokens: 800, color: "", kind: "used" }, + { id: "autocompact_buffer", name: "Buffer", tokens: 33_000, color: "", kind: "buffer" }, + { id: "free_space", name: "Free space", tokens: 900_000, color: "", kind: "free" }, + ], + totalTokens: 19_100, + maxTokens: 1_000_000, + rawMaxTokens: 1_000_000, + percentage: 2, + gridRows: [], + model: "claude-haiku-5-5", + memoryFiles: [], + mcpTools: [ + { name: "thread_read", serverName: "t3-code", tokens: 1_500, isLoaded: true }, + { name: "request_secret", serverName: "t3-code", tokens: 500 }, + { name: "thread_send", serverName: "t3-code", tokens: 900, isLoaded: false }, + { name: "search", serverName: "docs", tokens: 3_000, isLoaded: true }, + ], + agents: [], + isAutoCompactEnabled: true, + apiUsage: null, + } satisfies SDKControlGetContextUsageResponse; + + it.effect.each([ + { + name: "attaches Claude's in-window categories at turn end, with T3 tools split out", + getContextUsage: Effect.succeed(claudeContextUsage), + timesOut: false, + breakdowns: [ + [ + { kind: "system_prompt", tokens: 3_000 }, + { kind: "system_tools", tokens: 9_000 }, + { kind: "mcp_tools", tokens: 3_000 }, + { kind: "other", tokens: 100 }, + { kind: "memory_files", tokens: 1_200 }, + { kind: "messages", tokens: 800 }, + { kind: "t3_tools", tokens: 2_000 }, + ], + ] satisfies ReadonlyArray, + }, + { + name: "keeps plain usage when Claude cannot report categories", + getContextUsage: contextUsageUnsupported, + timesOut: false, + breakdowns: [] satisfies ReadonlyArray, + }, + { + name: "completes the turn when Claude's context summary never responds", + getContextUsage: Effect.never, + timesOut: true, + breakdowns: [] satisfies ReadonlyArray, + }, + ])("$name", ({ getContextUsage, timesOut, breakdowns }) => Effect.gen(function* () { + const contextRequested = yield* Deferred.make(); const harness = yield* makeWakeHarnessWithOptions({ - settings: { ...DEFAULT_CLAUDE_SETTINGS, autoCompactWindow: "150000" }, + getContextUsage: Deferred.succeed(contextRequested, undefined).pipe( + Effect.andThen(getContextUsage), + ), }); yield* harness.runtime.startTurn( makeClaudeTestTurnInput({ threadId: harness.threadId, providerThread: harness.providerThread, now: yield* DateTime.now, - attemptId: RunAttemptId.make("attempt-merged-stream-usage"), + attemptId: RunAttemptId.make("attempt-context-breakdown"), text: "Continue.", attachments: [], }), ); - const startUsage = { - input_tokens: 10, - output_tokens: 1, - cache_creation_input_tokens: 200, - cache_read_input_tokens: 1_000, - }; - const frames = [ - usageStreamFrame({ - type: "message_start", - message: { id: "msg_native", usage: startUsage }, - }), - usageSnapshotFrame("msg_native", "00000000-0000-4000-8000-000000000680", startUsage), - usageStreamFrame({ - type: "message_delta", - delta: { stop_reason: null, stop_sequence: null }, - usage: { input_tokens: null, output_tokens: 20 }, - }), - usageStreamFrame({ - type: "message_delta", - delta: { stop_reason: "end_turn", stop_sequence: null }, - usage: { - input_tokens: 25, - output_tokens: 50, - cache_creation_input_tokens: 0, - cache_read_input_tokens: 1_000, - }, - }), - usageSnapshotFrame("msg_native", "00000000-0000-4000-8000-000000000681", startUsage), - claudeSdkFrame({ - type: "system", - subtype: "compact_boundary", - compact_metadata: { trigger: "auto", pre_tokens: 1_075, post_tokens: 400 }, - uuid: "00000000-0000-4000-8000-000000000682", - session_id: WAKE_NATIVE_SESSION, - }), - usageSnapshotFrame("msg_native", "00000000-0000-4000-8000-000000000684", startUsage), - usageStreamFrame({ - type: "message_delta", - delta: { stop_reason: "end_turn", stop_sequence: null }, - usage: { input_tokens: null, output_tokens: 60 }, - }), - makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000683", result: "Done." }), - ]; - for (const frame of frames) yield* Queue.offer(harness.sdkMessages, frame); + const usage = { ...zeroUsage, input_tokens: 19_000, output_tokens: 100 }; + yield* Queue.offer( + harness.sdkMessages, + usageStreamFrame({ type: "message_start", message: { id: "msg_breakdown", usage } }), + ); + yield* Queue.offer( + harness.sdkMessages, + makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000690", result: "OK" }), + ); + yield* Deferred.await(contextRequested); + if (timesOut) yield* TestClock.adjust("2 seconds"); const terminal = yield* Queue.take(harness.terminalReceipts); assert.equal(terminal.status, "completed"); - assert.deepEqual(reportedContextTokens(harness.events), [1_211, 1_230, 1_075, 400]); + const usages = harness.events.flatMap((event) => + event.type === "provider_turn.updated" && event.providerTurn.tokenUsage + ? [event.providerTurn.tokenUsage] + : [], + ); assert.deepEqual( - harness.events.flatMap((event) => - event.type === "provider_turn.updated" && event.providerTurn.tokenUsage - ? [event.providerTurn.tokenUsage.autoCompactThreshold] - : [], - ), - [117_000, 117_000, 117_000, 117_000], + usages.map((tokenUsage) => tokenUsage.usedTokens), + [19_100, ...breakdowns.map(() => 19_100)], + ); + assert.deepEqual( + usages.flatMap((tokenUsage) => (tokenUsage.breakdown ? [tokenUsage.breakdown] : [])), + breakdowns, ); }).pipe( Effect.provide( @@ -9863,6 +9921,107 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); + it.effect.each([400, undefined])( + "merges stream usage and clears categories after compaction to %s", + (postTokens) => + Effect.gen(function* () { + const harness = yield* makeWakeHarnessWithOptions({ + settings: { ...DEFAULT_CLAUDE_SETTINGS, autoCompactWindow: "150000" }, + }); + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("attempt-merged-stream-usage"), + text: "Continue.", + attachments: [], + }), + ); + const startUsage = { + input_tokens: 10, + output_tokens: 1, + cache_creation_input_tokens: 200, + cache_read_input_tokens: 1_000, + }; + const frames = [ + usageStreamFrame({ + type: "message_start", + message: { id: "msg_native", usage: startUsage }, + }), + usageSnapshotFrame("msg_native", "00000000-0000-4000-8000-000000000680", startUsage), + usageStreamFrame({ + type: "message_delta", + delta: { stop_reason: null, stop_sequence: null }, + usage: { input_tokens: null, output_tokens: 20 }, + }), + usageStreamFrame({ + type: "message_delta", + delta: { stop_reason: "end_turn", stop_sequence: null }, + usage: { + input_tokens: 25, + output_tokens: 50, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 1_000, + }, + }), + usageSnapshotFrame("msg_native", "00000000-0000-4000-8000-000000000681", startUsage), + claudeSdkFrame({ + type: "system", + subtype: "compact_boundary", + compact_metadata: { + trigger: "auto", + pre_tokens: 1_075, + ...(postTokens === undefined ? {} : { post_tokens: postTokens }), + }, + uuid: "00000000-0000-4000-8000-000000000682", + session_id: WAKE_NATIVE_SESSION, + }), + usageSnapshotFrame("msg_native", "00000000-0000-4000-8000-000000000684", startUsage), + usageStreamFrame({ + type: "message_delta", + delta: { stop_reason: "end_turn", stop_sequence: null }, + usage: { input_tokens: null, output_tokens: 60 }, + }), + makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000683", result: "Done." }), + ]; + for (const frame of frames) yield* Queue.offer(harness.sdkMessages, frame); + const terminal = yield* Queue.take(harness.terminalReceipts); + assert.equal(terminal.status, "completed"); + assert.deepEqual(reportedContextTokens(harness.events), [ + 1_211, + 1_230, + 1_075, + postTokens ?? 1_075, + ]); + assert.deepEqual( + harness.events.flatMap((event) => + event.type === "provider_turn.updated" && event.providerTurn.tokenUsage + ? [event.providerTurn.tokenUsage.breakdown] + : [], + ), + [undefined, undefined, undefined, []], + ); + assert.deepEqual( + harness.events.flatMap((event) => + event.type === "provider_turn.updated" && event.providerTurn.tokenUsage + ? [event.providerTurn.tokenUsage.autoCompactThreshold] + : [], + ), + [117_000, 117_000, 117_000, 117_000], + ); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), + ), + ), + ); + it.effect.each([ { autoCompactWindow: "", model: "claude-sonnet-4-6", threshold: undefined }, { autoCompactWindow: "150000", model: "claude-sonnet-4-6", threshold: 117_000 }, @@ -13706,6 +13865,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { readMcpResource: () => Effect.die("unexpected MCP resource read"), callMcpTool: () => Effect.die("unexpected MCP tool call"), listPermissionRules: Effect.die("unexpected permission rules read"), + getContextUsage: contextUsageUnsupported, stopTask: () => Effect.void, interrupt: Effect.void, close: isFirstProcess @@ -13908,6 +14068,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { readMcpResource: () => Effect.die("unexpected MCP resource read"), callMcpTool: () => Effect.die("unexpected MCP tool call"), listPermissionRules: Effect.die("unexpected permission rules read"), + getContextUsage: contextUsageUnsupported, stopTask: () => Effect.void, interrupt: Effect.void, close: Queue.shutdown(sdkMessages), @@ -14076,6 +14237,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { readMcpResource: () => Effect.die("unexpected MCP resource read"), callMcpTool: () => Effect.die("unexpected MCP tool call"), listPermissionRules: Effect.die("unexpected permission rules read"), + getContextUsage: contextUsageUnsupported, stopTask: () => Effect.void, interrupt: Effect.void, close: Queue.shutdown(queue), @@ -17342,6 +17504,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { readMcpResource: () => Effect.die("unexpected MCP resource read"), callMcpTool: () => Effect.die("unexpected MCP tool call"), listPermissionRules: Effect.die("unexpected permission rules read"), + getContextUsage: contextUsageUnsupported, interrupt: Effect.void, close: closeSignal === undefined @@ -20600,6 +20763,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { readMcpResource: () => Effect.die("unexpected MCP resource read"), callMcpTool: () => Effect.die("unexpected MCP tool call"), listPermissionRules: Effect.die("unexpected permission rules read"), + getContextUsage: contextUsageUnsupported, stopTask: () => Effect.void, interrupt: Effect.void, close: Queue.shutdown(sdkMessages), @@ -20778,6 +20942,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { readMcpResource: () => Effect.die("unexpected MCP resource read"), callMcpTool: () => Effect.die("unexpected MCP tool call"), listPermissionRules: Effect.die("unexpected permission rules read"), + getContextUsage: contextUsageUnsupported, stopTask: () => Effect.void, interrupt: Effect.void, close: Queue.shutdown(sdkMessages), @@ -20956,6 +21121,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { readMcpResource: () => Effect.die("unexpected MCP resource read"), callMcpTool: () => Effect.die("unexpected MCP tool call"), listPermissionRules: Effect.die("unexpected permission rules read"), + getContextUsage: contextUsageUnsupported, stopTask: () => Effect.void, interrupt: Effect.void, close: Queue.shutdown(sdkMessages), @@ -21155,6 +21321,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { readMcpResource: () => Effect.die("unexpected MCP resource read"), callMcpTool: () => Effect.die("unexpected MCP tool call"), listPermissionRules: Effect.die("unexpected permission rules read"), + getContextUsage: contextUsageUnsupported, stopTask: () => Effect.void, interrupt: Effect.void, close: Effect.sync(() => { @@ -21302,6 +21469,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { readMcpResource: () => Effect.die("unexpected MCP resource read"), callMcpTool: () => Effect.die("unexpected MCP tool call"), listPermissionRules: Effect.die("unexpected permission rules read"), + getContextUsage: contextUsageUnsupported, stopTask: () => Effect.void, interrupt: Effect.void, close: Queue.shutdown(sdkMessages), @@ -21424,6 +21592,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { readMcpResource: () => Effect.die("unexpected MCP resource read"), callMcpTool: () => Effect.die("unexpected MCP tool call"), listPermissionRules: Effect.die("unexpected permission rules read"), + getContextUsage: contextUsageUnsupported, stopTask: () => Effect.void, interrupt: Effect.void, close: Queue.shutdown(sdkMessages), diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.testkit.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.testkit.ts index feac31a89af97..e3ce83916594c 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.testkit.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.testkit.ts @@ -853,6 +853,13 @@ function makeReplayQueryRunner( readMcpResource: () => Effect.die("unexpected MCP resource read in replay"), callMcpTool: () => Effect.die("unexpected MCP tool call in replay"), listPermissionRules: Effect.die("unexpected permission rules read in replay"), + // Recorded CLIs answer no context-usage request, so replays keep plain usage. + getContextUsage: Effect.fail( + new ClaudeAdapterV2.ClaudeAgentSdkQueryRunnerError({ + method: "getContextUsage", + cause: new Error("Unsupported control request subtype"), + }), + ), interrupt: replayEffect(() => { assertNextOutboundFrame({ type: "query.interrupt" }); }), diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 0ac551140704f..d8a6311125469 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -59,6 +59,7 @@ import { type SpawnOptions as ClaudeSpawnOptions, type SpawnedProcess as ClaudeSpawnedProcess, type SDKAssistantMessage, + type SDKControlGetContextUsageResponse, type SDKAPIRetryMessage, type SDKMessage, type SDKRateLimitInfo, @@ -94,6 +95,8 @@ import { } from "@t3tools/shared/claudeCompaction"; import { type ChatAttachment, + type ContextBreakdown, + type ContextBreakdownKind, PROVIDER_SEND_TURN_MAX_FILE_BYTES, ClaudeSettings, TrimmedNonEmptyString, @@ -334,6 +337,51 @@ export function claudeProviderTurnTokenUsage( type ClaudeMessageUsage = Parameters[0]; +const CLAUDE_CONTEXT_CATEGORY_KINDS: Partial< + Record< + NonNullable, + ContextBreakdownKind + > +> = { + system_prompt: "system_prompt", + system_tools: "system_tools", + mcp_tools: "mcp_tools", + memory_files: "memory_files", + skills: "skills", + custom_agents: "custom_agents", + messages: "messages", +}; + +/** The in-window rows of Claude's `/context`; a CLI that predates category ids reports none. */ +function claudeContextBreakdown( + usage: SDKControlGetContextUsageResponse, +): ContextBreakdown | undefined { + if (!usage.categories.some((category) => category.id !== undefined)) return undefined; + const tokens = new Map(); + const add = (kind: ContextBreakdownKind, count: number) => + tokens.set(kind, (tokens.get(kind) ?? 0) + count); + for (const category of usage.categories) { + if (category.kind !== "used") continue; + const kind = category.id === undefined ? undefined : CLAUDE_CONTEXT_CATEGORY_KINDS[category.id]; + add(kind ?? "other", Math.max(0, Math.round(category.tokens))); + } + const t3Tools = Math.min( + tokens.get("mcp_tools") ?? 0, + Math.round( + usage.mcpTools + .filter((tool) => tool.serverName === "t3-code" && tool.isLoaded !== false) + .reduce((sum, tool) => sum + tool.tokens, 0), + ), + ); + if (t3Tools > 0) { + add("mcp_tools", -t3Tools); + add("t3_tools", t3Tools); + } + return Array.from(tokens, ([kind, count]) => ({ kind, tokens: count })).filter( + (category) => category.tokens > 0, + ); +} + // message_delta usage is per-message cumulative; null or absent fields retain message_start values and zeros replace them. function mergeClaudeStreamUsage( current: ClaudeMessageUsage, @@ -680,6 +728,10 @@ export interface ClaudeAgentSdkQuerySession { ReadonlyArray, ClaudeAgentSdkQueryRunnerError >; + readonly getContextUsage: Effect.Effect< + SDKControlGetContextUsageResponse, + ClaudeAgentSdkQueryRunnerError + >; readonly readMcpResource: (input: { readonly server: string; readonly uri: string; @@ -1832,6 +1884,10 @@ export const layerQueryRunner: Layer.Layer< try: () => queryRuntime.mcpServerStatus(), catch: (cause) => queryRunnerError(cause, "mcpServerStatus"), }), + getContextUsage: Effect.tryPromise({ + try: () => queryRuntime.getContextUsage({ detail: "summary" }), + catch: (cause) => queryRunnerError(cause, "getContextUsage"), + }), readMcpResource: ({ server, uri }) => Effect.tryPromise({ try: () => queryRuntime.readMcpResource(server, uri), @@ -6240,6 +6296,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( context: ActiveClaudeTurnContext, usage: ClaudeMessageUsage | number, requestStarted = false, + breakdown?: ContextBreakdown, ) { const now = yield* DateTime.now; // Only message_start marks the request start; an assistant snapshot @@ -6283,7 +6340,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( status: "running", startedAt: context.startedAt, completedAt: null, - tokenUsage, + tokenUsage: breakdown === undefined ? tokenUsage : { ...tokenUsage, breakdown }, ...(context.executedModel === undefined ? {} : { model: context.executedModel }), }, }); @@ -10821,6 +10878,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( budget.compacting = null; yield* persistAttachmentBudget(context.nativeThreadId); } + const previousUsage = context.streamUsage?.usage; if (context.streamUsage !== null) context.streamUsage.usage = null; const postTokens = message.compact_metadata.post_tokens; const afterTokenCount = @@ -10828,7 +10886,12 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( ? Math.round(postTokens) : undefined; context.compactionTokens = afterTokenCount; - if (afterTokenCount !== undefined) yield* emitContextUsage(context, afterTokenCount); + // An empty split invalidates the pre-compaction categories until the next summary. + const compactedUsage = + afterTokenCount ?? previousUsage ?? message.compact_metadata.pre_tokens; + if (compactedUsage != null) { + yield* emitContextUsage(context, compactedUsage, false, []); + } yield* emitProviderEvent({ type: "turn_item.updated", driver: CLAUDE_PROVIDER, @@ -11617,8 +11680,19 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( if (message.type === "result") { const contextUsage = context.streamUsage?.usage ?? context.compactionTokens; - if (resultsReportingNewContextWindow.has(message) && contextUsage !== undefined) { - yield* emitContextUsage(context, contextUsage); + if (contextUsage !== undefined) { + // "summary" answers from Claude's local state without a token-count API call. + const breakdown = yield* input.query.getContextUsage.pipe( + Effect.map(claudeContextBreakdown), + Effect.timeout("2 seconds"), + Effect.tapError((cause) => + Effect.logDebug("orchestration-v2.claude-context-usage-unavailable", { cause }), + ), + Effect.orElseSucceed(() => undefined), + ); + if (breakdown !== undefined || resultsReportingNewContextWindow.has(message)) { + yield* emitContextUsage(context, contextUsage, false, breakdown); + } } const completedAt = yield* DateTime.now; const interrupted = (yield* Ref.get(interruptedTurns)).has(context.providerTurnId); diff --git a/apps/server/src/orchestration-v2/ClaudeAutomaticDelivery.integration.test.ts b/apps/server/src/orchestration-v2/ClaudeAutomaticDelivery.integration.test.ts index 0fead24d97792..31f97f4bbb53b 100644 --- a/apps/server/src/orchestration-v2/ClaudeAutomaticDelivery.integration.test.ts +++ b/apps/server/src/orchestration-v2/ClaudeAutomaticDelivery.integration.test.ts @@ -175,6 +175,12 @@ it.effect.each(deliveries)( readMcpResource: () => Effect.die("unexpected MCP resource read"), callMcpTool: () => Effect.die("unexpected MCP tool call"), listPermissionRules: Effect.die("unexpected permission rules read"), + getContextUsage: Effect.fail( + new ClaudeAdapterV2.ClaudeAgentSdkQueryRunnerError({ + method: "getContextUsage", + cause: new Error("Unsupported control request subtype"), + }), + ), stopTask: () => Effect.void, interrupt: Effect.die("automatic delivery must never interrupt"), close: Effect.void, @@ -525,6 +531,12 @@ it.effect("applies an effort change to the running Claude query without waiting readMcpResource: () => Effect.die("unexpected MCP resource read"), callMcpTool: () => Effect.die("unexpected MCP tool call"), listPermissionRules: Effect.die("unexpected permission rules read"), + getContextUsage: Effect.fail( + new ClaudeAdapterV2.ClaudeAgentSdkQueryRunnerError({ + method: "getContextUsage", + cause: new Error("Unsupported control request subtype"), + }), + ), stopTask: () => Effect.void, interrupt: Effect.die("an effort change must not interrupt the turn"), close: Effect.void, @@ -720,6 +732,12 @@ it.effect("offers a continuation for a later wake after a queued continuation is readMcpResource: () => Effect.die("unexpected MCP resource read"), callMcpTool: () => Effect.die("unexpected MCP tool call"), listPermissionRules: Effect.die("unexpected permission rules read"), + getContextUsage: Effect.fail( + new ClaudeAdapterV2.ClaudeAgentSdkQueryRunnerError({ + method: "getContextUsage", + cause: new Error("Unsupported control request subtype"), + }), + ), stopTask: () => Effect.void, interrupt: Effect.void, close: Effect.void, diff --git a/apps/server/src/orchestration-v2/EventSink.ts b/apps/server/src/orchestration-v2/EventSink.ts index 5ea6604fd1f12..d69f93218c732 100644 --- a/apps/server/src/orchestration-v2/EventSink.ts +++ b/apps/server/src/orchestration-v2/EventSink.ts @@ -404,7 +404,11 @@ const layerBase: Layer.Layer< event.payload.runId === null ? undefined : runOrdinals.get(event.payload.runId), ) .pipe(Effect.map((payload) => ({ ...event, payload }))) - : Effect.succeed(event), + : event.type === "provider-turn.updated" && event.driver === "claudeAgent" + ? projectionStore + .carryProviderTurnContextUsage(event.threadId, event.payload) + .pipe(Effect.map((payload) => ({ ...event, payload }))) + : Effect.succeed(event), { concurrency: 1 }, ); }; diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 53f726bf4628d..07a6ab7aa2839 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -503,6 +503,10 @@ export interface OrchestratorV2Shape { OrchestratorV2Error >; readonly countThreads: () => Effect.Effect; + readonly countArchivedThreadMessages: () => Effect.Effect< + ReadonlyMap, + OrchestratorV2Error + >; readonly listActiveShellIndex: () => Effect.Effect< ReadonlyArray, OrchestratorV2Error @@ -3013,6 +3017,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ...(queuedMessage.idleCompaction === undefined ? {} : { idleCompaction: queuedMessage.idleCompaction }), + ...(queuedMessage.retryOfRunId === undefined + ? {} + : { retryOfRunId: queuedMessage.retryOfRunId }), }), ...(freshContext ? { freshContext: true } : {}), providerThreadId: queuedProviderThread.id, @@ -5999,6 +6006,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio readonly senderThreadId?: OrchestrationV2ConversationMessage["senderThreadId"]; readonly linkedSender?: OrchestrationV2ConversationMessage["linkedSender"]; readonly idleCompaction?: OrchestrationV2ConversationMessage["idleCompaction"]; + readonly retryOfRunId?: OrchestrationV2ConversationMessage["retryOfRunId"]; readonly delegatedCompletion?: OrchestrationV2ConversationMessage["delegatedCompletion"]; readonly notification?: OrchestrationV2ConversationMessage["notification"]; readonly forceRestart: boolean; @@ -6144,6 +6152,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ...(input.senderThreadId === undefined ? {} : { senderThreadId: input.senderThreadId }), ...(input.linkedSender === undefined ? {} : { linkedSender: input.linkedSender }), ...(input.idleCompaction === undefined ? {} : { idleCompaction: input.idleCompaction }), + ...(input.retryOfRunId === undefined ? {} : { retryOfRunId: input.retryOfRunId }), id: input.messageId, threadId: input.command.threadId, runId: messageInput.runId, @@ -6165,6 +6174,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ...(input.senderThreadId === undefined ? {} : { senderThreadId: input.senderThreadId }), ...(input.linkedSender === undefined ? {} : { linkedSender: input.linkedSender }), ...(input.idleCompaction === undefined ? {} : { idleCompaction: input.idleCompaction }), + ...(input.retryOfRunId === undefined ? {} : { retryOfRunId: input.retryOfRunId }), id: idAllocator.derive.userTurnItem({ messageId: input.messageId }), threadId: input.command.threadId, runId: messageInput.runId, @@ -7410,6 +7420,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ...(command.senderThreadId === undefined ? {} : { senderThreadId: command.senderThreadId }), ...(command.linkedSender === undefined ? {} : { linkedSender: command.linkedSender }), ...(command.idleCompaction === undefined ? {} : { idleCompaction: command.idleCompaction }), + ...(command.retryOfRunId === undefined ? {} : { retryOfRunId: command.retryOfRunId }), forceRestart: dispatchMode.type === "restart_active", }); return; @@ -7705,6 +7716,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ...(command.senderThreadId === undefined ? {} : { senderThreadId: command.senderThreadId }), ...(command.linkedSender === undefined ? {} : { linkedSender: command.linkedSender }), ...(command.idleCompaction === undefined ? {} : { idleCompaction: command.idleCompaction }), + ...(command.retryOfRunId === undefined ? {} : { retryOfRunId: command.retryOfRunId }), ...(command.scheduledTaskId !== undefined && command.modelSelection === undefined ? { followsThreadSelection: true as const } : {}), @@ -8123,6 +8135,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ...(command.senderThreadId === undefined ? {} : { senderThreadId: command.senderThreadId }), ...(command.linkedSender === undefined ? {} : { linkedSender: command.linkedSender }), ...(command.idleCompaction === undefined ? {} : { idleCompaction: command.idleCompaction }), + ...(command.retryOfRunId === undefined ? {} : { retryOfRunId: command.retryOfRunId }), ...(command.scheduledTaskId !== undefined && command.modelSelection === undefined ? { followsThreadSelection: true as const } : {}), @@ -8149,6 +8162,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ...(command.senderThreadId === undefined ? {} : { senderThreadId: command.senderThreadId }), ...(command.linkedSender === undefined ? {} : { linkedSender: command.linkedSender }), ...(command.idleCompaction === undefined ? {} : { idleCompaction: command.idleCompaction }), + ...(command.retryOfRunId === undefined ? {} : { retryOfRunId: command.retryOfRunId }), id: idAllocator.derive.userTurnItem({ messageId: command.messageId }), threadId: command.threadId, runId, @@ -8919,6 +8933,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ...(command.senderThreadId === undefined ? {} : { senderThreadId: command.senderThreadId }), ...(command.linkedSender === undefined ? {} : { linkedSender: command.linkedSender }), ...(command.idleCompaction === undefined ? {} : { idleCompaction: command.idleCompaction }), + ...(command.retryOfRunId === undefined ? {} : { retryOfRunId: command.retryOfRunId }), id: command.messageId, threadId: command.threadId, runId, @@ -8942,6 +8957,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ...(command.senderThreadId === undefined ? {} : { senderThreadId: command.senderThreadId }), ...(command.linkedSender === undefined ? {} : { linkedSender: command.linkedSender }), ...(command.idleCompaction === undefined ? {} : { idleCompaction: command.idleCompaction }), + ...(command.retryOfRunId === undefined ? {} : { retryOfRunId: command.retryOfRunId }), id: idAllocator.derive.userTurnItem({ messageId: command.messageId }), threadId: command.threadId, runId, @@ -11486,6 +11502,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ...(queuedMessage.idleCompaction === undefined ? {} : { idleCompaction: queuedMessage.idleCompaction }), + ...(queuedMessage.retryOfRunId === undefined + ? {} + : { retryOfRunId: queuedMessage.retryOfRunId }), forceRestart: false, }); }); @@ -16312,6 +16331,13 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio countThreads: Effect.fn("OrchestratorV2.countThreads")(function () { return projectionStore.countThreads().pipe(Effect.mapError(shellProjectionError)); }), + countArchivedThreadMessages: Effect.fn("OrchestratorV2.countArchivedThreadMessages")( + function () { + return projectionStore + .countArchivedThreadMessages() + .pipe(Effect.mapError(shellProjectionError)); + }, + ), listActiveShellIndex: Effect.fn("OrchestratorV2.listActiveShellIndex")(function () { return projectionStore.listActiveShellIndex().pipe(Effect.mapError(shellProjectionError)); }), diff --git a/apps/server/src/orchestration-v2/ProjectionStore.test.ts b/apps/server/src/orchestration-v2/ProjectionStore.test.ts index 156223fee2072..fd4060a873ebc 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.test.ts @@ -7,6 +7,7 @@ import { CommandId, CheckpointId, ContextTransferId, + type ContextBreakdown, CheckpointRef, CheckpointScopeId, ContextHandoffId, @@ -1986,6 +1987,51 @@ const threadWorktreePathsIncludeArchivedOnRequest = Effect.gen(function* () { occurredAt: now, payload: { ...deleted, deletedAt: now }, }); + const archivedDeleted = yield* create("thread:archived-deleted", { + worktreePath: null, + archived: true, + }); + const addMessage = ( + threadId: ThreadId, + id: string, + role: "user" | "assistant" | "system", + streaming = false, + ) => + store.apply({ + id: EventId.make(`event:${id}`), + type: "message.updated", + threadId, + occurredAt: now, + payload: { + id: MessageId.make(id), + threadId, + runId: null, + nodeId: null, + role, + text: id, + attachments: [], + streaming, + createdBy: "user", + creationSource: "web", + createdAt: now, + updatedAt: now, + }, + }); + const archivedId = ThreadId.make("thread:archived"); + yield* addMessage(archivedId, "message:archived:user", "user"); + yield* addMessage(archivedId, "message:archived:assistant", "assistant"); + yield* addMessage(archivedId, "message:archived:system", "system"); + yield* addMessage(archivedId, "message:archived:streaming", "assistant", true); + yield* addMessage(ThreadId.make("thread:active"), "message:active:user", "user"); + yield* addMessage(archivedDeleted.id, "message:archived-deleted:user", "user"); + yield* store.apply({ + id: EventId.make("event:thread:archived-deleted:deleted"), + type: "thread.deleted", + threadId: archivedDeleted.id, + occurredAt: now, + payload: { ...archivedDeleted, deletedAt: now }, + }); + assert.deepEqual(yield* store.countArchivedThreadMessages(), new Map([[archivedId, 2]])); assert.deepEqual(yield* store.getThreadWorktreePaths(), [ { threadId: ThreadId.make("thread:active"), worktreePath: "/worktrees/active" }, @@ -3704,7 +3750,7 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { () => runlessNativeSubagentNeedsRecovery, ); it.effect( - "worktree path and title lookups skip deleted threads, and can include archived ones", + "worktree path, title, and archived message count lookups skip deleted threads", () => threadWorktreePathsIncludeArchivedOnRequest, ); @@ -3856,6 +3902,78 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { const replaced = yield* projectionStore.getThreadProjection(threadId); assert.deepEqual(replaced.providerTurns[0]?.tokenUsage, replacementUsage); + + const categories = [ + { kind: "system_prompt", tokens: 3_000 }, + { kind: "system_tools", tokens: 4_000 }, + { kind: "messages", tokens: 11_000 }, + ] as const; + const fresh = [ + { kind: "system_prompt", tokens: 2_000 }, + { kind: "messages", tokens: 21_000 }, + ] as const; + const phases = [ + { usedTokens: 18_000, breakdown: categories, expected: categories }, + { + usedTokens: 20_000, + expected: [...categories.slice(0, 2), { kind: "messages", tokens: 13_000 }], + }, + { + usedTokens: 19_000, + expected: [...categories.slice(0, 2), { kind: "messages", tokens: 12_000 }], + }, + { + usedTokens: 24_000, + breakdown: fresh, + expected: [fresh[0], { kind: "messages", tokens: 22_000 }], + }, + { usedTokens: 25_000, expected: [fresh[0], { kind: "messages", tokens: 23_000 }] }, + { usedTokens: 4_000, breakdown: [], expected: [] }, + { usedTokens: 6_000, expected: undefined }, + { + usedTokens: 24_000, + breakdown: fresh, + expected: [fresh[0], { kind: "messages", tokens: 22_000 }], + }, + { usedTokens: 1_000, expected: [] }, + ] satisfies ReadonlyArray<{ + usedTokens: number; + breakdown?: ContextBreakdown; + expected: ContextBreakdown | undefined; + }>; + for (const [index, phase] of phases.entries()) { + const turn = yield* projectionStore.carryProviderTurnContextUsage(threadId, { + ...providerTurn, + tokenUsage: { + usedTokens: phase.usedTokens, + ...("breakdown" in phase ? { breakdown: phase.breakdown } : {}), + updatedAt: `2026-08-29T12:01:0${index}.000Z`, + }, + }); + assert.deepEqual(turn.tokenUsage?.breakdown, phase.expected); + yield* projectionStore.apply({ + id: EventId.make(`event:provider-usage-reload:phase-${index}`), + type: "provider-turn.updated", + threadId, + nodeId, + driver, + occurredAt: now, + payload: turn, + }); + const reloaded = yield* projectionStore.getThreadRecords(threadId, ["providerTurns"], { + latestProviderTurnUsageOnly: true, + }); + assert.deepEqual(reloaded.providerTurns[0]?.tokenUsage?.breakdown, phase.expected); + } + const unrelated = { + ...providerTurn, + providerThreadId: ProviderThreadId.make("other-context"), + tokenUsage: replacementUsage, + }; + assert.deepEqual( + yield* projectionStore.carryProviderTurnContextUsage(threadId, unrelated), + unrelated, + ); }), ); diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index 65c5d38b1d09d..0147a09c54ce1 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -500,6 +500,10 @@ export interface OlderSettledThreadsListing { } export interface ProjectionStoreV2Shape { + readonly carryProviderTurnContextUsage: ( + threadId: ThreadId, + turn: OrchestrationV2ProviderTurn, + ) => Effect.Effect; readonly searchThreadStream: ( input: OrchestrationV2SearchThreadInput, ) => Stream.Stream; @@ -584,6 +588,10 @@ export interface ProjectionStoreV2Shape { >; /** Threads that are not deleted, archived ones included. */ readonly countThreads: () => Effect.Effect; + readonly countArchivedThreadMessages: () => Effect.Effect< + ReadonlyMap, + ProjectionStoreV2Error + >; /** Every active thread, in shell order. */ readonly listActiveShellIndex: () => Effect.Effect< ReadonlyArray, @@ -6523,6 +6531,44 @@ export const layer: Layer.Layer = sql.withTransaction(effect).pipe(Effect.mapError(controlReadError(threadId))), ); + const carryProviderTurnContextUsage = Effect.fn( + "ProjectionStore.carryProviderTurnContextUsage", + )( + function* (threadId: ThreadId, turn: OrchestrationV2ProviderTurn) { + const usage = turn.tokenUsage; + if (usage === undefined || usage.breakdown?.length === 0) return turn; + const rows = + usage.breakdown === undefined + ? yield* sql` + SELECT payload_json FROM orchestration_v2_projection_provider_turns + WHERE thread_id = ${threadId} AND provider_thread_id = ${turn.providerThreadId} + AND json_extract(payload_json, '$.tokenUsage') IS NOT NULL + ORDER BY julianday(json_extract(payload_json, '$.tokenUsage.updatedAt')) DESC, + ordinal DESC + LIMIT 1 + ` + : []; + const previous = + rows[0] === undefined + ? undefined + : (yield* decodeProviderTurnPayload(rows[0].payload_json)).tokenUsage; + const lastBreakdown = usage.breakdown ?? previous?.breakdown; + if (!lastBreakdown?.length) return turn; + const categories = lastBreakdown.filter((category) => category.kind !== "messages"); + const fixedTokens = categories.reduce((sum, category) => sum + category.tokens, 0); + // Only conversation growth is measured between summaries; compaction sends an empty split. + const breakdown = + fixedTokens > usage.usedTokens + ? [] + : [ + ...categories, + { kind: "messages" as const, tokens: usage.usedTokens - fixedTokens }, + ]; + return { ...turn, tokenUsage: { ...usage, breakdown } }; + }, + (effect, threadId) => effect.pipe(Effect.mapError(controlReadError(threadId))), + ); + const getThreadSnapshot: ProjectionStoreV2Shape["getThreadSnapshot"] = (threadId) => sql .withTransaction( @@ -8919,6 +8965,24 @@ export const layer: Layer.Layer = return rows[0]?.count ?? 0; }, Effect.mapError(shellSnapshotReadError)); + const countArchivedThreadMessages = Effect.fn("ProjectionStore.countArchivedThreadMessages")( + function* () { + const rows = yield* sql<{ readonly thread_id: string; readonly count: number }>` + SELECT message.thread_id AS thread_id, COUNT(*) AS count + FROM orchestration_v2_projection_messages AS message + INNER JOIN orchestration_v2_projection_threads AS thread + ON thread.thread_id = message.thread_id + WHERE thread.archived_at IS NOT NULL + AND thread.deleted_at IS NULL + AND message.streaming = 0 + AND message.role IN ('user', 'assistant') + GROUP BY message.thread_id + `; + return new Map(rows.map((row) => [ThreadId.make(row.thread_id), Number(row.count)])); + }, + Effect.mapError(shellSnapshotReadError), + ); + const listActiveShellIndex = Effect.fn("ProjectionStore.listActiveShellIndex")(function* () { const rows = yield* sql<{ readonly thread_id: string; @@ -9255,11 +9319,13 @@ export const layer: Layer.Layer = return { apply, + carryProviderTurnContextUsage, getAgentAwarenessSnapshot: readOnReader(getAgentAwarenessSnapshot), getShellSnapshot: readOnReader(getShellSnapshot), getPullRequestSnapshot: readOnReader(getPullRequestSnapshot), listActiveSubagentThreadIds: readOnReader(listActiveSubagentThreadIds), countThreads: readOnReader(countThreads), + countArchivedThreadMessages: readOnReader(countArchivedThreadMessages), listActiveShellIndex: readOnReader(listActiveShellIndex), listActiveThreads: readOnReader(listActiveThreads), readShellSnapshot: readOnReader(readShellSnapshot), diff --git a/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts b/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts index a751b0bb462d1..ec9448965e0c4 100644 --- a/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts +++ b/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts @@ -234,6 +234,87 @@ function threadCreatedEvent( const layer = it.layer(layerTest); +layer("context breakdown", (it) => { + it.effect.each(["claudeAgent", "codex"])( + "persists and streams the last category split for %s", + (provider) => + Effect.gen(function* () { + const now = yield* DateTime.now; + const sink = yield* EventSink.EventSinkV2; + const projections = yield* ProjectionStore.ProjectionStoreV2; + const events = yield* EventStore.EventStoreV2; + const thread = yield* threadCreatedEvent(now); + yield* sink.write({ events: [thread] }); + const breakdown = [ + { kind: "system_prompt", tokens: 300 }, + { kind: "messages", tokens: 700 }, + ] as const; + const turn = { + id: ProviderTurnId.make("context:first"), + providerThreadId: ProviderThreadId.make("context:provider-thread"), + nodeId: NodeId.make("context:node"), + runAttemptId: null, + nativeTurnRef: null, + ordinal: 1, + status: "completed" as const, + startedAt: now, + completedAt: now, + tokenUsage: { usedTokens: 1_000, breakdown, updatedAt: DateTime.formatIso(now) }, + }; + yield* sink.write({ + events: [ + { + id: yield* (yield* IdAllocator.IdAllocatorV2).allocate.event({ + threadId: thread.threadId, + }), + type: "provider-turn.updated", + threadId: thread.threadId, + driver: ProviderDriverKind.make(provider), + occurredAt: now, + payload: turn, + }, + ], + }); + const stored = yield* sink.write({ + events: [ + { + id: yield* (yield* IdAllocator.IdAllocatorV2).allocate.event({ + threadId: thread.threadId, + }), + type: "provider-turn.updated", + threadId: thread.threadId, + driver: ProviderDriverKind.make(provider), + occurredAt: now, + payload: { + ...turn, + id: ProviderTurnId.make("context:second"), + ordinal: 2, + status: "running", + completedAt: null, + tokenUsage: { usedTokens: 1_200, updatedAt: DateTime.formatIso(now) }, + }, + }, + ], + }); + const expected = + provider === "claudeAgent" + ? ([ + { kind: "system_prompt", tokens: 300 }, + { kind: "messages", tokens: 900 }, + ] as const) + : undefined; + const live = stored[0]!.event; + assert.equal(live.type, "provider-turn.updated"); + if (live.type !== "provider-turn.updated") return; + assert.deepEqual(live.payload.tokenUsage?.breakdown, expected); + const reloaded = yield* projections.getThreadProjection(thread.threadId); + assert.deepEqual(reloaded.providerTurns[1]?.tokenUsage?.breakdown, expected); + const replayed = yield* events.read({ threadId: thread.threadId }).pipe(Stream.runCollect); + assert.deepEqual(replayed.at(-1)?.event, live); + }), + ); +}); + it.effect("takes a hub's reset for an unreset usage_limit, then refreshes the snapshot", () => Effect.gen(function* () { const refreshed = yield* Deferred.make(); diff --git a/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts b/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts index 532d019ddd6fb..32851a55173b1 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts @@ -210,6 +210,7 @@ it.effect( ProjectionStore.ProjectionStoreV2, ProjectionStore.ProjectionStoreV2.of({ apply: () => Effect.void, + carryProviderTurnContextUsage: () => Effect.die("unused carryProviderTurnContextUsage"), getLimitRecoveryCandidates: () => Effect.die("unused getLimitRecoveryCandidates"), getLimitRecoveryWakeAt: () => Effect.die("unused getLimitRecoveryWakeAt"), getOpenRemoteDelegatedTasks: Effect.die("unused getOpenRemoteDelegatedTasks"), @@ -217,6 +218,7 @@ it.effect( getShellSnapshot: () => Effect.die("unused getShellSnapshot"), listActiveSubagentThreadIds: () => Effect.die("unused listActiveSubagentThreadIds"), countThreads: () => Effect.die("unused countThreads"), + countArchivedThreadMessages: () => Effect.die("unused countArchivedThreadMessages"), listActiveShellIndex: () => Effect.die("unused listActiveShellIndex"), listActiveThreads: () => Effect.die("unused listActiveThreads"), listOlderSettledThreads: () => Effect.die("unused listOlderSettledThreads"), diff --git a/apps/server/src/orchestration-v2/RemoteDelegatedTask.test.ts b/apps/server/src/orchestration-v2/RemoteDelegatedTask.test.ts index 292882d0f894b..0306bc04364af 100644 --- a/apps/server/src/orchestration-v2/RemoteDelegatedTask.test.ts +++ b/apps/server/src/orchestration-v2/RemoteDelegatedTask.test.ts @@ -217,6 +217,7 @@ it.layer(layerTest)("a delegated task in a linked environment", (it) => { const [task] = projection.subagents; assert.equal(task?.childThreadId, null); assert.deepEqual(task?.remoteChild, remoteChild); + // Clients name the task's model and effort from the selection it was started with. assert.deepEqual(task?.modelSelection, modelSelection); assert.equal(task?.status, "running"); const item = projection.turnItems.find((candidate) => candidate.type === "subagent"); diff --git a/apps/server/src/orchestration-v2/ThreadFind.test.ts b/apps/server/src/orchestration-v2/ThreadFind.test.ts index d0e8760a9addc..6b86756bea043 100644 --- a/apps/server/src/orchestration-v2/ThreadFind.test.ts +++ b/apps/server/src/orchestration-v2/ThreadFind.test.ts @@ -615,7 +615,7 @@ describe("V2 thread find", () => { ); it.effect( - "does not search undispatched inputs, cancelled queue rows, or folded question answers", + "does not search undispatched inputs, cancelled queue rows, folded question answers, or retried prompts", () => Effect.gen(function* () { const projection = yield* setup; @@ -624,6 +624,8 @@ describe("V2 thread find", () => { const queued = item("cancelled", 1, "needle", "user_message"); if (queued.type !== "user_message") return; const answer = item("async-answer:question", 2, "needle", "user_message"); + const retried = item("retried", 4, "needle", "user_message"); + if (retried.type !== "user_message") return; const question: OrchestrationV2TurnItem = { ...item("question", 3, ""), type: "user_input_request", @@ -635,6 +637,7 @@ describe("V2 thread find", () => { { ...queued, runId: queuedRunId, inputIntent: "queued_turn" }, answer, question, + { ...retried, retryOfRunId: runId }, ]); const event: OrchestrationV2DomainEvent = { id: EventId.make("undispatched"), diff --git a/apps/server/src/orchestration-v2/ThreadFind.ts b/apps/server/src/orchestration-v2/ThreadFind.ts index e0f732ba9425a..2ae37ffd2dcb7 100644 --- a/apps/server/src/orchestration-v2/ThreadFind.ts +++ b/apps/server/src/orchestration-v2/ThreadFind.ts @@ -36,6 +36,7 @@ interface FindPayload extends FindRow { readonly runId: OrchestrationV2TurnItem["runId"]; readonly type: OrchestrationV2TurnItem["type"]; readonly emptyText: boolean; + readonly retried: boolean; } interface FindDocument extends FindRow { readonly entryId: string; @@ -234,12 +235,14 @@ export const makeThreadFind = Effect.fn("makeThreadFind")(function* ( run_id: OrchestrationV2TurnItem["runId"]; item_type: OrchestrationV2TurnItem["type"]; empty_text: number | null; + retried: number; }>` SELECT item.thread_id, item.turn_item_id, item.payload_json, COALESCE(json_extract(item.payload_json, '$.messageId'), item.turn_item_id) AS entry_id, json_extract(item.payload_json, '$.runId') AS run_id, json_extract(item.payload_json, '$.type') AS item_type, - json_extract(item.payload_json, '$.text') = '' AS empty_text + json_extract(item.payload_json, '$.text') = '' AS empty_text, + json_extract(item.payload_json, '$.retryOfRunId') IS NOT NULL AS retried FROM orchestration_v2_projection_turn_items AS item JOIN json_each(${sources}) AS wanted ON item.thread_id = json_extract(wanted.value, '$.threadId') @@ -262,6 +265,7 @@ export const makeThreadFind = Effect.fn("makeThreadFind")(function* ( runId: payload.run_id, type: payload.item_type, emptyText: payload.empty_text === 1, + retried: payload.retried === 1, }); }); }); @@ -438,7 +442,10 @@ export const makeThreadFind = Effect.fn("makeThreadFind")(function* ( payloads.filter( (row) => row.type !== "user_input_request" && - !(row.type === "user_message" && foldedAnswerRunIds.get(row.entryId) === row.runId), + !( + row.type === "user_message" && + (row.retried || foldedAnswerRunIds.get(row.entryId) === row.runId) + ), ); const documents = new Map(); const count = Effect.fnUntraced(function* (row: FindPayload | RenderedDocument) { diff --git a/apps/server/src/orchestration-v2/ThreadManagementService.ts b/apps/server/src/orchestration-v2/ThreadManagementService.ts index b89f5f9bd0b5b..b55f8f1ccd73d 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.ts @@ -441,6 +441,7 @@ export interface ThreadManagementServiceShape { ) => Effect.Effect; readonly listActiveSubagentThreadIds: Orchestrator.OrchestratorV2["Service"]["listActiveSubagentThreadIds"]; readonly countThreads: Orchestrator.OrchestratorV2["Service"]["countThreads"]; + readonly countArchivedThreadMessages: Orchestrator.OrchestratorV2["Service"]["countArchivedThreadMessages"]; readonly listActiveShellIndex: Orchestrator.OrchestratorV2["Service"]["listActiveShellIndex"]; readonly listOlderSettledThreads: Orchestrator.OrchestratorV2["Service"]["listOlderSettledThreads"]; readonly readShellSnapshot: ( @@ -1750,6 +1751,7 @@ const make = Effect.gen(function* () { getShellSnapshot: orchestrator.getShellSnapshot, listActiveSubagentThreadIds: orchestrator.listActiveSubagentThreadIds, countThreads: orchestrator.countThreads, + countArchivedThreadMessages: orchestrator.countArchivedThreadMessages, listActiveShellIndex: orchestrator.listActiveShellIndex, listOlderSettledThreads: orchestrator.listOlderSettledThreads, readShellSnapshot: orchestrator.readShellSnapshot, diff --git a/apps/server/src/orchestration-v2/ThreadSearch.test.ts b/apps/server/src/orchestration-v2/ThreadSearch.test.ts index 7518705f5a5ac..41fba295b4ad5 100644 --- a/apps/server/src/orchestration-v2/ThreadSearch.test.ts +++ b/apps/server/src/orchestration-v2/ThreadSearch.test.ts @@ -134,6 +134,7 @@ const message = ( readonly streaming?: boolean; readonly createdBy?: "user" | "agent"; readonly runId?: RunId; + readonly retryOfRunId?: RunId; } = {}, ): OrchestrationV2DomainEvent => ({ id: EventId.make(`message:${id}`), @@ -152,6 +153,7 @@ const message = ( text, attachments: [], streaming: options.streaming ?? false, + ...(options.retryOfRunId === undefined ? {} : { retryOfRunId: options.retryOfRunId }), createdAt: at(options.minute ?? 1), updatedAt: at(options.minute ?? 1), }, @@ -736,7 +738,7 @@ it.layer(layerTest)("ThreadSearch filters before it ranks", (it) => { }), ); - it.effect("leaves out answers in their question card and turns the thread hides", () => + it.effect("leaves out question answers, retried prompts and turns the thread hides", () => Effect.gen(function* () { const projections = yield* ProjectionStore.ProjectionStoreV2; const search = yield* ThreadSearch.ThreadSearch; @@ -812,6 +814,9 @@ it.layer(layerTest)("ThreadSearch filters before it ranks", (it) => { [ thread(asked, project), message(asked, "async-answer:request-1", "user", "use the albatross drive"), + message(asked, "retried", "user", "fix the albatross", { + retryOfRunId: RunId.make("run:failed"), + }), thread(rewound, project), run(rewound, "rolled_back"), message(rewound, "rewound-reply", "assistant", "the albatross drive is ready", { diff --git a/apps/server/src/orchestration-v2/ThreadSearch.ts b/apps/server/src/orchestration-v2/ThreadSearch.ts index f01fb6b0f35eb..6e48fe9b83325 100644 --- a/apps/server/src/orchestration-v2/ThreadSearch.ts +++ b/apps/server/src/orchestration-v2/ThreadSearch.ts @@ -280,6 +280,7 @@ export const messageHitsQuery = ( -- Answers to an agent's question show inside its question card, where -- neither find nor a jump can reach them. AND messages.message_id NOT LIKE 'async-answer:%' + AND json_extract(messages.payload_json, '$.retryOfRunId') IS NULL -- Nor can they reach a rolled-back turn, or a message of a stopped turn -- that no shown item carries, such as a queued prompt that never ran. AND NOT EXISTS ( diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index d9c431925a6bb..bf52a6fd46acf 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -11071,6 +11071,50 @@ it.layer(layerSharedApplicationDataPlaneTest)("visited projection", (it) => { }), ); + it.effect("keeps a retry's failed-run marker on its message and turn item", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const threadId = ThreadId.make("runtime-layer-retry-marker"); + const retryOfRunId = RunId.make(`${threadId}:failed-run`); + const messageId = MessageId.make(`${threadId}:retry`); + yield* orchestrator.dispatch({ + type: "thread.create", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make(`${threadId}:create`), + threadId, + projectId: ProjectId.make(`${threadId}:project`), + title: "Retried thread", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: `/tmp/t3-${threadId}`, + }); + yield* orchestrator.dispatch({ + type: "message.dispatch", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make(`${threadId}:message`), + threadId, + messageId, + text: "Build it", + attachments: [], + retryOfRunId, + dispatchMode: { type: "start_immediately" }, + }); + const projection = yield* orchestrator.getThreadProjection(threadId); + assert.equal( + projection.messages.find((message) => message.id === messageId)?.retryOfRunId, + retryOfRunId, + ); + const item = projection.turnItems.find( + (candidate) => candidate.type === "user_message" && candidate.messageId === messageId, + ); + assert.equal(item?.type === "user_message" ? item.retryOfRunId : undefined, retryOfRunId); + }), + ); + it.effect("marks unread before the user's last turn when an automatic compaction followed", () => Effect.gen(function* () { const orchestrator = yield* Orchestrator.OrchestratorV2; diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index 10388e99fe7c5..392aee9432298 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -226,6 +226,7 @@ const makeTestRelay = Effect.fnUntraced(function* ( listActiveSubagentThreadIds: unused, listAppOwnedChildThreadIds: unused, countThreads: unused, + countArchivedThreadMessages: unused, listActiveShellIndex: () => Effect.succeed([]), listOlderSettledThreads: unused, readShellSnapshot: unused, diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index 873649cb168f5..5ef8a91b18946 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -5,6 +5,7 @@ import { DEFAULT_SERVER_SETTINGS, globalIdleCompaction, type ManagedPolicyDocument, + DEFAULT_LAYOUT_SETTINGS, ModelSelection, ProjectId, ProjectScript, @@ -13,6 +14,7 @@ import { ProviderInstanceId, resolveProviderInstanceEnabled, SecretProviderId, + ServerConfig as ServerConfigContract, ServerSettings, ServerSettingsPatch, UsageLimitSourceId, @@ -43,7 +45,10 @@ import { resolveProviderInstanceTerminalEnvironment } from "./terminal/Manager.t const decodeServerSettings = Schema.decodeUnknownEffect(ServerSettings); const decodeServerSettingsJson = Schema.decodeUnknownEffect(Schema.fromJsonString(ServerSettings)); -const decodeServerSettingsPatch = Schema.decodeUnknownEffect(ServerSettingsPatch); +const decodeClientConfigSettings = Schema.decodeEffect( + Schema.toType(ServerConfigContract.fields.settings), +); +const decodeSettingsPatch = Schema.decodeUnknownEffect(ServerSettingsPatch); const layerServerSettings = (policy?: ManagedPolicyDocument) => ServerSettingsModule.layer.pipe( @@ -219,6 +224,86 @@ it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("server settings" }), ).pipe(Effect.provide(layerServerSettings())), ); + it.effect("retains layout only for client migration across unrelated saves and restarts", () => + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + const service = yield* ServerSettingsModule.ServerSettingsService; + const legacyLayout = { ...DEFAULT_LAYOUT_SETTINGS, terminalPlacement: "new-column" as const }; + yield* fs.writeFileString( + config.settingsPath, + JSON.stringify({ + layout: legacyLayout, + responseStreamingMode: "turn", + projectSettingsOverrides: { + legacy: { layout: { template: "retired-shape" }, defaultAutoPull: true }, + }, + }), + ); + + const settings = yield* service.getSettings; + const clientSettings = yield* decodeClientConfigSettings( + ServerSettingsModule.redactServerSettingsForClient(settings, { secretReferences: true }), + ); + assert.deepEqual(clientSettings.layout, legacyLayout); + const projectSettingsOverrides: unknown = settings.projectSettingsOverrides; + assert.deepEqual(projectSettingsOverrides, { + [ProjectId.make("legacy")]: { + layout: { template: "retired-shape" }, + defaultAutoPull: true, + }, + }); + yield* service.updateSettings( + yield* decodeSettingsPatch({ + responseStreamingMode: "paragraph", + layout: { ...legacyLayout, newTabPosition: "end" }, + }), + ); + const persisted = JSON.parse(yield* fs.readFileString(config.settingsPath)); + assert.deepEqual(persisted.layout, legacyLayout); + assert.notProperty(persisted, "legacyLayout"); + assert.deepEqual(persisted.projectSettingsOverrides, { + legacy: { layout: { template: "retired-shape" }, defaultAutoPull: true }, + }); + + const restarted = yield* Effect.gen(function* () { + const service = yield* ServerSettingsModule.ServerSettingsService; + return yield* service.getSettings; + }).pipe( + Effect.provide( + Layer.fresh(ServerSettingsModule.layer).pipe(Layer.provide(ManagedPolicy.layerTest())), + ), + ); + assert.deepEqual( + (yield* decodeClientConfigSettings( + ServerSettingsModule.redactServerSettingsForClient(restarted, { secretReferences: true }), + )).layout, + legacyLayout, + ); + assert.equal(restarted.responseStreamingMode, "paragraph"); + }).pipe(Effect.provide(layerServerSettingsWithSecrets())), + ); + + it.effect("ignores malformed retired layout without resetting other server settings", () => + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + const service = yield* ServerSettingsModule.ServerSettingsService; + yield* fs.writeFileString( + config.settingsPath, + JSON.stringify({ + layout: { template: "retired-shape" }, + responseStreamingMode: "turn", + enableAgentBrowserAccess: false, + }), + ); + const settings = yield* service.getSettings; + assert.notProperty(settings, "layout"); + assert.equal(settings.responseStreamingMode, "turn"); + assert.isFalse(settings.enableAgentBrowserAccess); + }).pipe(Effect.provide(layerServerSettings())), + ); + it.effect("migrates saved token delivery to paragraph buffering without resetting settings", () => Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; @@ -326,7 +411,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("server settings" Effect.gen(function* () { const service = yield* ServerSettingsModule.ServerSettingsService; const write = (entry: unknown) => - decodeServerSettingsPatch({ secretProviders: { personal: entry } }).pipe( + decodeSettingsPatch({ secretProviders: { personal: entry } }).pipe( Effect.flatMap(service.updateSettings), Effect.flip, ); @@ -1872,7 +1957,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("server settings" const work = { provider: SecretProviderId.make("work"), reference: "op://Work/token" }; const instanceId = ProviderInstanceId.make("antigravity_refs"); const hub = UsageLimitSourceId.make("hub"); - yield* decodeServerSettingsPatch({ + yield* decodeSettingsPatch({ secretProviders: { personal: { kind: "one_password", label: "Personal", config: { account: "my" } }, work: { kind: "one_password", label: "Work", config: { account: "work" } }, @@ -2971,7 +3056,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))( reference: "op://Private/Anthropic/key", }; yield* withPolicy(undefined, (service) => - decodeServerSettingsPatch({ + decodeSettingsPatch({ secretProviders: { personal: { kind: "one_password", label: "Personal", config: { account: "my" } }, }, diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index 4dc30d29a6cff..9871c392d6298 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -1,5 +1,7 @@ import { DEFAULT_SERVER_SETTINGS, + ForwardCompatibleOptional, + LayoutSettings, ModelSelection, ProjectId, ProjectScript, @@ -76,7 +78,11 @@ export { } from "@t3tools/shared/serverSettings"; const encodeServerSettings = Schema.encodeEffect(ServerSettings); -const encodeServerSettingsJson = Schema.encodeUnknownEffect(fromJsonStringPretty(ServerSettings)); +const encodeServerSettingsJson = Schema.encodeUnknownEffect( + fromJsonStringPretty( + Schema.Struct({ ...ServerSettings.fields, layout: ForwardCompatibleOptional(LayoutSettings) }), + ), +); const decodeServerSettings = Schema.decodeUnknownEffect(ServerSettings); const textEncoder = new TextEncoder(); @@ -260,6 +266,7 @@ export class ServerSettingsService extends Context.Service< /** The administrator's policy as clients see it; absent when unmanaged. */ readonly managedPolicy?: ManagedPolicySummary | undefined; + /** Read current server settings. */ readonly getSettings: Effect.Effect; readonly updateSettings: ( @@ -380,8 +387,10 @@ const PersistedServerSettings = Schema.Struct({ // Parse first and decode second, so a managed policy can fill its defaults in between. const decodeSettingsFileJsonExit = Schema.decodeUnknownExit(fromLenientJson(Schema.Unknown)); const decodePersistedServerSettingsExit = Schema.decodeUnknownExit(PersistedServerSettings); +/** Retired keys feed source-control migration and the read-only client layout migration. */ const LegacyProviderSettingsJson = fromLenientJson( Schema.Struct({ + layout: ForwardCompatibleOptional(LayoutSettings), providers: Schema.optionalKey(Schema.Record(Schema.String, Schema.Unknown)), bitbucket: Schema.optionalKey(Schema.Record(Schema.String, Schema.Unknown)), github: Schema.optionalKey(Schema.Record(Schema.String, Schema.Unknown)), @@ -686,6 +695,7 @@ const make = Effect.gen(function* () { ); const writeSemaphore = yield* Semaphore.make(1); const cacheKey = "settings" as const; + const legacyLayoutRef = yield* Ref.make(undefined); const changesPubSub = yield* PubSub.unbounded(); const startedRef = yield* Ref.make(false); const startedDeferred = yield* Deferred.make(); @@ -726,14 +736,19 @@ const make = Effect.gen(function* () { const writeSettingsAtomically = Effect.fnUntraced( function* (settings: ServerSettings) { + // Preserve the read-only layout on disk so each client can migrate independently. + const legacyLayout = yield* Ref.get(legacyLayoutRef); const stripped = stripDefaultServerSettings(settings, managed.defaults) ?? {}; - const sparseSettingsJson = yield* encodeServerSettingsJson( + const sparseSettings = managed.keepStored === undefined ? stripped : managed.keepStored(stripped, settings, yield* readStoredSettings, (key) => ATOMIC_SETTINGS_KEYS.has(key), - ), - ); + ); + const sparseSettingsJson = yield* encodeServerSettingsJson({ + ...(typeof sparseSettings === "object" && sparseSettings !== null ? sparseSettings : {}), + ...(legacyLayout === undefined ? {} : { layout: legacyLayout }), + }); return yield* writeFileStringAtomically({ filePath: settingsPath, @@ -785,6 +800,7 @@ const make = Effect.gen(function* () { const loadSettingsFromDisk = Effect.gen(function* () { let settings = managed.defaults; + yield* Ref.set(legacyLayoutRef, undefined); let legacyProviders: Readonly> | undefined; let legacySourceControl: Parameters[1] = {}; let parseFailure = Option.none(); @@ -797,6 +813,7 @@ const make = Effect.gen(function* () { : decodePersistedServerSettingsExit(managed.withDefaults(parsed.value)); const legacySettings = decodeLegacyProviderSettingsJsonExit(raw); if (legacySettings._tag === "Success") { + yield* Ref.set(legacyLayoutRef, legacySettings.value.layout); legacyProviders = legacySettings.value.providers; legacySourceControl = { bitbucket: legacySettings.value.bitbucket, @@ -1366,7 +1383,15 @@ const make = Effect.gen(function* () { managedPolicy, start, ready: Deferred.await(startedDeferred), - getSettings: getMaterializedSettings, + getSettings: getMaterializedSettings.pipe( + Effect.flatMap((settings) => + Ref.get(legacyLayoutRef).pipe( + Effect.map((legacyLayout) => + legacyLayout === undefined ? settings : { ...settings, layout: legacyLayout }, + ), + ), + ), + ), updateSettings: (patch) => updateAndPersistSettings(patch, (current) => ensureSecretProviderWritesAllowed(patch, settingsPath).pipe( diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index d7ad7196929d0..6d65101cea822 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1908,22 +1908,7 @@ export const layerWsRpc = ( listProjects: projectStore.listShells(), latestSequence: applicationEvents.latestApplicationSequence, }); - // Settings › Archive shows how much conversation each thread holds. - // Only this snapshot pays for the count; live shells stay lean. - const messageCountRows = yield* sql<{ threadId: string; messageCount: number }>` - SELECT message.thread_id AS "threadId", COUNT(*) AS "messageCount" - FROM orchestration_v2_projection_messages AS message - INNER JOIN orchestration_v2_projection_threads AS thread - ON thread.thread_id = message.thread_id - WHERE thread.archived_at IS NOT NULL - AND thread.deleted_at IS NULL - AND message.streaming = 0 - AND message.role IN ('user', 'assistant') - GROUP BY message.thread_id - `; - const messageCountByThread = new Map( - messageCountRows.map((row) => [row.threadId, Number(row.messageCount)] as const), - ); + const messageCountByThread = yield* threadManagement.countArchivedThreadMessages(); return { schemaVersion: threads.schemaVersion, snapshotSequence, diff --git a/apps/web/index.html b/apps/web/index.html index 713f90c90c2dc..dc6f9c3a1bbb2 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -56,6 +56,18 @@ // splash's background is the sidebar role, the window color the // runtime paints around the inset pane. const BUILT_IN_THEME_PALETTES = { + "t3-code-classic": { + light: { + background: "#fafafa", + foreground: "#18181b", + accent: "#1b4ed8", + }, + dark: { + background: "#000000", + foreground: "#f5f5f5", + accent: "#346bf1", + }, + }, "t3-chat": { light: { background: "oklch(0.928886 0.031178 322.592)", @@ -124,6 +136,7 @@ ]), ); const RESERVED_THEME_IDS = [ + "t3-code-classic", "system", "light", "dark", @@ -417,6 +430,9 @@ html.black-chrome body { background-color: #000000; + } + + html.black-chrome #boot-shell { color: #f5f5f5; } diff --git a/apps/web/src/appInit.tsx b/apps/web/src/appInit.tsx index 10fa831606f70..2407411de6900 100644 --- a/apps/web/src/appInit.tsx +++ b/apps/web/src/appInit.tsx @@ -42,6 +42,7 @@ import { startHostEnvironmentLookup } from "./state/primaryEnvironment"; import { getClientSettings, getClientSettingsHydratedSnapshot, + startLegacyLayoutSettingsMigration, startModelPreferencesMigration, subscribeClientSettings, subscribeClientSettingsHydration, @@ -278,6 +279,7 @@ export function startAppServices(): AppRouter { startBrowserChromeThemeSync(router); startDefaultThemeAdoption(); startModelPreferencesMigration(); + startLegacyLayoutSettingsMigration(); startAgentOpenedPreviews(); if (isElectron) startElectronBrowserHost(); startPreviewTitleRecording(); diff --git a/apps/web/src/assets/chrome/art-nightly-sunrise-color.svg b/apps/web/src/assets/chrome/art-nightly-sunrise-color.svg new file mode 100644 index 0000000000000..dfdea07cd9058 --- /dev/null +++ b/apps/web/src/assets/chrome/art-nightly-sunrise-color.svg @@ -0,0 +1,1922 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/web/src/browser/BrowserSurfaceSlot.tsx b/apps/web/src/browser/BrowserSurfaceSlot.tsx index e00b002ca0036..531736992dd7f 100644 --- a/apps/web/src/browser/BrowserSurfaceSlot.tsx +++ b/apps/web/src/browser/BrowserSurfaceSlot.tsx @@ -1,7 +1,94 @@ import { useLayoutEffect, useRef } from "react"; -import { acquireBrowserSurface } from "./browserSurfaceStore"; import { observeResize } from "../lib/observeResize"; +import { acquireBrowserSurface, type BrowserSurfacePlacement } from "./browserSurfaceStore"; +import { + type BrowserSurfaceSlotPhase, + kickBrowserSurfaceTracking, + trackBrowserSurfaceSlot, +} from "./browserSurfaceTracking"; + +/** An ancestor that clips the slot: its pane, the strip, the overview's frame. */ +interface ClipAncestor { + readonly element: Element; + /** Corner radii in its own pixels: top-left, top-right, bottom-right, bottom-left. */ + readonly radii: readonly [number, number, number, number]; +} + +function clipAncestorsOf(element: HTMLElement): ClipAncestor[] { + const ancestors: ClipAncestor[] = []; + for ( + let node = element.parentElement; + node && node !== document.body; + node = node.parentElement + ) { + const style = getComputedStyle(node); + if (style.overflowX === "visible" && style.overflowY === "visible") continue; + ancestors.push({ + element: node, + radii: [ + parseFloat(style.borderTopLeftRadius) || 0, + parseFloat(style.borderTopRightRadius) || 0, + parseFloat(style.borderBottomRightRadius) || 0, + parseFloat(style.borderBottomLeftRadius) || 0, + ], + }); + } + return ancestors; +} + +const near = (value: number, edges: readonly number[]) => + edges.some((edge) => Math.abs(edge - value) < 1); + +/** + * The part of the slot its ancestors leave visible, as a `clip-path` in the + * slot's own pixels. A corner that meets a rounded pane's corner, or where + * the strip's edge cuts that pane (index.css, [data-pane-cut]), rounds as + * the pane does. + */ +function resolveClipPath(rect: DOMRect, scale: number, ancestors: readonly ClipAncestor[]) { + const boxes = ancestors.map((ancestor) => ancestor.element.getBoundingClientRect()); + let left = rect.left; + let top = rect.top; + let right = rect.right; + let bottom = rect.bottom; + for (const box of boxes) { + left = Math.max(left, box.left); + top = Math.max(top, box.top); + right = Math.min(right, box.right); + bottom = Math.min(bottom, box.bottom); + } + if (right - left < 1 || bottom - top < 1) return "inset(50%)"; + const corners = [ + [left, top], + [right, top], + [right, bottom], + [left, bottom], + ] as const; + const radii = corners.map(([x, y], corner) => { + let radius = 0; + ancestors.forEach((ancestor, index) => { + const own = ancestor.radii[corner]!; + if (own <= 0) return; + const box = boxes[index]!; + const outer = boxes.slice(index + 1); + const xs = [box.left, box.right, ...outer.flatMap((edge) => [edge.left, edge.right])]; + const ys = [box.top, box.bottom, ...outer.flatMap((edge) => [edge.top, edge.bottom])]; + if (!near(x, xs) || !near(y, ys)) return; + const ownScale = box.width / Math.max(1, (ancestor.element as HTMLElement).offsetWidth || 1); + radius = Math.max(radius, own * ownScale); + }); + return radius / scale; + }); + const insets = [top - rect.top, rect.right - right, rect.bottom - bottom, left - rect.left].map( + (inset) => Math.max(0, inset / scale), + ); + if (insets.every((inset) => inset < 0.5) && radii.every((radius) => radius === 0)) return ""; + const round = radii.some((radius) => radius > 0) + ? ` round ${radii.map((radius) => `${radius}px`).join(" ")}` + : ""; + return `inset(${insets.map((inset) => `${inset}px`).join(" ")}${round})`; +} export function BrowserSurfaceSlot(props: { readonly tabId: string; @@ -22,56 +109,78 @@ export function BrowserSurfaceSlot(props: { fitSourceContent = false, } = props; const presentationRef = useRef({ visible, cornerRadius, zIndex }); - const updateRef = useRef<(() => void) | null>(null); + const updateRef = useRef<((phase: BrowserSurfaceSlotPhase) => boolean) | null>(null); // The lease lives as long as this element for one tab, so the ref callback owns it. const attachSurface = (element: HTMLDivElement) => { let lease = acquireBrowserSurface(tabId, fitSourceContent); - const update = () => { + let ancestors = clipAncestorsOf(element); + let presented = ""; + let placed: BrowserSurfacePlacement | null = null; + // The webview keeps the slot's own size and follows it on screen with a + // transform, so a pane sliding or zooming never resizes the page. + const update = (phase: BrowserSurfaceSlotPhase) => { const rect = element.getBoundingClientRect(); + const width = Math.max(1, element.offsetWidth); + const height = Math.max(1, element.offsetHeight); const presentation = presentationRef.current; - const presented = lease.present( - { + const shown = presentation.visible && element.offsetWidth > 0 && element.offsetHeight > 0; + if (phase !== "frame") ancestors = clipAncestorsOf(element); + const key = `${width}:${height}:${shown}:${presentation.cornerRadius}:${presentation.zIndex}`; + // Position alone reaches the store once it settles; size and visibility at once. + if (key !== presented || phase !== "frame") { + const box = { x: Math.round(rect.x), y: Math.round(rect.y), - width: Math.max(1, Math.round(rect.width)), - height: Math.max(1, Math.round(rect.height)), - }, - presentation.visible && rect.width > 0 && rect.height > 0, - presentation.cornerRadius, - presentation.zIndex, - ); - if (presentation.visible && !presented) { - lease.release(); - lease = acquireBrowserSurface(tabId, fitSourceContent); - lease.present( - { - x: Math.round(rect.x), - y: Math.round(rect.y), - width: Math.max(1, Math.round(rect.width)), - height: Math.max(1, Math.round(rect.height)), - }, - rect.width > 0 && rect.height > 0, - presentation.cornerRadius, - presentation.zIndex, - ); + width, + height, + }; + if (!lease.present(box, shown, presentation.cornerRadius, presentation.zIndex)) { + // Another slot took the tab (the mini player); a visible one takes it back, + // but not every frame, so two never trade it back and forth. + if (!shown || phase === "frame") return false; + lease.release(); + lease = acquireBrowserSurface(tabId, fitSourceContent); + lease.present(box, shown, presentation.cornerRadius, presentation.zIndex); + } + presented = key; } + const scale = rect.width / width; + const next: BrowserSurfacePlacement = { + x: rect.x, + y: rect.y, + scale, + clipPath: resolveClipPath(rect, scale, ancestors), + // Under the overview or off the focused workspace mid-switch the pane is inert. + interactive: element.closest("[inert]") === null, + }; + const moved = + placed === null || + Math.abs(placed.x - next.x) > 0.01 || + Math.abs(placed.y - next.y) > 0.01 || + Math.abs(placed.scale - next.scale) > 0.0001 || + placed.clipPath !== next.clipPath || + placed.interactive !== next.interactive; + if (moved || phase !== "frame") lease.place(next); + placed = next; + return moved; }; updateRef.current = update; - update(); + update("start"); + const stopTracking = trackBrowserSurfaceSlot(element, update); const observed: Element[] = [element]; // Inline shells animate without resizing the slot, so observe the shell too. const panel = element.closest('[data-preview-panel-mode="inline"]'); if (panel) observed.push(panel); // Sidebar width changes move the slot without changing its size or column offset. observed.push(...document.querySelectorAll('[data-slot="sidebar-gap"]')); - const stopObserving = observeResize(observed, update); - window.addEventListener("resize", update); - window.addEventListener("scroll", update, true); + const stopObserving = observeResize(observed, () => { + update("start"); + kickBrowserSurfaceTracking(); + }); return () => { stopObserving(); - window.removeEventListener("resize", update); - window.removeEventListener("scroll", update, true); + stopTracking(); if (updateRef.current === update) updateRef.current = null; lease.release(); }; @@ -80,7 +189,7 @@ export function BrowserSurfaceSlot(props: { // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- pushes visibility, radius and stacking to the desktop browser surface after layout useLayoutEffect(() => { presentationRef.current = { visible, cornerRadius, zIndex }; - updateRef.current?.(); + updateRef.current?.("start"); }, [ cornerRadius, // oxlint-disable-next-line react/exhaustive-effect-dependencies -- A layoutVersion bump means the slot moved without resizing, so it re-presents at the new position. diff --git a/apps/web/src/browser/HostedBrowserWebview.tsx b/apps/web/src/browser/HostedBrowserWebview.tsx index 30140af3ae2d0..715ba5d2a6c70 100644 --- a/apps/web/src/browser/HostedBrowserWebview.tsx +++ b/apps/web/src/browser/HostedBrowserWebview.tsx @@ -4,7 +4,7 @@ import type { ScopedThreadRef, } from "@t3tools/contracts"; import { useShallow } from "zustand/react/shallow"; -import { useEffect, useEffectEvent, useRef, useState } from "react"; +import { useEffect, useEffectEvent, useLayoutEffect, useRef, useState } from "react"; import { previewBridge } from "~/components/preview/previewBridge"; import { @@ -15,7 +15,11 @@ import { usePreviewBridge } from "~/components/preview/usePreviewBridge"; import { useClientSettingsHydrated } from "~/hooks/useSettings"; import { cn, isMacPlatform } from "~/lib/utils"; -import { resolveBrowserSurfacePanelRect, useBrowserSurfaceStore } from "./browserSurfaceStore"; +import { + resolveBrowserSurfacePanelRect, + syncBrowserSurfaceWrapper, + useBrowserSurfaceStore, +} from "./browserSurfaceStore"; import { useActiveBrowserRecordingTabIds } from "./browserRecording"; import { browserViewportSettingKey, @@ -188,7 +192,10 @@ export function HostedBrowserWebview(props: { // Guest clicks never reach this document, so replay a pointerdown to dismiss host popups. // The guest preload reports each press, since the guest does not always take focus. const dismissHostPopups = () => { - webview.dispatchEvent( + const slot = document.querySelector( + `[data-browser-surface-slot="${CSS.escape(runtimeTabId)}"]`, + ); + (slot?.closest("[data-layout-tab-id]") ? slot : webview).dispatchEvent( new PointerEvent("pointerdown", { bubbles: true, pointerType: "mouse" }), ); }; @@ -293,6 +300,15 @@ export function HostedBrowserWebview(props: { return () => window.cancelAnimationFrame(frameId); }, [syncContentPresentation]); + // The slot moves the wrapper on screen directly (BrowserSurfaceSlot); a + // render that shows or hides it takes or drops that placement. + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Publish the committed wrapper placement to the external browser surface registry. + useLayoutEffect(() => { + syncBrowserSurfaceWrapper(runtimeTabId, wrapperRef.current, active); + }); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Remove this wrapper from the browser surface registry when its runtime tab unmounts. + useEffect(() => () => syncBrowserSurfaceWrapper(runtimeTabId, null, false), [runtimeTabId]); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- scrolls the webview wrapper element to its origin for a new tab or viewport size useEffect(() => { const wrapper = wrapperRef.current; diff --git a/apps/web/src/browser/ServerBrowserSurface.tsx b/apps/web/src/browser/ServerBrowserSurface.tsx index 67b467dd5f9ae..c570efb9f4029 100644 --- a/apps/web/src/browser/ServerBrowserSurface.tsx +++ b/apps/web/src/browser/ServerBrowserSurface.tsx @@ -29,6 +29,7 @@ import { useEffect, useEffectEvent, useImperativeHandle, + useLayoutEffect, useRef, useState, } from "react"; @@ -39,6 +40,7 @@ import { Button } from "~/components/ui/button"; import { Input } from "~/components/ui/input"; import { toastManager } from "~/components/ui/toast"; import { cn } from "~/lib/utils"; +import { setWheelScroller } from "~/motion/gesture"; import { refreshPreviewStreamAccess, usePreviewStreamAccess } from "~/state/previewStream"; import { observeResize } from "~/lib/observeResize"; @@ -51,7 +53,33 @@ export interface ServerBrowserHandle { readonly canvas: () => HTMLCanvasElement | null; } +let handoffFrame: { key: string; canvas: HTMLCanvasElement } | null = null; + +/** Carry the displayed frame across a panel/mini-player handoff until its new stream paints. */ +export function handoffServerBrowserFrame( + environmentId: EnvironmentId, + threadId: string, + tabId: string, + source: HTMLCanvasElement | null, +): void { + handoffFrame = null; + if (!source || source.width < 1 || source.height < 1) return; + const canvas = document.createElement("canvas"); + canvas.width = source.width; + canvas.height = source.height; + const context = canvas.getContext("2d"); + if (!context) return; + context.drawImage(source, 0, 0); + const frame = { key: JSON.stringify([environmentId, threadId, tabId]), canvas }; + handoffFrame = frame; + window.setTimeout(() => { + if (handoffFrame === frame) handoffFrame = null; + }, 1000); +} + const RESIZE_THROTTLE_MS = 150; +// How long a page this viewer resized may take to send a frame at its new size. +const RESIZE_HOLD_MS = 1_000; // Reuse nearby probes when a slow link would return the tap's answer after it ends. const PROBE_REUSE_PX = 24; const PROBE_REUSE_MS = 10_000; @@ -176,6 +204,7 @@ export function ServerBrowserSurface(props: { const clientRef = useRef(null); const viewportRef = useRef(null); const sizeRef = useRef<{ width: number; height: number } | null>(null); + const placementRef = useRef(null); const hasFrameRef = useRef(false); const controlRef = useRef(null); const [control, setControl] = useState(null); @@ -215,6 +244,28 @@ export function ServerBrowserSurface(props: { const probeRef = useRef(null); const lastProbeRef = useRef<{ x: number; y: number; editable: boolean; at: number } | null>(null); const firstFrame = useEffectEvent(() => onFirstFrame?.()); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Paint the handoff frame before the destination becomes visible while its stream reconnects. + useLayoutEffect(() => { + const canvas = canvasRef.current; + if ( + !visible || + pdfUrl || + !canvas || + handoffFrame?.key !== JSON.stringify([environmentId, threadId, tabId]) + ) + return; + const seed = handoffFrame.canvas; + handoffFrame = null; + const context = canvas.getContext("2d"); + if (!context) return; + canvas.width = seed.width; + canvas.height = seed.height; + context.drawImage(seed, 0, 0); + if (!hasFrameRef.current) { + hasFrameRef.current = true; + firstFrame(); + } + }, [environmentId, threadId, tabId, visible, pdfUrl]); const viewportChanged = useEffectEvent((viewport: PreviewStreamViewport) => onViewport?.(viewport), ); @@ -354,7 +405,17 @@ export function ServerBrowserSurface(props: { if (capTimer !== null) clearTimeout(capTimer); capTimer = setTimeout(growCap, previous === null ? 0 : RESIZE_THROTTLE_MS * 2); }; - const stopObserving = observeResize(element, () => { + const stopObserving = observeResize(element, (entries) => { + const entry = entries.at(-1); + // A page that follows this viewer's size is about to be resized. + const resizing = followSize && controlRef.current?.controller === "you"; + if (entry) { + placementRef.current?.resized( + entry.contentRect.width, + entry.contentRect.height, + resizing ? RESIZE_THROTTLE_MS + RESIZE_HOLD_MS : 0, + ); + } if (timer !== null) return; timer = setTimeout(measure, sizeRef.current === null ? 0 : RESIZE_THROTTLE_MS); }); @@ -370,7 +431,10 @@ export function ServerBrowserSurface(props: { const canvas = canvasRef.current; if (!visible || accessDenied || hostSetup || !access || !cap || !canvas) return; let refreshTimer: ReturnType | null = null; + const placement = createFramePlacement(canvas); + placementRef.current = placement; const painter = createPreviewFramePainter(canvas, () => { + placement.painted(sizeRef.current); if (hasFrameRef.current) return; hasFrameRef.current = true; firstFrame(); @@ -449,6 +513,8 @@ export function ServerBrowserSurface(props: { clientRef.current = client; return () => { painter.stop(); + placement.stop(); + if (placementRef.current === placement) placementRef.current = null; if (refreshTimer !== null) clearTimeout(refreshTimer); client.stop(); if (clientRef.current === client) clientRef.current = null; @@ -488,7 +554,13 @@ export function ServerBrowserSurface(props: { queueWheel(point, event.deltaX * unit, event.deltaY * unit, previewStreamModifiers(event)); }; canvas.addEventListener("wheel", onWheel, { passive: false }); - return () => canvas.removeEventListener("wheel", onWheel); + // The page takes the wheel while you control it, so a swipe over it + // scrolls the page instead of switching workspaces. + setWheelScroller(canvas, () => controlRef.current?.controller === "you"); + return () => { + canvas.removeEventListener("wheel", onWheel); + setWheelScroller(canvas, null); + }; }, [pagePoint, queueWheel]); // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- cancels the pending input animation frame when the component unmounts @@ -750,7 +822,7 @@ export function ServerBrowserSurface(props: { >
void; + /** The surface resized; `holdMs` is how long a resize of the page may take. */ + readonly resized: (width: number, height: number, holdMs: number) => void; + readonly stop: () => void; +} + +function createFramePlacement(canvas: HTMLCanvasElement): FramePlacement { + let drawn: Size | null = null; + // The surface's size as last seen; the frame shown may predate this placement. + let box: Size | null = canvas.parentElement + ? { width: canvas.parentElement.clientWidth, height: canvas.parentElement.clientHeight } + : null; + let holdUntil = 0; + let fallback: ReturnType | null = null; + // The painted frame's corner color, read at most once per frame. + let corner: string | null = null; + const setRoom = (color: string) => { + if (canvas.parentElement) canvas.parentElement.style.backgroundColor = color; + }; + const place = (next: Size | null) => { + if (next?.width !== drawn?.width || next?.height !== drawn?.height) { + canvas.style.width = next ? `${next.width}px` : ""; + canvas.style.height = next ? `${next.height}px` : ""; + } + drawn = next; + // Room the frame leaves uncovered takes its far corner's color. + const uncovered = + next !== null && + box !== null && + (box.width > next.width + 0.5 || box.height > next.height + 0.5); + setRoom(uncovered ? (corner ??= frameCornerColor(canvas)) : ""); + }; + const fits = (size: Size | null) => + size !== null && + canvas.width > 1 && + Math.abs(canvas.width / canvas.height - size.width / size.height) < 0.01; + const settle = () => { + holdUntil = 0; + if (fallback !== null) clearTimeout(fallback); + fallback = null; + }; + return { + painted: (size) => { + corner = null; + if (fits(size)) { + settle(); + place(size); + } else if (!drawn || performance.now() >= holdUntil) { + place(null); + } + }, + resized: (width, height, holdMs) => { + // A frame drawn before this placement was made, at the old size. + if (!drawn && fits(box)) place(box); + box = { width, height }; + if (!drawn) return; + if (holdMs <= 0) { + // Nothing will redraw the page at this size: fit the frame shown. + settle(); + place(null); + return; + } + holdUntil = performance.now() + holdMs; + if (fallback !== null) clearTimeout(fallback); + // The page never came back at the new size: fit what it last sent. + fallback = setTimeout(() => { + fallback = null; + if (drawn && !fits(box)) place(null); + }, holdMs); + place(drawn); + }, + stop: settle, + }; +} + +/** The color at the frame's bottom-right corner, where a page's own background usually shows. */ +function frameCornerColor(canvas: HTMLCanvasElement): string { + const pixel = canvas + .getContext("2d") + ?.getImageData(canvas.width - 1, canvas.height - 1, 1, 1).data; + return pixel ? `rgb(${pixel[0]} ${pixel[1]} ${pixel[2]})` : ""; +} diff --git a/apps/web/src/browser/browserSurfaceStore.ts b/apps/web/src/browser/browserSurfaceStore.ts index a49154ed8def4..9b47536244f9f 100644 --- a/apps/web/src/browser/browserSurfaceStore.ts +++ b/apps/web/src/browser/browserSurfaceStore.ts @@ -53,6 +53,8 @@ export interface BrowserSurfaceLease { cornerRadius?: number, zIndex?: number, ) => boolean; + /** Moves the webview to where the slot shows now; false once another slot owns the tab. */ + readonly place: (placement: BrowserSurfacePlacement) => boolean; readonly release: () => void; } @@ -222,10 +224,77 @@ export function acquireBrowserSurface( useBrowserSurfaceStore.getState().present(tabId, owner, rect, visible, cornerRadius, zIndex); return true; }, + place: (placement) => { + if (released) return false; + if (useBrowserSurfaceStore.getState().byTabId[tabId]?.owner !== owner) return false; + place(tabId, placement); + return true; + }, release: () => { if (released) return; released = true; + if (useBrowserSurfaceStore.getState().byTabId[tabId]?.owner === owner) place(tabId, null); useBrowserSurfaceStore.getState().release(tabId, owner); }, }; } + +/** Placement changes every animation frame, so apply it directly to the webview wrapper instead of + * rerendering React. */ +export interface BrowserSurfacePlacement { + readonly x: number; + readonly y: number; + readonly scale: number; + /** A CSS `clip-path` in the wrapper's own pixels; empty when nothing is cut. */ + readonly clipPath: string; + readonly interactive: boolean; +} + +const placements = new Map(); +const wrappers = new Map(); +/** What each wrapper was last given; the style reads back normalized. */ +const written = new WeakMap(); + +function writePlacement(element: HTMLElement, placement: BrowserSurfacePlacement | null): void { + const last = written.get(element) ?? null; + if (last === placement) return; + written.set(element, placement); + const transform = placement + ? `translate(${placement.x}px, ${placement.y}px) scale(${placement.scale})` + : ""; + const lastTransform = last ? `translate(${last.x}px, ${last.y}px) scale(${last.scale})` : ""; + if (transform !== lastTransform) element.style.transform = transform; + const clipPath = placement?.clipPath ?? ""; + if (clipPath !== (last?.clipPath ?? "")) element.style.clipPath = clipPath; + if (placement && placement.interactive !== last?.interactive) { + element.style.pointerEvents = placement.interactive ? "auto" : "none"; + } +} + +/** + * The webview's wrapper for a tab, kept by `HostedBrowserWebview`, which + * calls this after every render: an active wrapper takes the tab's latest + * placement, an inactive one drops it. + */ +export function syncBrowserSurfaceWrapper( + tabId: string, + element: HTMLElement | null, + active: boolean, +): void { + if (!element) { + wrappers.delete(tabId); + return; + } + wrappers.set(tabId, element); + // React may have restyled it (pointer events): write it all again. + written.delete(element); + writePlacement(element, active ? (placements.get(tabId) ?? null) : null); +} + +function place(tabId: string, placement: BrowserSurfacePlacement | null): void { + if (placement) placements.set(tabId, placement); + else placements.delete(tabId); + const element = wrappers.get(tabId); + const current = useBrowserSurfaceStore.getState().byTabId[tabId]; + if (element) writePlacement(element, current?.visible ? placement : null); +} diff --git a/apps/web/src/browser/browserSurfaceTracking.ts b/apps/web/src/browser/browserSurfaceTracking.ts new file mode 100644 index 0000000000000..173541d099771 --- /dev/null +++ b/apps/web/src/browser/browserSurfaceTracking.ts @@ -0,0 +1,105 @@ +/** Compositor transforms emit no layout events. Remeasure browser slots while motion runs, then stop + * after several resting frames. */ + +/** Where a frame belongs in a burst of movement. */ +export type BrowserSurfaceSlotPhase = "start" | "frame" | "settle"; + +/** Re-measures one slot; returns whether its placement changed. */ +type SlotUpdate = (phase: BrowserSurfaceSlotPhase) => boolean; + +/** Frames without movement or a running animation before a burst settles. */ +const REST_FRAMES = 6; +/** A transition of one of these may move or resize a slot. */ +const MOVING_PROPERTY = + /transform|translate|scale|width|height|left|right|top|bottom|inset|margin|flex/; + +const slots = new Map(); +let frame: number | null = null; +let quietFrames = 0; +let starting = false; + +function underAnimation(): boolean { + for (const animation of document.getAnimations()) { + if (animation.playState !== "running") continue; + const target = (animation.effect as KeyframeEffect | null)?.target; + if (!(target instanceof Element)) continue; + for (const element of slots.keys()) { + if (target.contains(element)) return true; + } + } + return false; +} + +function tick() { + frame = null; + const phase = starting ? "start" : "frame"; + starting = false; + let moved = false; + for (const update of slots.values()) { + if (update(phase)) moved = true; + } + quietFrames = moved || underAnimation() ? 0 : quietFrames + 1; + if (quietFrames < REST_FRAMES) { + frame = window.requestAnimationFrame(tick); + return; + } + for (const update of slots.values()) update("settle"); +} + +/** + * Something may have moved the slots: follow them each frame until they + * rest. `now` also places them at once, from a layout effect, so the frame + * a layout change paints already shows its webviews where they belong. + */ +export function kickBrowserSurfaceTracking(options?: { readonly now?: boolean }): void { + if (slots.size === 0) return; + quietFrames = 0; + if (options?.now) { + for (const update of slots.values()) update("start"); + } + if (frame === null) { + starting = true; + frame = window.requestAnimationFrame(tick); + } +} + +const onMove = () => kickBrowserSurfaceTracking(); +const onPointerMove = (event: PointerEvent) => { + // A drag (a column's edge, the overview's camera) moves panes; hovering does not. + if (event.buttons !== 0) kickBrowserSurfaceTracking(); +}; +const onTransitionRun = (event: TransitionEvent) => { + if (MOVING_PROPERTY.test(event.propertyName)) kickBrowserSurfaceTracking(); +}; +const listenerOptions = { capture: true, passive: true } as const; + +function listen(add: boolean) { + if (add) { + window.addEventListener("scroll", onMove, listenerOptions); + window.addEventListener("wheel", onMove, listenerOptions); + window.addEventListener("resize", onMove, listenerOptions); + window.addEventListener("pointermove", onPointerMove, listenerOptions); + window.addEventListener("transitionrun", onTransitionRun, listenerOptions); + return; + } + window.removeEventListener("scroll", onMove, listenerOptions); + window.removeEventListener("wheel", onMove, listenerOptions); + window.removeEventListener("resize", onMove, listenerOptions); + window.removeEventListener("pointermove", onPointerMove, listenerOptions); + window.removeEventListener("transitionrun", onTransitionRun, listenerOptions); +} + +/** Follow a slot while it may move; returns the way to stop. */ +export function trackBrowserSurfaceSlot(element: HTMLElement, update: SlotUpdate): () => void { + if (slots.size === 0) listen(true); + slots.set(element, update); + kickBrowserSurfaceTracking(); + return () => { + if (slots.get(element) !== update) return; + slots.delete(element); + if (slots.size > 0) return; + listen(false); + if (frame !== null) window.cancelAnimationFrame(frame); + frame = null; + }; +} diff --git a/apps/web/src/browser/hostedBrowserWebviewStyle.ts b/apps/web/src/browser/hostedBrowserWebviewStyle.ts index f37c222b08e9c..a1742dcb6b69d 100644 --- a/apps/web/src/browser/hostedBrowserWebviewStyle.ts +++ b/apps/web/src/browser/hostedBrowserWebviewStyle.ts @@ -8,6 +8,8 @@ export interface HostedBrowserWebviewSize { export interface HostedBrowserWebviewWrapperStyle { readonly left: number; readonly top: number; + /** An active wrapper sits at the origin; its slot's placement moves it (browserSurfaceStore). */ + readonly transformOrigin?: string; readonly width: number; readonly height: number; readonly zIndex: number; @@ -38,8 +40,9 @@ export function resolveHostedBrowserWebviewWrapperStyle(input: { } = input; if (active && rect) { return { - left: rect.x, - top: rect.y, + left: 0, + top: 0, + transformOrigin: "0 0", width: rect.width, height: rect.height, zIndex, diff --git a/apps/web/src/closedViewStore.ts b/apps/web/src/closedViewStore.ts index b5db7bd741bef..487ef5babce01 100644 --- a/apps/web/src/closedViewStore.ts +++ b/apps/web/src/closedViewStore.ts @@ -12,8 +12,10 @@ import { createJSONStorage, persist } from "zustand/middleware"; import { resolveStorage } from "./lib/storage"; import { randomUUID } from "./lib/utils"; import { type RightPanelSurface } from "./rightPanelStore"; +import type { DraftId } from "./composerDraftStore"; export type ClosedView = + | { kind: "chat"; threadRef: ScopedThreadRef; draftId?: DraftId; paneId: string; index: number } | { kind: "panel-tab"; threadRef: ScopedThreadRef; @@ -38,6 +40,8 @@ const sameTarget = (entry: ClosedViewEntry, view: ClosedView): boolean => { return false; } switch (entry.kind) { + case "chat": + return view.kind === "chat" && entry.draftId === view.draftId; case "panel-tab": return view.kind === "panel-tab" && entry.surface.id === view.surface.id; case "browser": @@ -55,6 +59,14 @@ const isAttachment = Schema.is(ChatFileAttachment); const isClosedViewEntry = (entry: unknown): entry is ClosedViewEntry => { const view = entry as ClosedViewEntry | null; if (!view || typeof view.id !== "string" || !isThreadRef(view.threadRef)) return false; + if (view.kind === "chat") { + return ( + typeof view.paneId === "string" && + Number.isSafeInteger(view.index) && + view.index >= 0 && + (view.draftId === undefined || typeof view.draftId === "string") + ); + } if (view.kind === "browser") return isSnapshot(view.snapshot); if (view.kind !== "panel-tab") return false; const surface = view.surface; diff --git a/apps/web/src/cloud/relayClientInstallDialog.test.ts b/apps/web/src/cloud/relayClientInstallDialog.test.ts index 2f505df24fb5f..4d5da5dde08ce 100644 --- a/apps/web/src/cloud/relayClientInstallDialog.test.ts +++ b/apps/web/src/cloud/relayClientInstallDialog.test.ts @@ -1,7 +1,9 @@ -import { beforeEach, describe, expect, it } from "vite-plus/test"; +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; import { RelayClientInstallConfirmationConflictError, + cancelRelayClientInstall, + onRelayClientInstallCancel, reportRelayClientInstallProgress, requestRelayClientInstallConfirmation, resetRelayClientInstallDialogForTests, @@ -36,4 +38,17 @@ describe("relay client install dialog coordinator", () => { "Cannot confirm relay client installation 2026.11.0; installation 2026.10.0 has dialog status installing.", ); }); + + it.each(["before", "after"])("delivers cancellation %s the install subscribes", async (when) => { + const confirmation = requestRelayClientInstallConfirmation("2026.10.0"); + respondToRelayClientInstallConfirmation(true); + await expect(confirmation).resolves.toBe(true); + if (when === "before") cancelRelayClientInstall(); + const cancelled = vi.fn(); + const unsubscribe = onRelayClientInstallCancel(cancelled); + if (when === "after") cancelRelayClientInstall(); + cancelRelayClientInstall(); + expect(cancelled).toHaveBeenCalledTimes(1); + unsubscribe(); + }); }); diff --git a/apps/web/src/cloud/relayClientInstallDialog.ts b/apps/web/src/cloud/relayClientInstallDialog.ts index 587e20e513f4e..793e5a4e537be 100644 --- a/apps/web/src/cloud/relayClientInstallDialog.ts +++ b/apps/web/src/cloud/relayClientInstallDialog.ts @@ -127,6 +127,7 @@ export function cancelRelayClientInstall(): void { export function onRelayClientInstallCancel(listener: () => void): () => void { cancelListeners.add(listener); + if (state.status === "installing" && state.cancelling) listener(); return () => { cancelListeners.delete(listener); }; diff --git a/apps/web/src/commandPaletteBus.ts b/apps/web/src/commandPaletteBus.ts index 7e0e672110e6c..98d92a1f81f6b 100644 --- a/apps/web/src/commandPaletteBus.ts +++ b/apps/web/src/commandPaletteBus.ts @@ -38,7 +38,8 @@ export function openCommandPalette(detail: CommandPaletteOpenDetail = {}): void } } -/** True while the palette popup is on screen, including its closing animation. */ +/** True while the palette popup is open. */ export function isCommandPaletteOpen(): boolean { - return openLayerMatches("[data-command-palette]"); + // A palette playing its close animation is closed: shortcuts work again. + return openLayerMatches("[data-command-palette]:not([data-closed])"); } diff --git a/apps/web/src/commandPaletteStore.ts b/apps/web/src/commandPaletteStore.ts index 3fdd07383fd24..8fa9391008b52 100644 --- a/apps/web/src/commandPaletteStore.ts +++ b/apps/web/src/commandPaletteStore.ts @@ -1,4 +1,5 @@ import { useEffect } from "react"; +import { flushSync } from "react-dom"; import { create } from "zustand"; import { @@ -19,6 +20,11 @@ export const useCommandPaletteStore = create(() => INITIA let returnFocus: { element: HTMLElement | null; href: string; focusRequest: number } | null = null; +export const commandPaletteFocus = { + openerRef: { current: null as HTMLElement | null }, + dismissedRef: { current: false }, +}; + export function dispatchCommandPalette(action: CommandPaletteUiAction): void { const state = useCommandPaletteStore.getState(); const next = reduceCommandPaletteUiState(state, action); @@ -30,7 +36,19 @@ export function dispatchCommandPalette(action: CommandPaletteUiAction): void { focusRequest: getWorkspaceFocusRequestVersion(), }; } - useCommandPaletteStore.setState(next, true); + commandPaletteFocus.dismissedRef.current = false; + const active = typeof document === "undefined" ? null : document.activeElement; + if ( + typeof HTMLElement !== "undefined" && + active instanceof HTMLElement && + !active.closest("[data-command-palette]") + ) { + commandPaletteFocus.openerRef.current = active === document.body ? null : active; + } + // Commit and focus the input before the next trusted key event. + flushSync(() => { + useCommandPaletteStore.setState(next, true); + }); } /** @@ -48,6 +66,15 @@ export function getCommandPaletteReturnFocus(): HTMLElement | boolean { return element?.isConnected && !element.matches(":disabled") ? element : true; } +export function leaveCommandPaletteOverlay(): void { + if (useCommandPaletteStore.getState().parentSearch) { + dispatchCommandPalette({ _tag: "BackToCommand" }); + } else { + dispatchCommandPalette({ _tag: "SetOpen", open: false }); + commandPaletteFocus.dismissedRef.current = true; + } +} + // The palette is only mounted on app-shell routes (not /pair, sign-in or // landings), so its shortcuts must stay inert elsewhere. let mountedPalettes = 0; diff --git a/apps/web/src/components/AppRouteModal.browser.test.tsx b/apps/web/src/components/AppRouteModal.browser.test.tsx index e701f8670abce..41c6993e473d7 100644 --- a/apps/web/src/components/AppRouteModal.browser.test.tsx +++ b/apps/web/src/components/AppRouteModal.browser.test.tsx @@ -1,9 +1,10 @@ +import { useState } from "react"; import { describe, expect, it, vi } from "vite-plus/test"; import { page, userEvent } from "vite-plus/test/browser"; import { render } from "../../test/render"; import { useCommitOnBlur } from "../hooks/useCommitOnBlur"; -import { AppRouteModal } from "./AppRouteModal"; +import { AppRouteModal, AppRouteModalClose } from "./AppRouteModal"; function CommitOnBlurField({ onCommit }: { onCommit: (next: string) => void }) { return ; @@ -44,3 +45,31 @@ describe("AppRouteModal", () => { .toHaveAttribute("data-open"); }); }); + +function ModalLauncher() { + const [open, setOpen] = useState(false); + return ( + <> + + {open ? ( + + {}} /> + + + ) : null} + + ); +} + +it.each(["Escape", "Close"])("returns focus to the launcher on %s", async (method) => { + await using screen = await render(); + const launcher = screen.getByRole("button", { name: "Settings launcher" }); + await launcher.click(); + await expect.element(page.getByRole("dialog", { name: "Settings" })).toBeVisible(); + if (method === "Escape") await userEvent.keyboard("{Escape}"); + else + ( + page.getByRole("button", { name: "Close", exact: true }).element() as HTMLButtonElement + ).click(); + await expect.element(launcher).toHaveFocus(); +}); diff --git a/apps/web/src/components/AppRouteModal.tsx b/apps/web/src/components/AppRouteModal.tsx index aff192a1cdf68..c15e683c55111 100644 --- a/apps/web/src/components/AppRouteModal.tsx +++ b/apps/web/src/components/AppRouteModal.tsx @@ -24,7 +24,6 @@ export function AppRouteModal({ // Text selection that ends on the backdrop is not a dismissal. const backdropPressRef = useRef(false); // Focus the surface, not its first control: no stray focus ring, no phone keyboard. - // On close, leave focus alone; returning it to the opener paints a focus ring. const popupRef = useRef(null); // Clear the native window controls (macOS traffic lights, the Windows and Linux caption // buttons). Below sm the modal fills the window and leaves that strip empty; up to lg its @@ -66,7 +65,6 @@ export function AppRouteModal({ bottomStickOnMobile={false} ref={popupRef} initialFocus={popupRef} - finalFocus={false} aria-label={label} data-app-modal="" viewportProps={{ diff --git a/apps/web/src/components/AppSidebarLayout.browser.test.tsx b/apps/web/src/components/AppSidebarLayout.browser.test.tsx index c97ea5e6412aa..c02551430347d 100644 --- a/apps/web/src/components/AppSidebarLayout.browser.test.tsx +++ b/apps/web/src/components/AppSidebarLayout.browser.test.tsx @@ -1,5 +1,6 @@ import { page, userEvent } from "vite-plus/test/browser"; import { describe, expect, it, vi } from "vite-plus/test"; +import type { ReactNode } from "react"; import { render } from "../../test/render"; @@ -14,6 +15,10 @@ vi.mock(import("../state/server"), async (original) => { }); // The thread list needs live environments; the shortcut lives in the shell around it. vi.mock(import("./Sidebar"), () => ({ default: (() => null) as never })); +// Pane routing is outside this shell shortcut test. +vi.mock(import("../layout/WorkspacePlane"), () => ({ + WorkspacePlane: ({ children }: { children: ReactNode }) => <>{children}, +})); import { AppSidebarLayout } from "./AppSidebarLayout"; diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index af780b43e8a65..d386a7886f288 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -9,7 +9,7 @@ import { type CSSProperties, type ReactNode, } from "react"; -import { useLocation, useRouter, type AnyRouter } from "@tanstack/react-router"; +import { useLocation, useParams, useRouter, type AnyRouter } from "@tanstack/react-router"; import { ArrowLeftIcon, ArrowRightIcon } from "lucide-react"; import { moveWorkspaceFocus, workspaceAreaProps } from "../lib/workspaceFocus"; @@ -53,8 +53,15 @@ import { BackgroundThreadAnnouncer } from "./BackgroundThreadAnnouncer"; import { HistorySwipeNavigation } from "./HistorySwipeNavigation"; import ThreadSidebar from "./Sidebar"; import { isAppModalPage } from "./sidebar/mainAppLocation"; -import { SidebarChromeHeader } from "./sidebar/SidebarChrome"; +import { resolveThreadRouteTarget } from "../threadRoutes"; +import { selectFocusedTab } from "../layout/layoutModel"; +import { useLayoutStore } from "../layout/layoutStore"; +import { isLayoutDragActive } from "../layout/layoutDrag"; +import { isModalDialogOpen } from "../lib/modalDialog"; +import { SidebarChromeBrand, SidebarChromeHeader } from "./sidebar/SidebarChrome"; import type { SidebarRouteReveal } from "./sidebar/routeReveal"; +import { WorkspacePlane } from "../layout/WorkspacePlane"; +import { overviewZoomTransition, useOverviewPhase } from "../layout/Overview"; import { ChromeChannelArt, useSidebarStageBackdropVariant } from "./SidebarStageBackdrop"; import { clampThreadSidebarWidth, @@ -222,7 +229,15 @@ function registerPullRequestsEscapeCommand(router: AnyRouter): () => void { { observe: (event, consumed) => { if (consumed || event.repeat || event.isComposing || event.key !== "Escape") return; - if (router.state.location.pathname !== "/pull-requests") return; + if ( + router.state.location.pathname !== "/pull-requests" || + isCommandPaletteOpen() || + isModalDialogOpen() || + isLayoutDragActive() + ) + return; + if (selectFocusedTab(useLayoutStore.getState().layout)?.content.kind !== "pull-requests") + return; event.preventDefault(); const activeElement = document.activeElement; if (activeElement instanceof HTMLElement) activeElement.blur(); @@ -320,7 +335,7 @@ function registerAppShellCommands(router: AnyRouter, shellElement: Element): () registerPullRequestsEscapeCommand(router), registerMenuAction("open-settings", () => { if (!/^\/settings(\/|$)/.test(router.state.location.pathname)) - void router.navigate({ to: "/settings" }); + void router.navigate({ to: "/settings", search: { pane: undefined } }); }), ]; return () => { @@ -341,11 +356,16 @@ function SidebarControl({ showSidebar }: { readonly showSidebar: boolean }) { context: { usagePageOpen }, }); const onStageBackdrop = isSidebarVisible && stageBackdropVariant !== null; + const overviewPhase = useOverviewPhase(); + const overviewShown = overviewPhase === "opening" || overviewPhase === "open"; // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- window keydown listener that toggles the sidebar on its shortcut useEffect(() => { // A hidden sidebar would still save the toggle, so the shortcut does nothing without one. if (!showSidebar) return; + const toggleOutsideOverview = () => { + if (!useLayoutStore.getState().overviewOpen) toggleSidebar(); + }; const onKeyDown = (event: KeyboardEvent) => { if (event.defaultPrevented) return; if ( @@ -354,13 +374,19 @@ function SidebarControl({ showSidebar }: { readonly showSidebar: boolean }) { ) { return; } + const composer = + event.target instanceof HTMLElement + ? event.target.closest('[data-composer-rich-text="true"]') + : null; + const selection = window.getSelection(); if ( - isRichTextBoldShortcut(event, window.getSelection()) && - event.target instanceof HTMLElement && - event.target.closest('[data-composer-rich-text="true"]') + isRichTextBoldShortcut(event, selection) && + composer && + selection && + composer.contains(selection.anchorNode) ) { - // The rich-text composer claims Mod+B only to bold selected text; - // the toggle stays available everywhere else. + // The rich-text composer claims Mod+B to bold text selected in it; + // with just a caret, and everywhere else, it toggles the sidebar. return; } const command = resolveShortcutCommand(event, keybindings, { @@ -375,12 +401,12 @@ function SidebarControl({ showSidebar }: { readonly showSidebar: boolean }) { event.preventDefault(); event.stopPropagation(); - toggleSidebar(); + toggleOutsideOverview(); }; window.addEventListener("keydown", onKeyDown, true); const unsubscribe = window.desktopBridge?.onMenuAction((action) => { - if (action === "sidebar.toggle") toggleSidebar(); + if (action === "sidebar.toggle") toggleOutsideOverview(); }); return () => { window.removeEventListener("keydown", onKeyDown, true); @@ -407,23 +433,54 @@ function SidebarControl({ showSidebar }: { readonly showSidebar: boolean }) { data-sidebar-control="" > {showSidebar ? ( - - - } - /> - - Toggle main sidebar{shortcutLabel ? ` (${shortcutLabel})` : ""} - - +
+ + + } + /> + + Toggle main sidebar{shortcutLabel ? ` (${shortcutLabel})` : ""} + + +
+ ) : null} + {showSidebar ? ( +
+
+ +
+
) : null} {/* Phones keep the header for the page; the system back gesture covers them. */}
@@ -505,6 +562,15 @@ export function AppSidebarLayout({ usePanelAnimationSettings(); const pathname = useLocation({ select: (location) => location.pathname }); const panelAnimationsSuppressed = usePanelNavigationSuppression(pathname); + // Each chat suppresses its own panels (SurfaceHost). React propagates a context change past + // nested providers, so a flip here on a chat switch would re-render every mounted chat twice. + const onChatRoute = useParams({ + strict: false, + select: (params) => resolveThreadRouteTarget(params) !== null, + }); + const pagePanelAnimationsSuppressed = usePanelNavigationSuppression( + onChatRoute ? "chat" : pathname, + ); const routePanelAnimationsActive = panelAnimationsActive && !panelAnimationsSuppressed; const isOnDesignSystem = DesignSystemSidebarNav !== null && @@ -537,6 +603,10 @@ export function AppSidebarLayout({ }); const environmentIdentificationMode = useEnvironmentIdentificationMode(); const chromeArtVariant = useSidebarStageBackdropVariant(environmentIdentificationMode); + const overviewPhase = useOverviewPhase(); + const overviewStaged = overviewPhase !== "closed" && !isMobileLayout; + const overviewShown = overviewPhase === "opening" || overviewPhase === "open"; + const overviewTransition = overviewStaged ? overviewZoomTransition() : {}; const hideWindowControlsWhenSidebarCollapsed = useHideWindowControlsWhenSidebarCollapsed(); const isWindowFullscreen = useSyncExternalStore( subscribeToWindowFullscreen, @@ -561,6 +631,8 @@ export function AppSidebarLayout({ : {} ) as CSSProperties; const sidebarProviderStyle = { + "--overview-sidebar-duration": overviewTransition.transitionDuration, + "--overview-sidebar-easing": overviewTransition.transitionTimingFunction, "--sidebar-width": `${clampThreadSidebarWidth(sidebarWidth, sidebarMinimumWidth, sidebarMaximumWidth)}px`, "--panel-animation-duration": `${panelAnimationDurationMs}ms`, ...windowControlsInsetStyle, @@ -579,66 +651,85 @@ export function AppSidebarLayout({ }, [isMacosDesktop, windowControlsHidden]); return ( - - +
[data-slot=sidebar-inner]]:transition-opacity md:[&_[data-app-sidebar][data-overview-zoom]>[data-slot=sidebar-inner]]:duration-(--overview-sidebar-duration) md:[&_[data-app-sidebar][data-overview-zoom]>[data-slot=sidebar-inner]]:ease-(--overview-sidebar-easing)", + "md:[&_[data-app-sidebar][data-overview-zoom=opening]>[data-slot=sidebar-inner]]:opacity-0 md:[&_[data-app-sidebar][data-overview-zoom=open]>[data-slot=sidebar-inner]]:opacity-0", + )} > - {showSidebar ? ( - <> - - {chromeArtVariant ? : null} - - nextWidth <= currentWidth || - wrapper.clientWidth - nextWidth >= THREAD_MAIN_CONTENT_MIN_WIDTH, - storageKey: THREAD_SIDEBAR_WIDTH_STORAGE_KEY, - onResize: setSidebarWidth, - }} - style={windowControlsInsetStyle} - > - {isOnDesignSystem && DesignSystemSidebarNav ? ( - <> - - - - - - ) : ( - - )} - - - - ) : null} - {children} - - {isElectron ? ( - // The chrome strip above the inset pane moves the window like the - // header rows on either side of it. -
- ) : null} - - {isElectron ? : null} - + + {showSidebar ? ( + <> + + {chromeArtVariant ? : null} + + nextWidth <= currentWidth || + wrapper.clientWidth - nextWidth >= THREAD_MAIN_CONTENT_MIN_WIDTH, + storageKey: THREAD_SIDEBAR_WIDTH_STORAGE_KEY, + onResize: setSidebarWidth, + }} + className={cn(overviewStaged && "z-35")} + data-overview-zoom={overviewStaged ? overviewPhase : undefined} + inert={overviewStaged && overviewShown} + style={ + overviewStaged + ? { + ...windowControlsInsetStyle, + ...overviewTransition, + } + : windowControlsInsetStyle + } + > + {isOnDesignSystem && DesignSystemSidebarNav ? ( + <> + + + + + + ) : ( + + )} + + + + ) : null} + {children} + + {isElectron ? ( + // The chrome strip above the inset pane moves the window like the + // header rows on either side of it. +
+ ) : null} + + {isElectron ? : null} + +
); } diff --git a/apps/web/src/components/BranchToolbar.logic.ts b/apps/web/src/components/BranchToolbar.logic.ts index 6b4d5bc58a14b..8493656b3722b 100644 --- a/apps/web/src/components/BranchToolbar.logic.ts +++ b/apps/web/src/components/BranchToolbar.logic.ts @@ -83,18 +83,20 @@ const CONTEXT_STRIP_COMPACT_EXPAND_HYSTERESIS_PX = 16; export type ContextStripCompaction = "none" | "labels" | "branch"; +export function readContextStripCompaction(value: string | undefined): ContextStripCompaction { + return value === "labels" || value === "branch" ? value : "none"; +} + export function resolveContextStripCompaction(input: { current: ContextStripCompaction; neededWidth: number; + labelsNeededWidth: number; availableWidth: number; - labelsSavedWidth: number; }): ContextStripCompaction { const fits = (width: number, expanding: boolean) => width <= input.availableWidth - (expanding ? CONTEXT_STRIP_COMPACT_EXPAND_HYSTERESIS_PX : 0); if (fits(input.neededWidth, input.current !== "none")) return "none"; - if (fits(input.neededWidth - input.labelsSavedWidth, input.current === "branch")) { - return "labels"; - } + if (fits(input.labelsNeededWidth, input.current === "branch")) return "labels"; return "branch"; } diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index 5dde9fb10e89c..3d86862c3a2ee 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -3,6 +3,7 @@ import { formatFilePathForClipboard } from "@t3tools/client-runtime/media-action import { readLocalApi } from "../localApi"; import { stackedThreadToast, toastManager } from "./ui/toast"; import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { isScratchProject } from "@t3tools/client-runtime/state/projects"; import type { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts"; import { ChevronDownIcon, @@ -27,11 +28,12 @@ import { flushSync } from "react-dom"; import { useComposerDraftStore, type DraftId } from "../composerDraftStore"; import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; -import { useProject, useThreadShellsForProjectRefs } from "../state/entities"; +import { useProject, useServerConfigs, useThreadShellsForProjectRefs } from "../state/entities"; import { ADD_TO_MACHINE_LABEL_CLASS, type EnvMode, type EnvironmentOption, + readContextStripCompaction, resolveContextStripCompaction, type ContextStripCompaction, resolveCurrentWorkspaceLabel, @@ -79,21 +81,25 @@ import { MiddleTruncate } from "./ui/middle-truncate"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; import { ComposerSurface } from "./chat/ComposerSurface"; import { measureRestingComposerControls } from "./chat/restingComposerControlsMeasurement"; +import { contextStripGroupWidth } from "./contextStripMeasurement"; import { resolveRestingComposerControlsMinimumWidth, - resolveRestingComposerControlsNaturalWidth, + resolveRestingComposerControlsNarrowestWidth, } from "./composerFooterLayout"; +import { NoProjectChip } from "./chat/NoProjectChip"; import { useComposerMenuProps } from "./chat/composerEventScope"; import { cn } from "~/lib/utils"; import { observeResize } from "~/lib/observeResize"; export interface BranchToolbarHandle { openBranchPicker: () => void; + focusBranchPicker: () => void; usePreviousWorktree: () => void; } interface BranchToolbarProps { layout?: "composer" | "panel"; + onOpenWorkspaceDetails?: (() => void) | undefined; panelSection?: "all" | "workspace" | "branch"; forceNewWorktree?: boolean; ref?: Ref; @@ -124,6 +130,8 @@ interface BranchToolbarProps { environmentUnavailable?: boolean; /** Panel only: where the branch picker renders the thread's PR rows. */ panelPrRowsContainer?: HTMLElement | null; + /** Panel-only actions that belong to the environment, such as opening its editor. */ + panelEnvironmentActions?: ReactNode; } interface WorkspaceMenuProps { @@ -136,40 +144,30 @@ interface WorkspaceMenuProps { children: ReactNode; onContextMenu: (event: ReactMouseEvent) => void; }; - /** List Run on here because no machine chip is shown, as on local threads. */ - showRunOn: boolean; forceNewWorktree: boolean; - autoEnvironmentLabel?: string | undefined; - onAutoEnvironment?: (() => void) | undefined; - envLocked: boolean; envModeLocked: boolean; environmentId: EnvironmentId; - availableEnvironments: readonly EnvironmentOption[] | undefined; - showEnvironmentPicker: boolean; - onEnvironmentChange: ((environmentId: EnvironmentId) => void) | undefined; effectiveEnvMode: EnvMode; activeWorktreePath: string | null; onEnvModeChange: (mode: EnvMode) => void; workspaceRoot: string; previousWorktree: PreviousWorktreeSeed | null; onUsePreviousWorktree: () => void; +} + +interface MachineMenuProps { + autoEnvironmentLabel?: string | undefined; + onAutoEnvironment?: (() => void) | undefined; + envLocked: boolean; + environmentId: EnvironmentId; + availableEnvironments: readonly EnvironmentOption[] | undefined; + showEnvironmentPicker: boolean; + onEnvironmentChange: ((environmentId: EnvironmentId) => void) | undefined; + environmentUnavailable: boolean; addTargets: readonly AddProjectTarget[]; onAddToMachine: (environmentId: EnvironmentId) => void; } -type MachineMenuProps = Pick< - WorkspaceMenuProps, - | "autoEnvironmentLabel" - | "onAutoEnvironment" - | "envLocked" - | "environmentId" - | "availableEnvironments" - | "showEnvironmentPicker" - | "onEnvironmentChange" - | "addTargets" - | "onAddToMachine" -> & { environmentUnavailable: boolean }; - /** "2h ago · 3 files changed" for the previous worktree row; the count is read while open. */ function PreviousWorktreeMeta({ environmentId, @@ -209,27 +207,17 @@ function PreviousWorktreeMeta({ /** The composer's workspace and branch chip. Machines have their own chip, `MachineMenu`. */ const WorkspaceMenu = memo(function WorkspaceMenu({ branch, - showRunOn, forceNewWorktree, - autoEnvironmentLabel, - onAutoEnvironment, - envLocked, envModeLocked, environmentId, - availableEnvironments, - showEnvironmentPicker, - onEnvironmentChange, effectiveEnvMode, activeWorktreePath, onEnvModeChange, workspaceRoot, previousWorktree, onUsePreviousWorktree, - addTargets, - onAddToMachine, }: WorkspaceMenuProps) { const composerFloatingLayerProps = useComposerMenuProps(); - const connectedEnvironmentIds = useConnectedEnvironmentIds(); const [workspaceOpen, setWorkspaceOpen] = useState(false); const popupRef = useRef(null); const open = branch?.open ?? workspaceOpen; @@ -243,10 +231,9 @@ const WorkspaceMenu = memo(function WorkspaceMenu({ : effectiveEnvMode === "worktree" ? resolveEnvModeLabel("worktree") : resolveCurrentWorkspaceLabel(activeWorktreePath); - const canPickEnvironment = (showEnvironmentPicker && !envLocked) || addTargets.length > 0; const icon = ; - if (envModeLocked && !(showRunOn && canPickEnvironment) && !branch) { + if (envModeLocked && !branch) { return ( } + data-pressed={open ? "" : undefined} className={branch ? "min-w-0 max-w-full" : envModeLocked ? "ps-2 pe-0.5" : "ps-2"} aria-label={ branch ? `Branch and workspace: ${branch.label}` : `Workspace: ${workspaceLabel}` } onContextMenu={branch?.onContextMenu} data-composer-context-control - data-composer-shortcut={[ - showRunOn && canPickEnvironment ? "composer.host" : "", - !envModeLocked ? "composer.workspace" : "", - ].join(" ")} + data-composer-shortcut={envModeLocked ? undefined : "composer.workspace"} /> } > @@ -304,7 +289,11 @@ const WorkspaceMenu = memo(function WorkspaceMenu({ {envModeLocked && !branch ? null : ( <> - {branch?.label ?? workspaceLabel} + {branch ? ( + + ) : ( + workspaceLabel + )} - {showRunOn && availableEnvironments && availableEnvironments.length > 0 ? ( - - Run on - - value === "auto" - ? onAutoEnvironment?.() - : onEnvironmentChange?.(value as EnvironmentId) - } - > - {availableEnvironments.map((env) => ( - - - - {env.label} - {env.isPrimary ? null : connectedEnvironmentIds.includes(env.environmentId) ? ( - Online - ) : ( - Offline - )} - - - ))} - {onAutoEnvironment && ( - { - if (autoEnvironmentLabel) onAutoEnvironment?.(); - }} - > - - - - )} - - {addTargets.length > 0 ? ( - - - - - Add to another machine - - - - {addTargets.map((target) => ( - onAddToMachine(target.environmentId)} - > - - - {target.label} - - - ))} - - - ) : null} - - ) : null} Workspace onEnvModeChange(value as EnvMode)} > - + {activeWorktreePath ? ( ) : ( @@ -433,7 +341,7 @@ const WorkspaceMenu = memo(function WorkspaceMenu({ - + {resolveEnvModeLabel("worktree")} @@ -529,7 +437,7 @@ const MachineMenu = memo(function MachineMenu({ render={ } className={cn( - "min-w-0 max-w-full", + "max-w-full shrink-0", environmentUnavailable && "text-warning-foreground", )} aria-label={`Run on: ${environmentLabel}`} @@ -700,6 +608,7 @@ function showWorkspaceContextMenu(event: ReactMouseEvent, workspacePath: string) interface RunContextSelectorProps { displayMode?: "toolbar" | "panel"; + panelEnvironmentActions?: ReactNode; workspaceRoot?: string; forceNewWorktree: boolean; autoEnvironmentLabel?: string | undefined; @@ -719,10 +628,16 @@ interface RunContextSelectorProps { onUsePreviousWorktree: () => void; addTargets: readonly AddProjectTarget[]; onAddToMachine: (environmentId: EnvironmentId) => void; + /** A scratch thread with no project: the row names the folder as "No project". */ + noProject?: boolean; + /** Panel only: the machine row lists Run on, the workspace row lists the workspace modes. */ + section?: "machine" | "workspace"; } const RunContextSelector = memo(function RunContextSelector({ displayMode = "toolbar", + section, + panelEnvironmentActions, workspaceRoot, forceNewWorktree, autoEnvironmentLabel, @@ -742,29 +657,47 @@ const RunContextSelector = memo(function RunContextSelector({ onUsePreviousWorktree, addTargets, onAddToMachine, + noProject = false, }: RunContextSelectorProps) { const composerFloatingLayerProps = useComposerMenuProps(); const activeEnvironment = availableEnvironments?.find((env) => env.environmentId === environmentId) ?? null; - const WorkspaceIcon = - effectiveEnvMode === "worktree" + const WorkspaceIcon = noProject + ? FolderIcon + : effectiveEnvMode === "worktree" ? FolderGit2Icon : activeWorktreePath ? FolderGitIcon : FolderIcon; - const workspaceLabel = forceNewWorktree - ? resolveEnvModeLabel("worktree") - : envModeLocked - ? resolveLockedWorkspaceLabel(activeWorktreePath, effectiveEnvMode) - : effectiveEnvMode === "worktree" - ? resolveEnvModeLabel("worktree") - : resolveCurrentWorkspaceLabel(activeWorktreePath); + const workspaceLabel = noProject + ? "No project" + : forceNewWorktree + ? resolveEnvModeLabel("worktree") + : envModeLocked + ? resolveLockedWorkspaceLabel(activeWorktreePath, effectiveEnvMode) + : effectiveEnvMode === "worktree" + ? resolveEnvModeLabel("worktree") + : resolveCurrentWorkspaceLabel(activeWorktreePath); const isPanel = displayMode === "panel"; - const isLocked = !isPanel && (envLocked || envModeLocked); + const isLocked = isPanel ? section === "workspace" && envModeLocked : envLocked || envModeLocked; const workspacePath = forceNewWorktree || (effectiveEnvMode === "worktree" && !activeWorktreePath) ? null : (activeWorktreePath ?? workspaceRoot); + const runOnEnvironments = + section !== "workspace" && + ((showEnvironmentPicker && onEnvironmentChange) || addTargets.length > 0) + ? availableEnvironments + : undefined; + const showWorkspaceGroup = section !== "machine"; + const showPathGroup = isPanel && workspacePath !== null && section !== "workspace"; + const environmentActions = isPanel && section !== "workspace" ? panelEnvironmentActions : null; + const runContextLabel = + section === "workspace" + ? `Workspace: ${workspaceLabel}` + : section === "machine" + ? `Run on: ${autoEnvironmentLabel ?? activeEnvironment?.label ?? "Run on"}` + : `Run context: ${[autoEnvironmentLabel ?? activeEnvironment?.label, workspaceLabel].filter(Boolean).join(", ")}`; const handleContextMenu = (event: ReactMouseEvent) => { if (isPanel && workspacePath) { showWorkspaceContextMenu(event, workspacePath); @@ -777,7 +710,7 @@ const RunContextSelector = memo(function RunContextSelector({ {isPanel - ? forceNewWorktree + ? forceNewWorktree && !noProject ? "Each model starts in its own worktree." : (workspacePath ?? workspaceLabel) : workspaceLabel} @@ -809,7 +742,9 @@ const RunContextSelector = memo(function RunContextSelector({ const triggerContent = ( <> {isPanel ? ( - autoEnvironmentLabel ? ( + section === "workspace" ? ( + + ) : autoEnvironmentLabel ? ( ) : ( - {autoEnvironmentLabel ?? - (isPanel || showEnvironmentIndicator - ? (activeEnvironment?.label ?? "Run on") - : workspaceLabel)} + {section === "workspace" + ? workspaceLabel + : (autoEnvironmentLabel ?? + (isPanel || showEnvironmentIndicator + ? (activeEnvironment?.label ?? "Run on") + : workspaceLabel))} ); + const hasMenuContent = + showPathGroup || + runOnEnvironments !== undefined || + showWorkspaceGroup || + Boolean(environmentActions); - if (isLocked) { - return ( + if (isLocked || !hasMenuContent) { + const row = ( {triggerContent} ); + return isPanel ? ( + + + {workspacePath ?? workspaceLabel} + + ) : ( + row + ); } return ( @@ -863,11 +809,7 @@ const RunContextSelector = memo(function RunContextSelector({ } : undefined } - aria-label={ - isPanel - ? `Run context: ${[autoEnvironmentLabel ?? activeEnvironment?.label, workspaceLabel].filter(Boolean).join(", ")}` - : undefined - } + aria-label={isPanel ? runContextLabel : undefined} data-composer-context-control data-composer-shortcut={[ showEnvironmentPicker && !envLocked ? "composer.host" : "", @@ -882,6 +824,9 @@ const RunContextSelector = memo(function RunContextSelector({ - {isPanel && workspacePath ? ( + {showPathGroup ? ( {activeEnvironment?.label ?? "Environment"}
@@ -899,8 +844,7 @@ const RunContextSelector = memo(function RunContextSelector({
) : null} - {availableEnvironments && - ((showEnvironmentPicker && onEnvironmentChange) || addTargets.length > 0) ? ( + {runOnEnvironments ? ( <> Run on @@ -929,7 +873,7 @@ const RunContextSelector = memo(function RunContextSelector({ )} - {availableEnvironments.map((env) => ( + {runOnEnvironments.map((env) => ( ) : null} - + {showWorkspaceGroup ? : null} ) : null} - - Workspace - { - if (value === "previous-worktree") { - onUsePreviousWorktree(); - return; - } - onEnvModeChange(value as EnvMode); - }} - > - - - {activeWorktreePath ? ( - - ) : ( - - )} - - - - - - - {resolveEnvModeLabel("worktree")} - - - {previousWorktreeLabel ? ( - - + {showWorkspaceGroup ? ( + + Workspace + { + if (value === "previous-worktree") { + onUsePreviousWorktree(); + return; + } + onEnvModeChange(value as EnvMode); + }} + > + + + {activeWorktreePath ? ( + + ) : ( + + )} + + - ) : null} - - + + + + {resolveEnvModeLabel("worktree")} + + + {previousWorktreeLabel ? ( + + + + ) : null} + + + ) : null} + {environmentActions ? ( + <> + {showPathGroup || runOnEnvironments || showWorkspaceGroup ? : null} + {environmentActions} + + ) : null}
); @@ -1017,9 +973,11 @@ const COMPOSER_CONTEXT_MOTION_EASING = "cubic-bezier(0.32, 0.72, 0, 1)"; const COMPOSER_CONTEXT_LABEL_SELECTOR = "[data-composer-label]"; const RESTING_CONTROLS_SELECTOR = '[data-chat-composer-resting-controls="true"]'; -function readStripCompaction(strip: HTMLElement): ContextStripCompaction { - const value = strip.dataset.compact; - return value === "labels" || value === "branch" ? value : "none"; +function flexGap(element: HTMLElement | null): number { + while (element && getComputedStyle(element).display === "contents") { + element = element.parentElement; + } + return element ? Number.parseFloat(getComputedStyle(element).columnGap) || 0 : 0; } /** A hidden or squeezed label's scrollWidth loses its tail, causing label/icon flapping. */ @@ -1083,11 +1041,14 @@ function useStripCompaction(): readonly [ const attach = (current: HTMLDivElement | null) => { if (!current) return; + let measured = false; const labelAnimations = new Map(); const measure = () => { + // Hidden tabs retain stage layout but must keep their last label mode. + if (!current.checkVisibility()) return; // The committed attribute, not render state, so a stale closure cannot misread it. - const compact = readStripCompaction(current); + const compact = readContextStripCompaction(current.dataset.compact); const stripWidth = current.clientWidth; if (stripWidth === 0) return; const stripStyle = getComputedStyle(current); @@ -1095,26 +1056,9 @@ function useStripCompaction(): readonly [ stripWidth - (Number.parseFloat(stripStyle.paddingInlineStart) || 0) - (Number.parseFloat(stripStyle.paddingInlineEnd) || 0); - const contentWidth = (parent: Element): number => { - const gap = Number.parseFloat(getComputedStyle(parent).columnGap) || 0; - let width = 0; - let counted = 0; - for (const child of parent.children) { - if (!(child instanceof HTMLElement)) continue; - if (child.offsetWidth === 0) continue; - const style = getComputedStyle(child); - const position = style.position; - if (position === "absolute" || position === "fixed") continue; - width += - child.offsetWidth + - (Number.parseFloat(style.marginInlineStart) || 0) + - (Number.parseFloat(style.marginInlineEnd) || 0); - counted += 1; - } - return width + gap * Math.max(0, counted - 1); - }; const stripGap = Number.parseFloat(stripStyle.columnGap) || 0; let needed = 0; + let labelsNeeded = 0; let groups = 0; for (const child of current.children) { if (!(child instanceof HTMLElement)) continue; @@ -1122,28 +1066,43 @@ function useStripCompaction(): readonly [ const controlsMeasurement = controls ? measureRestingComposerControls(controls) : null; if (controlsMeasurement) { child.style.minWidth = `${resolveRestingComposerControlsMinimumWidth(controlsMeasurement)}px`; + groups += 1; + needed += resolveRestingComposerControlsNarrowestWidth(controlsMeasurement, "none"); + labelsNeeded += resolveRestingComposerControlsNarrowestWidth( + controlsMeasurement, + "labels", + ); + continue; } - const width = controlsMeasurement - ? resolveRestingComposerControlsNaturalWidth(controlsMeasurement) - : contentWidth(child); + const width = contextStripGroupWidth(child); if (width <= 1) continue; groups += 1; needed += width; + labelsNeeded += width; } needed += stripGap * Math.max(0, groups - 1); + labelsNeeded += stripGap * Math.max(0, groups - 1); const range = document.createRange(); - let labelsSavedWidth = 0; for (const label of current.querySelectorAll(COMPOSER_CONTEXT_LABEL_SELECTOR)) { const textWidth = labelTextWidth(label, range); - needed += Math.max(0, textWidth - label.getBoundingClientRect().width); - if (label.dataset.composerLabel === "hide") labelsSavedWidth += textWidth; + const renderedWidth = + label.getBoundingClientRect().width + + (Number.parseFloat(getComputedStyle(label).marginInlineStart) || 0); + const missingWidth = Math.max(0, textWidth - renderedWidth); + needed += missingWidth; + labelsNeeded += + label.dataset.composerLabel === "hide" + ? -renderedWidth - flexGap(label.parentElement) + : missingWidth; } const nextCompaction = resolveContextStripCompaction({ current: compact, neededWidth: needed, + labelsNeededWidth: labelsNeeded, availableWidth: available, - labelsSavedWidth, }); + const firstMeasure = !measured; + measured = true; if (nextCompaction === compact) return; const previousRects = new Map( Array.from(current.querySelectorAll(COMPOSER_CONTEXT_LABEL_SELECTOR)).map( @@ -1151,7 +1110,7 @@ function useStripCompaction(): readonly [ ), ); flushSync(() => setCompaction(nextCompaction)); - animateLabelWidths(previousRects, labelAnimations); + if (!firstMeasure) animateLabelWidths(previousRects, labelAnimations); }; let stopResize = () => {}; @@ -1192,6 +1151,7 @@ function useStripCompaction(): readonly [ export const BranchToolbar = memo(function BranchToolbar({ layout = "composer", + onOpenWorkspaceDetails, panelSection = "all", forceNewWorktree = false, ref, @@ -1219,6 +1179,7 @@ export const BranchToolbar = memo(function BranchToolbar({ afterBranch, environmentUnavailable = false, panelPrRowsContainer, + panelEnvironmentActions, }: BranchToolbarProps) { const branchSelectorRef = useRef(null); const threadRef = scopeThreadRef(environmentId, threadId); @@ -1226,6 +1187,7 @@ export const BranchToolbar = memo(function BranchToolbar({ // The composer retains resolved thread details while bootstrap cleanup can remove its shell. const activeProjectRef = scopeProjectRef(environmentId, projectId); const activeProject = useProject(activeProjectRef); + const scratchWorkspaceRoot = useServerConfigs().get(environmentId)?.scratchWorkspaceRoot; const activeWorktreePath = forceNewWorktree ? null : worktreePath; const effectiveEnvMode = forceNewWorktree ? "worktree" : envMode; const envModeLocked = envLocked || (isServerThread && activeWorktreePath !== null); @@ -1252,7 +1214,11 @@ export const BranchToolbar = memo(function BranchToolbar({ useImperativeHandle( ref, () => ({ - openBranchPicker: () => branchSelectorRef.current?.open(), + focusBranchPicker: () => branchSelectorRef.current?.focus(), + openBranchPicker: () => { + if (onOpenWorkspaceDetails) onOpenWorkspaceDetails(); + else branchSelectorRef.current?.open(); + }, usePreviousWorktree: () => { if (!showGitControls || !canUsePreviousWorktree || !previousWorktreeSeed) return; onUsePreviousWorktree(); @@ -1261,6 +1227,7 @@ export const BranchToolbar = memo(function BranchToolbar({ }), [ canUsePreviousWorktree, + onOpenWorkspaceDetails, onComposerFocusRequest, onUsePreviousWorktree, previousWorktreeSeed, @@ -1283,35 +1250,45 @@ export const BranchToolbar = memo(function BranchToolbar({ if (layout === "panel") { return (
- {panelSection !== "branch" ? ( - - ) : null} + {panelSection !== "branch" + ? // Without Git there is no worktree to make, so only the machine row is left. + (showGitControls ? (["machine", "workspace"] as const) : (["machine"] as const)).map( + (section) => ( + + ), + ) + : null} {panelSection !== "workspace" ? ( ) : null; - // Without Git the workspace chip stands alone, so the machine chip leads it here. - const workspaceMenu = ( - <> - {machineMenu} - - - ); return ( - {!showGitControls ? workspaceMenu : null} - {showGitControls ? machineMenu : null} - {showGitControls ? ( + {isScratchProject(activeProject, scratchWorkspaceRoot) ? ( + + ) : null} + {machineMenu} + {!showGitControls && !onOpenWorkspaceDetails ? ( + + ) : null} + {showGitControls && !onOpenWorkspaceDetails ? ( void; + focus: () => void; } interface BranchToolbarBranchSelectorProps { @@ -211,6 +212,7 @@ export function BranchToolbarBranchSelector({ }: BranchToolbarBranchSelectorProps) { const composerFloatingLayerProps = useComposerMenuProps(); const toolbarAnchorRef = useRef(null); + const triggerRef = useRef(null); const canWriteSourceControl = useEnvironmentScope(environmentId, AuthSourceControlWriteScope); const canOperateThread = useEnvironmentScope(environmentId, AuthOrchestrationOperateScope); const stopThreadSession = useAtomCommand(threadEnvironment.stopSession, "thread session stop"); @@ -763,6 +765,7 @@ export function BranchToolbarBranchSelector({ useImperativeHandle( ref, () => ({ + focus: () => triggerRef.current?.focus({ preventScroll: true }), open: () => { if (isInitialBranchesLoadPending || isBranchActionPending) return; setFocusBranchSearch(true); @@ -992,6 +995,10 @@ export function BranchToolbarBranchSelector({ : {}), align: "start", side: displayMode === "panel" ? "right" : "top", + collisionAvoidance: + displayMode === "panel" + ? { side: "flip", align: "shift", fallbackAxisSide: "end" } + : undefined, className: cn( "flex flex-col", displayMode === "panel" ? "w-(--anchor-width)" : "min-w-0 w-90", @@ -1018,6 +1025,7 @@ export function BranchToolbarBranchSelector({ > {displayMode === "toolbar" && renderWorkspaceMenu ? null : ( diff --git a/apps/web/src/components/ChatView.browser.test.tsx b/apps/web/src/components/ChatView.browser.test.tsx index 17e8ab66d6a36..b47f4efd35799 100644 --- a/apps/web/src/components/ChatView.browser.test.tsx +++ b/apps/web/src/components/ChatView.browser.test.tsx @@ -5,8 +5,12 @@ import { AgentSessionImportProjectChangedError, EnvironmentId, ORCHESTRATION_V2_WS_METHODS, + NodeId, + PROVIDER_SEND_TURN_MAX_INPUT_CHARS, RunId, + RuntimeRequestId, ThreadId, + TurnItemId, WS_METHODS, type AgentSessionImportInput, } from "@t3tools/contracts"; @@ -16,6 +20,7 @@ import { mergeWithDefaultKeybindings, } from "@t3tools/shared/keybindings"; import type * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import { expect, it, vi } from "vite-plus/test"; import { cdp, page, userEvent } from "vite-plus/test/browser"; @@ -30,8 +35,9 @@ import { __setClientSettingsForTests } from "~/hooks/useSettings"; import { environmentContextRecord } from "~/lib/composerContextRecords"; import { startWorkspaceFocus } from "~/lib/workspaceFocus"; import { usePromptStashStore } from "~/promptStashStore"; -import { useRightPanelStore } from "~/rightPanelStore"; +import { selectThreadRightPanelState, useRightPanelStore } from "~/rightPanelStore"; import { getRouter } from "~/router"; +import { useLayoutStore } from "~/layout/layoutStore"; import { appAtomRegistry } from "~/rpc/atomRegistry"; import { environmentThreadDetails } from "~/state/threads"; import { @@ -240,6 +246,8 @@ it("a question that opens while a stashed file is checked keeps a machine stash" it("imports Pi conversations through the environment RPC and can retry a changed project", async () => { await using _viewport = await appViewport(); + useLayoutStore.getState().reset(); + using _layout = { [Symbol.dispose]: () => useLayoutStore.getState().reset() }; using _environment = installFakeEnvironment([], { acceptCommands: true }); const scan = vi.fn(() => Effect.succeed({ @@ -319,6 +327,7 @@ it("imports Pi conversations through the environment RPC and can retry a changed await expect .element(page.getByRole("heading", { name: "Welcome to T3 Code" })) .not.toBeInTheDocument(); + await vi.waitFor(() => expect(router.state.location.pathname).not.toBe("/welcome")); changed = false; await router.navigate({ to: "/welcome" }); await expect.element(page.getByRole("heading", { name: "Welcome to T3 Code" })).toBeVisible(); @@ -414,6 +423,86 @@ it("an interrupted image-only first send of a new thread says it may not have be expect(page.getByText("Copy message", { exact: true }).query()).toBeNull(); }); +it("answers a pending question while an oversized message draft is set aside", async () => { + await using _viewport = await appViewport(); + const thread = threadWithHistory("oversized-question-draft", 0); + using _environment = installFakeEnvironment([thread]); + __setClientSettingsForTests({ + ...DEFAULT_CLIENT_SETTINGS, + onboardingCompletedAt: "2026-01-01T00:00:00.000Z", + }); + const target = scopeThreadRef(ENVIRONMENT_ID, thread.id); + const oversizedDraft = "x".repeat(PROVIDER_SEND_TURN_MAX_INPUT_CHARS + 1); + const router = getRouter( + createMemoryHistory({ initialEntries: [`/${ENVIRONMENT_ID}/${thread.id}`] }), + ); + await router.load(); + // AppRoot owns the router. + await using _app = await render(, { + container: appContainer(), + router: false, + }); + await expect.element(page.getByTestId("composer-editor")).toBeVisible(); + + const requestId = RuntimeRequestId.make("oversized-draft-question"); + const nodeId = NodeId.make("oversized-draft-node"); + const now = DateTime.makeUnsafe("2026-03-17T19:12:28.000Z"); + await act(async () => { + await emitThreadEvent(thread.id, { + type: "runtime-request.updated", + payload: { + id: requestId, + nodeId, + providerTurnId: null, + nativeRequestRef: null, + kind: "user_input", + status: "pending", + responseCapability: { type: "message" }, + createdAt: now, + resolvedAt: null, + }, + }); + await emitThreadEvent(thread.id, { + type: "turn-item.updated", + payload: { + id: TurnItemId.make("oversized-draft-question-card"), + threadId: thread.id, + runId: null, + nodeId, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal: 0, + status: "completed", + title: null, + startedAt: now, + completedAt: now, + updatedAt: now, + type: "user_input_request", + requestId, + questions: [ + { + id: "scope", + header: "Scope", + question: "Which scope should we use?", + options: [], + initialAnswer: "Composer", + }, + ], + }, + }); + }); + await expect.element(page.getByText("Which scope should we use?", { exact: true })).toBeVisible(); + await act(() => { + useComposerDraftStore.getState().setPrompt(target, oversizedDraft); + }); + await expect.element(page.getByText(/Prompt is 1 character over/)).not.toBeInTheDocument(); + await page.getByRole("button", { name: "Answer", exact: true }).click(); + await expect.element(page.getByRole("button", { name: /Submitting/ }).first()).toBeVisible(); + expect(useComposerDraftStore.getState().getComposerDraft(target)?.prompt).toBe(oversizedDraft); +}); + it("opens the thread actions menu for an older settled thread the shell leaves out", async () => { await using _viewport = await appViewport(); const thread = { ...threadWithHistory("older-settled", 1), olderSettled: true as const }; @@ -721,11 +810,13 @@ it("a shortcut conditioned on rightPanelOpen sees the thread's open right panel" using _environment = installFakeEnvironment([thread], { keybindings: mergeWithDefaultKeybindings( compileResolvedKeybindingsConfig([ - { key: "ctrl+t", command: "rightPanel.new", when: "rightPanelOpen && !terminalFocus" }, + { key: "ctrl+t", command: "threadPanel.toggle", when: "rightPanelOpen && !terminalFocus" }, { key: "ctrl+t", command: "rightPanel.toggle", when: "!rightPanelOpen" }, ]), ), }); + // Reconnect so this test's keybindings replace a previous app's live config. + appAtomRegistry.reset(); __setClientSettingsForTests({ ...DEFAULT_CLIENT_SETTINGS, onboardingCompletedAt: "2026-01-01T00:00:00.000Z", @@ -738,15 +829,23 @@ it("a shortcut conditioned on rightPanelOpen sees the thread's open right panel" // AppRoot brings its own router. router: false, }); + // Panes retired rightPanel.new; the side pane is the thread's right panel. + const threadRef = scopeThreadRef(ENVIRONMENT_ID, thread.id); + const sidePaneOpen = () => + selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, threadRef).isOpen; const composer = page.getByTestId("composer-editor"); - const toggle = page.getByRole("button", { name: "Toggle right panel", exact: true }); - await expect.element(toggle).toHaveAttribute("aria-pressed", "false"); - await toggle.click(); - await page.getByRole("button", { name: /^Files/ }).click(); + const details = page.getByRole("button", { name: "Toggle thread details panel", exact: true }); + await expect.element(details).toBeVisible(); + // As clicking the panel toggle and its Files tab did. + await act(() => useRightPanelStore.getState().open(threadRef, "files")); + await vi.waitFor(() => expect(sidePaneOpen()).toBe(true)); + const detailsOpen = details.element().getAttribute("aria-expanded"); await composer.click(); await userEvent.keyboard("{Control>}t{/Control}"); - await expect.element(page.getByRole("menuitem", { name: /Files/ })).toBeVisible(); - await expect.element(toggle).toHaveAttribute("aria-pressed", "true"); + await expect + .element(details) + .toHaveAttribute("aria-expanded", detailsOpen === "true" ? "false" : "true"); + expect(sidePaneOpen()).toBe(true); }); it("selecting a side-chat tab focuses that side chat's composer", async () => { @@ -766,8 +865,6 @@ it("selecting a side-chat tab focuses that side chat's composer", async () => { onboardingCompletedAt: "2026-01-01T00:00:00.000Z", }); const parentRef = scopeThreadRef(ENVIRONMENT_ID, parent.id); - useRightPanelStore.getState().openSideChat(parentRef, otherSide.id); - useRightPanelStore.getState().openSideChat(parentRef, side.id); const router = getRouter( createMemoryHistory({ initialEntries: [`/${ENVIRONMENT_ID}/${parent.id}`] }), ); @@ -777,6 +874,10 @@ it("selecting a side-chat tab focuses that side chat's composer", async () => { router: false, }); using _focus = { [Symbol.dispose]: startWorkspaceFocus(router) }; + await act(() => { + useRightPanelStore.getState().openSideChat(parentRef, otherSide.id); + useRightPanelStore.getState().openSideChat(parentRef, side.id); + }); const mainEditor = screen.getByRole("textbox", { name: `Message ${parent.title}`, exact: true }); const sideEditor = screen.getByRole("textbox", { name: `Message ${side.title}`, exact: true }); await expect.element(sideEditor).toBeVisible(); @@ -795,4 +896,10 @@ it("selecting a side-chat tab focuses that side chat's composer", async () => { await userEvent.click(screen.getByRole("button", { name: side.title, exact: true })); await expect.element(sideEditor).toHaveFocus(); await expect.element(mainEditor).toHaveTextContent("main draft"); + + // Shift+F6 goes straight back to the main chat; its tab, already showing, brings the box back. + await userEvent.keyboard("{Shift>}{F6}{/Shift}"); + await expect.element(mainEditor).toHaveFocus(); + await userEvent.click(screen.getByRole("button", { name: side.title, exact: true })); + await expect.element(sideEditor).toHaveFocus(); }); diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 54174262fd70f..4188ebb7e9697 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -62,6 +62,7 @@ import { deriveLockedProvider, hasCompactableConversation, hasServerAcknowledgedLocalDispatch, + shouldShowComposerTasks, waitForRevertedMessage, } from "./ChatView.logic"; @@ -175,6 +176,18 @@ describe("buildExpiredTerminalContextToastCopy", () => { }); }); +describe("shouldShowComposerTasks", () => { + it("hides a settled run's finished plan while the next send is pending", () => { + expect( + shouldShowComposerTasks({ + steps: [{ status: "completed" }], + planRunSettled: true, + isSendBusy: true, + }), + ).toBe(false); + }); +}); + describe("hasServerAcknowledgedLocalDispatch", () => { it("acknowledges a settled newer background turn", () => { const localDispatch = createLocalDispatchSnapshot( @@ -256,6 +269,15 @@ describe("hasServerAcknowledgedLocalDispatch", () => { threadError: null, }), ).toBe(acknowledged); + + expect( + deriveTimelineWorking({ + phase: derivePhase({ ...readySession, status }), + isSendBusy: !acknowledged, + isCompacting: false, + runlessWorkStartedAt: null, + }), + ).toBe(true); }); it("waits for the matching running turn before acknowledging", () => { diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 927a253ead2b8..a914f3ebd29ef 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -35,7 +35,6 @@ import { type ProviderInstanceId, type ProviderThreadId, type ServerProvider, - type ScopedProjectRef, type ScopedThreadRef, type ThreadContextRecord, type EnvironmentContextRecord, @@ -108,7 +107,6 @@ type ThreadProjection = EnvironmentThread["projection"]; type ThreadStructureRow = ThreadStructure["visibleTurnItems"][number]; export const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project"; -export const MAX_HIDDEN_MOUNTED_TERMINAL_THREADS = 10; export const ENVIRONMENT_RECONNECT_WARNING_GRACE_MS = 2_000; @@ -217,6 +215,15 @@ export function shouldOpenProactiveTurnDiff(input: { ); } +export function shouldShowComposerTasks(input: { + steps: ReadonlyArray<{ status: string }>; + planRunSettled: boolean; + isSendBusy: boolean; +}): boolean { + if (!input.planRunSettled) return true; + return !input.isSendBusy && input.steps.some((step) => step.status !== "completed"); +} + export function resolveProactiveTurnDiffAction(input: { checkpoint: Pick | undefined; isGitRepo: boolean | undefined; @@ -395,16 +402,6 @@ export function resolveThreadSwitchTimeline(input: return input.nextEntries; } -export function startNewThreadForProject( - projectRef: ScopedProjectRef | null, - handleNewThread: (projectRef: ScopedProjectRef) => Promise, -): boolean { - if (projectRef === null) return false; - void handleNewThread(projectRef); - - return true; -} - export function resolveThreadMetadataUpdateForNextTurn(input: { currentModelSelection: ModelSelection; nextModelSelection?: ModelSelection; @@ -557,37 +554,6 @@ export function getAntigravitySendBlockReason( return null; } -export function reconcileMountedTerminalThreadIds(input: { - currentThreadIds: ReadonlyArray; - openThreadIds: ReadonlyArray; - activeThreadId: string | null; - activeThreadTerminalOpen: boolean; - maxHiddenThreadCount?: number; -}): string[] { - const openThreadIdSet = new Set(input.openThreadIds); - const hiddenThreadIds = input.currentThreadIds.filter( - (threadId) => threadId !== input.activeThreadId && openThreadIdSet.has(threadId), - ); - const maxHiddenThreadCount = Math.max( - 0, - input.maxHiddenThreadCount ?? MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, - ); - const nextThreadIds = - hiddenThreadIds.length > maxHiddenThreadCount - ? hiddenThreadIds.slice(-maxHiddenThreadCount) - : hiddenThreadIds; - - if ( - input.activeThreadId && - input.activeThreadTerminalOpen && - !nextThreadIds.includes(input.activeThreadId) - ) { - nextThreadIds.push(input.activeThreadId); - } - - return nextThreadIds; -} - // Null identifies the project checkout when a launch or session is known. export function resolveTerminalWorktreePath(input: { launchContext: { readonly worktreePath: string | null } | null; @@ -1091,6 +1057,10 @@ export function hasServerAcknowledgedLocalDispatch(input: { ); } +export function isCoveredByModal(element: Pick | null): boolean { + return element?.closest('[aria-hidden="true"]') != null; +} + // Window return puts the caret in the composer unless focus is deliberate: another text field, // a terminal, an open dialog, menu, or popup, a region, timeline, or sidebar row reached from the // keyboard, or a region with its own keys (aria-keyshortcuts). A plain focused button or a clicked diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 33a090d5b379d..be2157ecebf26 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1,6 +1,6 @@ import { ChildThreadApprovals } from "./chat/ChildThreadApprovals"; import { ComposerTaskStatus } from "./chat/ComposerTaskStatus"; -import { workspaceAreaProps } from "../lib/workspaceFocus"; +import { isReturnedSurfaceFocus, workspaceAreaProps } from "../lib/workspaceFocus"; import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; import { resolveNewThreadModelSelection } from "@t3tools/shared/usageLimits"; import { queuedRunEditBlockedReason } from "@t3tools/client-runtime/queued-run-edit"; @@ -41,11 +41,15 @@ import { THREAD_FIND_BAR_RESERVED_HEIGHT } from "./chat/ThreadFindBar"; import { usageLimitRecoveryBannerItem } from "./chat/UsageLimitRecoveryBanner"; import { isThreadEnvironmentLocked, resolveDraftHeroState } from "./ChatView.logic"; import { useQueuedRunEdit } from "./chat/useQueuedRunEdit"; -import { recallCheckoutIsRepo, rememberCheckoutIsRepo } from "./ChatView.logic"; +import { + recallCheckoutIsRepo, + rememberCheckoutIsRepo, + shouldShowComposerTasks, +} from "./ChatView.logic"; import { useLogicalProjectEnvironments } from "../hooks/useLogicalProjectEnvironments"; import { useStableCallback } from "../hooks/useStableCallback"; +import { DeferredChatContext } from "../layout/chatRenderGate"; import { useProjectScriptEditing } from "./chat/useProjectScriptEditing"; -import { RightPanelSurfaceContent } from "./chat/RightPanelSurfaceContent"; import { usePendingThreadRequests } from "./chat/usePendingThreadRequests"; import { isScratchProject } from "@t3tools/client-runtime/state/projects"; import { visibleThreadPullRequests } from "@t3tools/shared/threadPullRequests"; @@ -125,7 +129,10 @@ import { resolveThreadReferenceCopyTarget } from "@t3tools/client-runtime/thread import { resolveTerminalSessionLabel } from "@t3tools/shared/terminalLabels"; import { useAtomValue } from "@effect/atom-react"; import { + type ComponentProps, + type CSSProperties, type MouseEvent as ReactMouseEvent, + type ReactNode, type SetStateAction, use, useEffect, @@ -138,6 +145,7 @@ import { WorkspaceMutationIdContext } from "./chat/ChatMarkdownMedia"; import { useLocation, useNavigate } from "@tanstack/react-router"; import { assistantCitationFromLocation, + assistantCitationScopeFromLocation, useSideChatCitationReveal, } from "../lib/assistantCitationNavigation"; import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection"; @@ -180,14 +188,12 @@ import { useRightPanelStore, } from "../rightPanelStore"; import { isPreviewSupportedInRuntime, useThreadPreviewState } from "../previewStateStore"; -import { previewRuntimeTabId } from "../browser/previewRuntimeTabId"; import { ThreadPreviewMiniPlayer, useThreadPreviewCanvasSource, } from "./preview/ThreadPreviewMiniPlayer"; import { createChatCanvasStore, resolveChatCanvasModel } from "./chat/canvasStore"; import { usePreviewSession } from "./preview/usePreviewSession"; -import { getConfiguredPreviewUrls } from "./preview/previewEmptyStateLogic"; import { previewMiniPlayerSourceKey, @@ -204,7 +210,6 @@ import { isEditableFocused } from "../lib/editableFocus"; import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings"; import { shortcutLabelForCommand } from "../keybindings"; import { cn } from "~/lib/utils"; -import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar"; import { stackedThreadToast, toastManager } from "./ui/toast"; import { projectScriptIdFromCommand } from "~/projectScripts"; import { newMessageId } from "~/lib/utils"; @@ -229,7 +234,7 @@ import { deadlineStore } from "@t3tools/client-runtime/clock"; import { usePanelAnimationSettings, usePanelPresence } from "../panelAnimations"; import { useNewThreadHandler } from "../hooks/useHandleNewThread"; import { useOpenPanelPullRequestUrl } from "../hooks/useOpenPanelPullRequestUrl"; -import { isPreviewFocused } from "../lib/previewFocus"; +import { isBrowserFocused } from "../lib/previewFocus"; import { getTerminalFocusOwner } from "../lib/terminalFocus"; import { draftModelSelectionApplies, threadHasStarted } from "../lib/chatThreadActions"; import { @@ -248,6 +253,7 @@ import { useComposerDraftStore, DraftId, } from "../composerDraftStore"; +import { useDraftSendOnOpenStore } from "../draftSendOnOpenStore"; import { type TerminalContextDraft } from "../lib/terminalContext"; import { collectInlineContextIds } from "../lib/composerContextReferences"; import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; @@ -311,22 +317,24 @@ import { ProviderSubagentBar } from "./chat/ProviderSubagentBar"; import { getTriggerDisplayModelName } from "./chat/providerIconUtils"; import { pendingApprovalSlotLabel } from "./chat/MessagesTimeline.logic"; import { resolveScrollToEndClearance } from "./composerFooterLayout"; -import { ChatHeader } from "./chat/ChatHeader"; +import { ChatHeader, type HiddenTerminalChip } from "./chat/ChatHeader"; import { useSideChat } from "./chat/useSideChat"; import { useRemoteOpenState } from "~/remoteOpen"; import { shouldShowOpenInPicker } from "./chat/OpenInPicker.logic"; -import { PaneHeaderActions } from "./chat/PaneHeaderActions"; +import { + ChatPaneHeader, + ChatPaneHeaderControls, + useHeaderControlsInset, +} from "./chat/ChatPaneHeader"; import GitActionsControl from "./GitActionsControl"; import { useOpenFavoriteEditorShortcut } from "./chat/OpenInPickerShortcut"; -import { - PanelLayoutControls, - type PanelLayoutControlsProps, - RightPanelMaximizeControl, - ThreadPanelHeaderControl, - WorkspaceTitlebarControls, -} from "./chat/PanelLayoutControls"; +import { PanelLayoutControls, type PanelLayoutControlsProps } from "./chat/PanelLayoutControls"; import type { ExpandedImagePreview } from "./chat/ExpandedImagePreview"; -import { ThreadDetailsPanel } from "./chat/ThreadDetailsPanel"; +import { ThreadDetailsPanel, type ThreadDetailsPanelHandle } from "./chat/ThreadDetailsPanel"; +import { + resolveThreadDetailsVisibility, + shouldShowComposerWorkspace, +} from "./chat/threadDetailsItems"; import { NoActiveThreadState } from "./NoActiveThreadState"; import { resolveEffectiveEnvMode, @@ -348,8 +356,7 @@ import { QueuedRunsControl, type QueuedRunsControlHandle } from "./chat/QueuedRu import { useThreadLinkedPullRequest } from "./pullRequest/useThreadLinkedPullRequest"; import { useProactivePanels } from "../hooks/useProactivePanels"; import { useIncrementalTimelineEntries } from "../hooks/useIncrementalTimelineEntries"; -import { useRightPanelSurfaceClose } from "../hooks/useRightPanelSurfaceClose"; -import { type ComposerBannerStackItem } from "./chat/ComposerBannerStack"; +import type { ComposerBannerStackItem } from "./chat/ComposerBannerStack"; import { modelFallbackBannerItem } from "./chat/ComposerUsageLimits"; import { ComposerSurface } from "./chat/ComposerSurface"; import { resolveThreadSyncPhase } from "../threadSync"; @@ -375,13 +382,16 @@ import { resolveComposerProviderSelection, resolveThreadMetadataUpdateForNextTurn, resolveSendEnvMode, - startNewThreadForProject, codexArtifactTemplatePromptToAppend, + isCoveredByModal, shouldRefocusComposerOnWindowFocus, hasCompactableConversation, } from "./ChatView.logic"; import { useLocalStorage } from "~/hooks/useLocalStorage"; import { useComposerHandleContext } from "../composerHandleContext"; +import { useLayoutPane, useLayoutPaneFocusedRef } from "../layout/layoutPaneContext"; +import { publishThreadChatBridge } from "../layout/threadSurfaceBridge"; +import { runInNewLayoutTab } from "../layout/layoutStore"; import { clampFileAttachmentUploadBytes } from "@t3tools/client-runtime/state/attachments"; import { appAtomRegistry } from "../rpc/atomRegistry"; import { assetEnvironment } from "../state/assets"; @@ -391,8 +401,6 @@ import { useOrchestrationCommand } from "../state/use-orchestration-command"; import { useAtomQueryRunner } from "../state/use-atom-query-runner"; import { observeResize } from "~/lib/observeResize"; import { ThreadVisitWatermark } from "./chat/ThreadVisitWatermark"; -import { ChatTerminalDrawers } from "./chat/ChatTerminalDrawers"; -import { ChatRightPanel } from "./chat/ChatRightPanel"; import { useDraftEnvironmentRouting } from "./chat/useDraftEnvironmentRouting"; import { branchMismatchBannerItem, extensionStatusBannerItem } from "./chat/ThreadActivityBanners"; import { ThreadNow } from "./chat/ThreadNow"; @@ -425,8 +433,6 @@ const EMPTY_AUTO_UPDATE_ENVIRONMENTS: ReadonlyArray = [ const EMPTY_COMPOSER_BANNER_ITEMS: ReadonlyArray = []; const EMPTY_PROVIDER_SLASH_COMMANDS: ServerProvider["slashCommands"] = []; -const EMPTY_PENDING_FILE_SURFACE_IDS: ReadonlySet = new Set(); - type ChatViewProps = | { environmentId: EnvironmentId; @@ -538,7 +544,11 @@ export default function ChatView(props: ChatViewProps) { }); const promptRef = useRef(""); const localComposerRef = useRef(null); - const composerRef = useComposerHandleContext() ?? localComposerRef; + const layoutPane = useLayoutPane(); + const layoutPaneFocusedRef = useLayoutPaneFocusedRef(); + const ownsThread = layoutPane.focused || layoutPane.related; + const paletteComposerRef = useComposerHandleContext(); + const composerRef = (ownsThread ? paletteComposerRef : null) ?? localComposerRef; const queuedRunEdit = useQueuedRunEdit({ environmentId, routeThreadRef, @@ -604,23 +614,42 @@ export default function ChatView(props: ChatViewProps) { const clientSettingsHydrated = useClientSettingsHydrated(); const timestampFormat = settings.timestampFormat; const navigate = useNavigate(); - // Selects primitives: an object here is new on every router store update, which - // re-renders ChatView on unrelated navigation state changes. - const citationHref = useLocation({ select: (location) => location.href }); - const citationKey = useLocation({ - select: (location) => location.state.assistantCitationActivation ?? location.state.__TSR_key, + const citationLocation = useLocation({ + // Other panes navigating must not rerender this thread's timeline. + select: (location) => { + const citation = assistantCitationFromLocation(location.href); + return citation && + citation.environmentId === environmentId && + (citation.threadId === threadId || (!embedded && layoutPane.focused)) + ? { + href: location.href, + key: + location.state.assistantCitationActivation ?? + location.state.__TSR_key ?? + location.href, + } + : null; + }, + structuralSharing: true, }); - const citationLocation = { href: citationHref, key: citationKey }; - const locationCitation = assistantCitationFromLocation(citationLocation.href); - const citationActivation = citationLocation.key ?? citationLocation.href; + const locationCitation = citationLocation + ? assistantCitationFromLocation(citationLocation.href) + : null; + const citationActivation = citationLocation?.key ?? ""; const citationRequest = ( - locationCitation && - locationCitation.environmentId === environmentId && - locationCitation.threadId === threadId - ? { citation: locationCitation, key: citationActivation } + citationLocation && locationCitation && locationCitation.threadId === threadId + ? { + citation: locationCitation, + scope: assistantCitationScopeFromLocation(citationLocation.href), + key: citationActivation, + } : null ) as AssistantCitationRequest | null; - useSideChatCitationReveal(embedded ? null : routeThreadRef, locationCitation, citationActivation); + useSideChatCitationReveal( + embedded || !layoutPane.focused ? null : routeThreadRef, + locationCitation, + citationActivation, + ); const citationHistory = useCitedHistory(citationRequest); const { resolvedTheme } = useTheme(); const composerRuntimeMode = useComposerDraftStore( @@ -660,7 +689,10 @@ export default function ChatView(props: ChatViewProps) { const composerImagesRef = useRef([]); const composerFilesRef = useRef([]); const composerTerminalContextsRef = useRef([]); + const [headerControlsInset, measureHeaderControls] = useHeaderControlsInset(); const branchToolbarRef = useRef(null); + const threadDetailsPanelRef = useRef(null); + const detailsVisibility = useClientSettings(resolveThreadDetailsVisibility); const [composerStashHost, setComposerStashHost] = useState(null); const [composerTrayControlsHost, setComposerTrayControlsHost] = useState( null, @@ -750,6 +782,9 @@ export default function ChatView(props: ChatViewProps) { return () => window.removeEventListener("dragend", clearWorkspaceFileDrag); }, [isWorkspaceFileDragActive]); const prefersPlanSidebarSheet = useMediaQuery(RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY); + // Layout opens are tabs at every viewport size. Only explicit shortcuts + // create splits; the legacy drawer/sheet is for views outside the layout. + const companionPanes = layoutPane.tabId !== null; const isMobileViewport = useMediaQuery("max-sm"); // Focusing the composer without a tap raises the on-screen keyboard on touch devices. const isCoarsePointer = useMediaQuery({ pointer: "coarse" }); @@ -1102,15 +1137,14 @@ export default function ChatView(props: ChatViewProps) { const rightPanelState = useRightPanelStore((state) => selectThreadRightPanelState(state.byThreadKey, activeThreadRef), ); - const shouldUsePlanSidebarSheet = prefersPlanSidebarSheet && rightPanelState.maximized !== true; + const shouldUsePlanSidebarSheet = + !companionPanes && + (prefersPlanSidebarSheet || layoutPane.narrow) && + rightPanelState.maximized !== true; const activeRightPanelSurface = useRightPanelStore((state) => selectActiveRightPanelSurface(state.byThreadKey, activeThreadRef), ); const activePreviewState = useThreadPreviewState(activeThreadRef); - const activePreviewServerEpoch = activePreviewState.serverEpoch; - const resolvePreviewRuntimeTabId = activeThreadRef - ? (tabId: string) => previewRuntimeTabId(activeThreadRef, activePreviewServerEpoch, tabId) - : undefined; const activePreviewMiniPlayer = usePreviewMiniPlayerStore((state) => selectThreadPreviewMiniPlayer(state.byThreadKey, activeThreadRef), ); @@ -1136,25 +1170,23 @@ export default function ChatView(props: ChatViewProps) { panelAnimationDurationMs, ); const rightPanelPresent = rightPanelPresence.present; + const rightPanelControlsInPanel = shouldUsePlanSidebarSheet && rightPanelPresent && rightPanelOpen; - const rightPanelControlsAtRoot = rightPanelPresent && !shouldUsePlanSidebarSheet; + const rightPanelControlsAtRoot = + rightPanelPresent && !shouldUsePlanSidebarSheet && !companionPanes; const renderedRightPanelSurface = rightPanelPresence.value?.activeSurface ?? null; - const renderedRightPanelSurfaces = rightPanelPresence.value?.surfaces ?? []; - const previewMiniPlayerVisible = shouldRenderPreviewMiniPlayer( - activePreviewMiniPlayer?.source ?? null, - renderedRightPanelSurface, - ); - const canMaximizeRightPanel = rightPanelOpen; - // An embedded side chat has no panel; a stale maximized flag would hide its own column. - const rightPanelMaximized = - !embedded && canMaximizeRightPanel && rightPanelState.maximized === true; - const toggleRightPanelMaximizedStable = useStableCallback(() => { - if (canMaximizeRightPanel && activeThreadRef) { - useRightPanelStore.getState().setMaximized(activeThreadRef, !rightPanelMaximized); - } - }); - const inlineRightPanelOwnsTitleBar = rightPanelOpen && !shouldUsePlanSidebarSheet; + // In a layout pane every shown surface is a tab of its own, not only the active one. + const previewMiniPlayerVisible = companionPanes + ? (rightPanelOpen ? rightPanelState.surfaces : [null]).every((surface) => + shouldRenderPreviewMiniPlayer(activePreviewMiniPlayer?.source ?? null, surface), + ) + : shouldRenderPreviewMiniPlayer( + activePreviewMiniPlayer?.source ?? null, + renderedRightPanelSurface, + ); + const inlineRightPanelOwnsTitleBar = + rightPanelOpen && !shouldUsePlanSidebarSheet && !companionPanes; const [threadPanelPopoverHandle] = useState(PopoverCreateHandle); const [chatCanvasStore] = useState(createChatCanvasStore); @@ -1178,8 +1210,10 @@ export default function ChatView(props: ChatViewProps) { !isServerThread && draftFallback ? draftFallback.selection : activeProjectSettings.settings.defaultModelSelection; + // The notch's "+": a new thread in this project, in a new tab of this pane. const handleNewThreadInActiveProject = () => { - startNewThreadForProject(activeProjectRef, handleNewThread); + if (activeProjectRef === null) return; + void runInNewLayoutTab(() => handleNewThread(activeProjectRef)); }; const handleNewThreadInActiveProjectStable = useStableCallback(handleNewThreadInActiveProject); const projectGroupingSettings = selectProjectGroupingSettings(settings); @@ -1201,29 +1235,6 @@ export default function ChatView(props: ChatViewProps) { const activeProjectKey = activeProject ? `${activeProject.environmentId}:${activeProject.workspaceRoot}` : null; - const [pendingFileSurfaceIdsByProject, setPendingFileSurfaceIdsByProject] = useState< - ReadonlyMap> - >(() => new Map()); - const pendingFileSurfaceIds = activeProjectKey - ? (pendingFileSurfaceIdsByProject.get(activeProjectKey) ?? EMPTY_PENDING_FILE_SURFACE_IDS) - : EMPTY_PENDING_FILE_SURFACE_IDS; - const handleFilePendingChange = (relativePath: string, pending: boolean) => { - if (!activeProjectKey) return; - setPendingFileSurfaceIdsByProject((currentByProject) => { - const current = currentByProject.get(activeProjectKey) ?? EMPTY_PENDING_FILE_SURFACE_IDS; - const surfaceId = `file:${relativePath}`; - if (current.has(surfaceId) === pending) return currentByProject; - const next = new Set(current); - if (pending) next.add(surfaceId); - else next.delete(surfaceId); - const nextByProject = new Map(currentByProject); - if (next.size === 0) nextByProject.delete(activeProjectKey); - else nextByProject.set(activeProjectKey, next); - return nextByProject; - }); - }; - const handleFilePendingChangeStable = useStableCallback(handleFilePendingChange); - const configuredPreviewUrls = getConfiguredPreviewUrls(activeProjectScripts); // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- drops file surfaces from the right panel store once bootstrap shows the thread has no workspace useEffect(() => { @@ -1405,17 +1416,20 @@ export default function ChatView(props: ChatViewProps) { const phase = derivePhase(activeRuntime); const serverThreadId = serverThread?.id; const serverThreadEnvironmentId = serverThread?.environmentId; - const setThreadError = makeThreadErrorSetter({ - routeThreadRef, - routeThreadKey, - draftId, - serverThreadRef: - serverThreadId !== undefined && serverThreadEnvironmentId !== undefined - ? { threadId: serverThreadId, environmentId: serverThreadEnvironmentId } - : null, - setLocalServerErrorsByThreadKey, - setLocalDraftErrorsByDraftId, - }); + // Stable, so a chat view render does not re-render the composer and every hook it feeds. + const setThreadError = useStableCallback( + makeThreadErrorSetter({ + routeThreadRef, + routeThreadKey, + draftId, + serverThreadRef: + serverThreadId !== undefined && serverThreadEnvironmentId !== undefined + ? { threadId: serverThreadId, environmentId: serverThreadEnvironmentId } + : null, + setLocalServerErrorsByThreadKey, + setLocalDraftErrorsByDraftId, + }), + ); const { pendingApprovals, pendingUserInputs, @@ -1636,6 +1650,17 @@ export default function ChatView(props: ChatViewProps) { ); const timelineEmpty = timelineEntries.length === 0 && !hasStreamedTimelineRows; const displayedTimelineKey = activeThreadKey ?? routeThreadKey; + const deferredChat = use(DeferredChatContext); + const [timelineInstance, setTimelineInstance] = useState({ + threadKey: displayedTimelineKey, + revision: 0, + }); + if (timelineInstance.threadKey !== displayedTimelineKey) { + setTimelineInstance({ + threadKey: displayedTimelineKey, + revision: timelineInstance.revision + (deferredChat ? 1 : 0), + }); + } const displayedThreadRef = parseScopedThreadKey(displayedTimelineKey); const { worktreeSetup, @@ -1786,6 +1811,7 @@ export default function ChatView(props: ChatViewProps) { const activeWorkspaceRoot = activeThreadWorktreePath ?? activeProjectCwd ?? undefined; const activeTerminalLaunchContext = terminalUiLaunchContext?.threadId === activeThreadId ? terminalUiLaunchContext : null; + // A draft without a project (the scratch folder) never shows git controls. const liveIsGitRepo = gitStatusQuery.data?.isRepo; // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- records live git repo status in the session cache read while the status query is unavailable useEffect(() => { @@ -1793,7 +1819,14 @@ export default function ChatView(props: ChatViewProps) { rememberCheckoutIsRepo(environmentId, gitStatusCwd, liveIsGitRepo); } }, [environmentId, gitStatusCwd, liveIsGitRepo]); - const isGitRepo = liveIsGitRepo ?? recallCheckoutIsRepo(environmentId, gitStatusCwd) ?? true; + const gitProjectIsScratch = + activeProject !== null && isScratchProject(activeProject, serverConfig?.scratchWorkspaceRoot); + const isGitRepo = + gitProjectIsScratch && !isServerThread + ? false + : (liveIsGitRepo ?? + recallCheckoutIsRepo(environmentId, gitStatusCwd) ?? + !gitProjectIsScratch); // Provider-native subagents cannot take messages; approvals and questions go to the top-level parent. const showProviderSubagentBar = isProviderSubagent; const composerMounted = !showProviderSubagentBar; @@ -1829,32 +1862,6 @@ export default function ChatView(props: ChatViewProps) { isGitRepo, showEnvironmentIndicator: showComposerEnvironmentIndicator, }); - const terminalShortcutLabelOptions = { - context: { - terminalFocus: true, - terminalOpen: Boolean(terminalUiState.terminalOpen), - }, - }; - const splitTerminalShortcutLabel = shortcutLabelForCommand( - keybindings, - "terminal.split", - terminalShortcutLabelOptions, - ); - const splitTerminalVerticalShortcutLabel = shortcutLabelForCommand( - keybindings, - "terminal.splitVertical", - terminalShortcutLabelOptions, - ); - const newTerminalShortcutLabel = shortcutLabelForCommand( - keybindings, - "terminal.new", - terminalShortcutLabelOptions, - ); - const closeTerminalShortcutLabel = shortcutLabelForCommand( - keybindings, - "terminal.close", - terminalShortcutLabelOptions, - ); const { needsLoadBalancing, @@ -1885,7 +1892,7 @@ export default function ChatView(props: ChatViewProps) { }); const { focusComposer: focusComposerStable, scheduleComposerFocus: scheduleComposerFocusStable } = - useComposerFocus(composerRef); + useComposerFocus(composerRef, layoutPaneFocusedRef); const canInterruptRunningThread = canOperateThread && deriveCanInterruptRunningThread(activeThread !== undefined, activeRuntime); const onInterrupt = async () => { @@ -1952,17 +1959,13 @@ export default function ChatView(props: ChatViewProps) { const queuedRunsControlRef = useRef(null); const { - activatePanelTerminalStable, + startTerminal, addTerminalContextToDraftStable, - addTerminalSurfaceStable, - closePanelTerminalStable, lastInvokedScriptByProjectId, runProjectScriptStable, runShellCommandStable, - setTerminalOpenStable, - splitPanelTerminalStable, - splitPanelTerminalVerticalStable, toggleTerminalVisibilityStable, + setTerminalOpenStable, shortcuts: terminalShortcuts, } = useChatTerminalActions({ environmentId, @@ -1972,7 +1975,6 @@ export default function ChatView(props: ChatViewProps) { activeThreadId, activeThreadRef, activeProject, - activeRightPanelSurface, runningTerminalIds, activeServerOrderedTerminalIds, activeTerminalLabelsById, @@ -1982,6 +1984,24 @@ export default function ChatView(props: ChatViewProps) { setTerminalUiLaunchContext, setThreadError, }); + // A hidden terminal pane stays in the chat's notch until cmd+J brings it back. + const terminalToggleShortcutLabel = shortcutLabelForCommand(keybindings, "terminal.toggle"); + const hasHiddenTerminal = + companionPanes && !terminalUiState.terminalOpen && terminalUiState.terminalIds.length > 0; + const hiddenTerminalChip = ( + hasHiddenTerminal + ? { + shortcutLabel: terminalToggleShortcutLabel, + running: activeThreadKnownSessions.some( + (session) => + terminalUiState.terminalIds.includes(session.target.terminalId) && + session.state.status === "running", + ), + onShow: toggleTerminalVisibilityStable, + } + : null + ) as HiddenTerminalChip | null; + const { saveProjectScript: saveProjectScriptStable, updateProjectScript: updateProjectScriptStable, @@ -1997,6 +2017,8 @@ export default function ChatView(props: ChatViewProps) { AuthPreviewOperateScope, ); const shownPreviewMiniPlayer = + layoutPane.visible && + !backgroundThread && activeThreadRef && activePreviewMiniPlayer && previewMiniPlayerVisible && @@ -2020,6 +2042,18 @@ export default function ChatView(props: ChatViewProps) { detailsCardInlineOpen: threadPanelInlineOpen, }).detailsCardMode, ); + const workspaceInDetails = !shouldShowComposerWorkspace({ + isServerThread, + embedded, + visibility: detailsVisibility, + }); + const openWorkspaceDetailsStable = useStableCallback(() => { + threadDetailsPanelRef.current?.openBranch(threadPanelPresentation); + }); + const openStartedWorkspaceStable = useStableCallback(() => { + if (workspaceInDetails) openWorkspaceDetailsStable(); + else branchToolbarRef.current?.openBranchPicker(); + }); const threadPanelOpen = useRightPanelStore((state) => selectThreadPanelOpen( state.threadPanelVisibilityByThreadKey, @@ -2082,16 +2116,6 @@ export default function ChatView(props: ChatViewProps) { visiblePullRequestCount, onDiffPanelOpen, }); - const { - closeSurface: closeRightPanelSurface, - closeOtherSurfaces: closeOtherRightPanelSurfacesStable, - closeSurfacesToRight: closeRightPanelSurfacesToRightStable, - closeAllSurfaces: closeAllRightPanelSurfacesStable, - } = useRightPanelSurfaceClose({ - threadRef: activeThreadRef, - terminalLabelsById: activeTerminalLabelsById, - }); - const closeRightPanelSurfaceStable = closeRightPanelSurface; const persistThreadSettingsForNextTurn = async (input: { threadId: ThreadId; createdAt: string; @@ -2187,13 +2211,32 @@ export default function ChatView(props: ChatViewProps) { setTimelineAnchor, }); + // Moving between panes keeps the workspace control the user chose in the pane it enters. + const threadEntryPaneFocusedRef = useRef(layoutPane.focused); + const keepsEnteredPaneFocus = useStableCallback(() => { + if (isReturnedSurfaceFocus()) return true; + if (threadEntryPaneFocusedRef.current || layoutPane.tabId === null) return false; + const activeArea = document.activeElement?.closest("[data-workspace-area]"); + return ( + activeArea?.closest("[data-layout-tab-id]")?.dataset.layoutTabId === + layoutPane.tabId + ); + }); + const focusOpenedThreadComposer = useStableCallback(() => { + if (!isCoveredByModal(composerOverlayElement)) focusComposerStable(); + }); useFocusComposerOnThreadOpen( activeThreadRef, workspaceLayoutElement, - !embedded, + !embedded && layoutPane.focused && !backgroundThread, terminalUiState.terminalOpen, - isCoarsePointer ? ignoreComposerFocus : focusComposerStable, + isCoarsePointer ? ignoreComposerFocus : focusOpenedThreadComposer, + keepsEnteredPaneFocus, ); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- records pane focus after thread entry reads it, so the next entry knows it moved between panes + useEffect(() => { + threadEntryPaneFocusedRef.current = layoutPane.focused; + }, [layoutPane.focused]); // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- window focus listener that refocuses the composer after the window regains focus on a fine pointer useEffect(() => { @@ -2208,6 +2251,7 @@ export default function ChatView(props: ChatViewProps) { } let frame: number | null = null; const onWindowFocus = () => { + if (!layoutPaneFocusedRef.current) return; if (frame !== null) window.cancelAnimationFrame(frame); // Wait an extra frame after window focus so the composer treats focus as an expansion request. frame = window.requestAnimationFrame(() => { @@ -2235,6 +2279,7 @@ export default function ChatView(props: ChatViewProps) { isCoarsePointer, isMobileViewport, terminalUiState.terminalOpen, + layoutPaneFocusedRef, ]); // Promotion drops draftId while a send may still be dispatching, so key by thread. @@ -2367,11 +2412,15 @@ export default function ChatView(props: ChatViewProps) { useLayoutEffect(() => { if (!composerOverlayElement) return; - const updateHeight = () => { - publishComposerOverlayHeightStable(composerOverlayElement.getBoundingClientRect().height); + const updateHeight = (entries: readonly ResizeObserverEntry[]) => { + const overlayEntry = entries.find((entry) => entry.target === composerOverlayElement); + publishComposerOverlayHeightStable( + overlayEntry?.borderBoxSize[0]?.blockSize ?? + composerOverlayElement.getBoundingClientRect().height, + ); }; - updateHeight(); + updateHeight([]); // The hero overlay fills the pane, so the composer's own growth only shows on its stack. const stack = isDraftHeroState ? composerOverlayElement.querySelector('[data-chat-composer-stack="true"]') @@ -2412,25 +2461,15 @@ export default function ChatView(props: ChatViewProps) { }) : null; const { - activateRightPanelSurfaceStable, - addDeviceSurfaceStable, - addDiffSurfaceStable, - addFilesSurfaceStable, - addPullRequestSurfaceStable, - addPullRequestsSurfaceStable, - closePreviewPanelStable, - copyRightPanelFilePathStable, - createBrowserSurface, - createBrowserSurfaceStable, - moveRightPanelSurfaceStable, + besideAvailable, + openBesideStable, onOpenToolDiffStable, onOpenTurnDiffStable, openChangesFromThreadPanelStable, - openFileSurfaceStable, - toggleRightPanelStable, toggleThreadPanelStable, shortcuts: rightPanelShortcuts, } = useRightPanelActions({ + startTerminal, activeThreadRef, activeProject, activeRightPanelSurface, @@ -2446,15 +2485,10 @@ export default function ChatView(props: ChatViewProps) { canOperatePreview, diffOpen, previewPanelOpen, - rightPanelOpen, supportsPullRequests, pullRequestsSurfaceAvailable, - gitCwd, - activeProjectCwd, onDiffPanelOpen, - closeRightPanelSurface, }); - const pullRequestSurfaceAvailable = supportsPullRequests && pullRequestPanelTarget !== null; const { acknowledgeActiveThreadWoke, activeBranchMismatchKey, @@ -2523,9 +2557,12 @@ export default function ChatView(props: ChatViewProps) { entry.enabled && entry.isAvailable, ); + const usablePendingHandoffSelection = pendingHandoffSelectionUsable + ? pendingHandoffSelection + : null; const pendingHandoff = resolvePendingHandoff( serverProjection, - pendingHandoffSelectionUsable ? pendingHandoffSelection : null, + usablePendingHandoffSelection, providerStatuses, ); const compactThreadUnavailable = @@ -2544,15 +2581,17 @@ export default function ChatView(props: ChatViewProps) { pendingApprovals.length > 0 || pendingUserInputs.length > 0; const compactDisabled = compactThreadUnavailable; - const compactDisabledReason = compactDisabled - ? !canOperateThread - ? "This connection cannot change threads." - : !activeProject - ? "Choose a project before compacting" + const compactUnavailableReason = !compactThreadUnavailable + ? null + : isWorking + ? "Compact when the agent finishes" + : pendingApprovals.length > 0 || pendingUserInputs.length > 0 + ? "Answer the pending request first" : !manualCompactionProviderAvailable - ? "Compaction is unavailable for this provider" - : "Compacting is unavailable right now" - : null; + ? "This provider cannot compact on request" + : !activeThreadHasCompactableConversation + ? "Nothing to compact yet" + : "Compacting is unavailable right now"; // The context window is the current conversation's, so a handoff destination never inherits it. const resumeCompactionTokens = activeContextWindow && @@ -2650,7 +2689,7 @@ export default function ChatView(props: ChatViewProps) { : null; // The working bar names the request while its row stays actionable. const firstPendingApproval = pendingApprovals[0] ?? null; - const timelinePendingRequest = ((): TimelinePendingRequest | null => { + const timelinePendingRequest = (() => { if (firstPendingApproval) { return { kind: "approval", @@ -2669,7 +2708,7 @@ export default function ChatView(props: ChatViewProps) { activeThreadShell?.hasPendingUserInput !== false, } : null; - })(); + })() as TimelinePendingRequest | null; const limitRecovery = activeThreadShell?.limitRecovery ?? null; const limitedRunId = serverRuntime?.status === "failed" && serverRuntime.lastErrorClass === "usage_limit" @@ -2720,7 +2759,7 @@ export default function ChatView(props: ChatViewProps) { const onToggleTimelineUsageLimitSnoozeStable = useStableCallback( onToggleTimelineUsageLimitSnooze, ); - const timelineUsageLimit = ((): TimelineUsageLimit | null => { + const timelineUsageLimit = (() => { if (limitedRunId === null || limitThreadId === null) return null; return { runId: limitedRunId, @@ -2728,7 +2767,7 @@ export default function ChatView(props: ChatViewProps) { snoozed: limitSnoozed, onToggleSnooze: limitResetAt === null ? null : onToggleTimelineUsageLimitSnoozeStable, }; - })(); + })() as TimelineUsageLimit | null; const focusComposerAtEnd = () => composerRef.current?.focusAtEnd(); const focusComposerAtEndStable = useStableCallback(focusComposerAtEnd); const composerBannerItems = (() => { @@ -2841,7 +2880,9 @@ export default function ChatView(props: ChatViewProps) { const getShortcutContext = (eventTarget: EventTarget | null = document.activeElement) => ({ terminalFocus: getTerminalFocusOwner() !== null, terminalOpen: Boolean(terminalUiState.terminalOpen), - previewFocus: isPreviewFocused(), + // A thread's own toggles answer from its diff, files and pull request tabs; only a + // browser keeps the keys it shares with them (mod+l). + previewFocus: isBrowserFocused(), previewOpen: previewPanelOpen, rightPanelOpen, editableFocus: isEditableFocused(eventTarget), @@ -2856,7 +2897,6 @@ export default function ChatView(props: ChatViewProps) { isWeb: !isElectron, isDesktop: isElectron, }); - const getShortcutContextStable = useStableCallback(getShortcutContext); const timelineSkills = activeProviderStatus ? resolveProviderSkillsForCwd(activeProviderStatus, gitCwd) : EMPTY_PROVIDER_SKILLS; @@ -2929,15 +2969,6 @@ export default function ChatView(props: ChatViewProps) { }, }); - // A focused desktop browser page forwards these chords as menu actions. - // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- subscribes to desktop bridge menu actions that toggle the right panel - useEffect(() => { - if (embedded) return; - return window.desktopBridge?.onMenuAction((action) => { - if (action === "rightPanel.toggle") toggleRightPanelStable(); - }); - }, [embedded, toggleRightPanelStable]); - usePasteToFocusComposer(embedded ? null : activeThreadId, composerRef); // A delegated task is a remote thread's subagent. const isSubagentThread = @@ -2959,11 +2990,6 @@ export default function ChatView(props: ChatViewProps) { if (activeThread) setThreadError(activeThread.id, message); }, }); - // A maximized panel covers the terminal drawer the command runs in. - const runSideChatShellCommandStable = useStableCallback((command: string) => { - if (activeThreadRef) useRightPanelStore.getState().setMaximized(activeThreadRef, false); - runShellCommandStable(command); - }); // Main-thread surfaces show this reason; `/side` sent anyway gets it as a toast. const sideChatDisabledReason = activeThread?.lineage.sideChat === true @@ -2983,7 +3009,6 @@ export default function ChatView(props: ChatViewProps) { ) ? "Available once the thread's worktree is set up." : null; - const openSideChatStable = useStableCallback(() => void sideChat.open()); const sendSideChatStable = useStableCallback((prompt: string) => sideChat.open(prompt)); const { onConfirmRevertStable, onForkFromRunStable, onRevertTimelineTurnStable } = useTimelineRewind({ @@ -3069,7 +3094,7 @@ export default function ChatView(props: ChatViewProps) { : (timelineMessages.findLast( (message) => message.role === "user" && message.runId === failedRunId, ) ?? null); - const { onSend, onRetryFailedRun, retryingFailedRunId } = useComposerSend({ + const { onSend, withdrawQueuedSend, onRetryFailedRun, retryingFailedRunId } = useComposerSend({ environmentId, routeThreadKey, currentRouteThreadKeyRef, @@ -3147,6 +3172,7 @@ export default function ChatView(props: ChatViewProps) { persistThreadSettingsForNextTurn, linkCheckoutPullRequest, }); + const withdrawQueuedSendStable = useStableCallback(withdrawQueuedSend); const onRetryFailedRunStable = useStableCallback(onRetryFailedRun); const canRetryFailedRun = @@ -3158,8 +3184,9 @@ export default function ChatView(props: ChatViewProps) { const trayStatusItems = useChatBannerLane({ environmentId, activeProviderStatus, + selectedModel: composerSelectedModelSelection?.model ?? activeThread?.modelSelection.model, providerStatuses, - usageLimitSources, + usageLimitSources: serverConfig?.usageLimitSources ?? EMPTY_USAGE_LIMIT_SOURCES, canSwitchProvider: lockedProvider === null || supportsProviderSwitchingViaHandoff, providerStatus: visibleProviderStatus, providerStatusDismissalKey, @@ -3176,14 +3203,19 @@ export default function ChatView(props: ChatViewProps) { ? (serverRuntime?.lastErrorCode ?? null) : null, threadErrorBannerKey, - onRetry: canRetryFailedRun ? () => void onRetryFailedRunStable() : null, + onRetry: !canRetryFailedRun + ? null + : failedRunId !== null && retryableWorkspacePreparationRunIds.has(failedRunId) + ? () => onRetryWorkspacePreparationStable(failedRunId) + : () => void onRetryFailedRunStable(), retrying: retryingFailedRunId !== null, setThreadError, setThreadErrorBannerDismissTick, }); + // The empty list is the shared constant, so an empty tray keeps the composer element memoized. const trayBannerItems = - trayStatusItems.length === 0 - ? composerBannerItems + trayStatusItems.length === 0 && composerBannerItems.length === 0 + ? EMPTY_COMPOSER_BANNER_ITEMS : [...trayStatusItems, ...composerBannerItems]; const onWorktreeSetupWorkLocally = () => { @@ -3258,21 +3290,15 @@ export default function ChatView(props: ChatViewProps) { })} onEditQueuedRun={queuedRunEdit.beginEdit} onCancelEdit={queuedRunEdit.cancelEdit} + onRemovePending={withdrawQueuedSendStable} timestampFormat={timestampFormat} onOpenThread={onOpenRelatedThreadStable} onImageExpand={onExpandTimelineImageStable} /> ) : null; - const resendAfterWorkLocally = () => { - setWorkLocallyResendDraftId(null); - void onComposerSendStable(); - }; - const resendAfterWorkLocallyStable = useStableCallback(resendAfterWorkLocally); - const workLocallyResendReady = - workLocallyResendDraftId !== null && - workLocallyResendDraftId === draftId && - isLocalDraftThread && + // Transient send blockers must also hold surface annotations until the chat can send. + const chatSendReady = !isSendBusy && !isRevertingCheckpoint && !threadDetailLoading && @@ -3281,6 +3307,37 @@ export default function ChatView(props: ChatViewProps) { !activeEnvironmentUnavailable && !activePendingUserInput && !feedbackUploading; + // The thread's surface tabs reach this chat through its bridge: diffs and + // files refresh on its workspace changes, browser annotations send from its + // composer, terminal selections go into it. + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- publishes the mounted chat bridge to the external surface registry and releases it on cleanup + useEffect(() => { + if (!activeThreadRef) return; + return publishThreadChatBridge(activeThreadRef, { + workspaceMutationId, + sendReady: chatSendReady, + sendAnnotation: (annotation, image) => { + sendPreviewAnnotationStable(annotation, image); + }, + addTerminalContext: addTerminalContextToDraftStable, + terminalLaunchContext: activeTerminalLaunchContext ?? null, + }); + }, [ + activeTerminalLaunchContext, + activeThreadRef, + addTerminalContextToDraftStable, + chatSendReady, + workspaceMutationId, + sendPreviewAnnotationStable, + ]); + // `chatSendReady` (above) holds every state that makes `onSend` bail and wait. + const draftSendReady = isLocalDraftThread && chatSendReady; + const resendAfterWorkLocallyStable = useStableCallback(() => { + setWorkLocallyResendDraftId(null); + void onComposerSendStable(); + }); + const workLocallyResendReady = + workLocallyResendDraftId !== null && workLocallyResendDraftId === draftId && draftSendReady; // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- switches the draft to local mode, then resends the waiting message once sending is idle useEffect(() => { if ( @@ -3305,6 +3362,30 @@ export default function ChatView(props: ChatViewProps) { setDraftThreadContext, workLocallyResendReady, ]); + // Send on the no-projects home lands here with the message carried in; + // send it once, as soon as the same readiness holds. + const sendOnOpenDraftId = useDraftSendOnOpenStore((store) => store.draftId); + const sendOnOpenReady = + sendOnOpenDraftId !== null && sendOnOpenDraftId === draftId && draftSendReady; + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- delivers the pending cross-pane send request to the mounted composer once its provider is ready + useEffect(() => { + if ( + !sendOnOpenReady || + sendInFlightRef.current || + feedbackUploadsInFlightRef.current.has(routeThreadKey) + ) { + return; + } + useDraftSendOnOpenStore.setState({ draftId: null }); + // Out of the commit: sending flushes the composer synchronously. + queueMicrotask(() => void onComposerSendStable()); + }, [ + routeThreadKey, + sendOnOpenReady, + sendInFlightRef, + feedbackUploadsInFlightRef, + onComposerSendStable, + ]); const shownThreadSyncPhase = activeEnvironmentUnavailable ? null : delayedThreadSyncPhase; const hasThreadNow = @@ -3323,35 +3404,6 @@ export default function ChatView(props: ChatViewProps) { childInputNow?.waitingLabel ?? null, queuedRunCount === 0 ? null : `${queuedRunCount} queued`, ].filter((part) => part !== null); - // The end of the timeline holds the thread's present and next turns: what it is doing now, - // its lifecycle status, guest messages awaiting you, then your queued messages. - const timelineFooter = - pendingHandoff === null && - !hasThreadNow && - threadStatusLine === null && - pendingGuestMessageCount === 0 && - !hasQueuedTurns ? null : ( - <> - {pendingHandoff === null ? null : ( -
- -
- )} - {threadStatusLine} - {pendingGuestMessageCount > 0 && isServerThread && activeThread ? ( - - ) : null} - - ); const pendingSidebarFileDrops = useSidebarPendingFileDropStore((state) => state.pending); const consumePendingFileDrop = useSidebarPendingFileDropStore( @@ -3395,82 +3447,41 @@ export default function ChatView(props: ChatViewProps) { (folders: Parameters[0]) => composerRef.current?.addDroppedFolders(folders), ); - // Above the early return, where React Compiler can cache it for the side chat pane. - const sideChatParentPanel = { - openToolDiff: onOpenToolDiffStable, - runShellCommand: runSideChatShellCommandStable, - }; if (!activeThread) { return embedded ? null : ; } - const rightPanelContent = activeThreadRef ? ( - 1 - ? addPullRequestsSurfaceStable - : undefined, - }} - files={{ - projectId: activeProject?.id, - projectName: activeProject?.title ?? null, - workspaceRoot: activeWorkspaceRoot, - availableEditors, - pendingSurfaceIds: pendingFileSurfaceIds, - onOpenFile: openFileSurfaceStable, - onPendingChange: handleFilePendingChangeStable, - }} + // The end of the timeline holds the thread's present and next turns: what it is doing now, + // its lifecycle status, guest messages awaiting you, then your queued messages. + // Built outside the condition, where the compiler memoizes it. + const timelineFooterContent = ( + 0 && isServerThread ? activeThreadRef : null} + onImageExpand={onExpandTimelineImageStable} + onFileOpen={openFileAttachmentStable} /> - ) : null; + ); + const timelineFooter = + pendingHandoff === null && + !hasThreadNow && + threadStatusLine === null && + pendingGuestMessageCount === 0 && + !hasQueuedTurns + ? null + : timelineFooterContent; + const panelToggleControlProps = { - terminalAvailable: activeProject !== null, - terminalOpen: terminalUiState.terminalOpen, - terminalShortcutLabel: shortcutLabelForCommand(keybindings, "terminal.toggle"), threadPanelOpen, threadPanelPresentation, threadPanelPopoverHandle, threadPanelShortcutLabel: shortcutLabelForCommand(keybindings, "threadPanel.toggle"), - rightPanelAvailable: activeProject !== null, - rightPanelOpen, - rightPanelShortcutLabel: shortcutLabelForCommand(keybindings, "rightPanel.toggle"), - onToggleTerminal: toggleTerminalVisibilityStable, onToggleThreadPanel: toggleThreadPanelStable, - onToggleRightPanel: toggleRightPanelStable, } satisfies PanelLayoutControlsProps; const panelToggleControls = ( ); - const threadPanelHeaderControl = ; + const threadPanelHeaderControl = ( + + + + ); const panelLayoutControls = ( - - + {panelToggleControls} - + ); const workspaceFileDropHandlers = makeWorkspaceFileDropHandlers({ setDragActive: setIsWorkspaceFileDragActive, @@ -3512,35 +3520,28 @@ export default function ChatView(props: ChatViewProps) { {rightPanelControlsAtRoot && !embedded ? panelLayoutControls : null}
{embedded ? null : ( -
{isElectron && rightPanelControlsAtRoot ? ( ) : null} {!rightPanelControlsAtRoot && !rightPanelControlsInPanel ? panelLayoutControls : null} @@ -3555,13 +3556,20 @@ export default function ChatView(props: ChatViewProps) { parentThreadLink={headerParentThreadLink} onOpenThread={onOpenRelatedThreadStable} rightPanelOpen={inlineRightPanelOwnsTitleBar} + trailingInset={ + !rightPanelControlsAtRoot && !rightPanelControlsInPanel ? headerControlsInset : null + } onNewThreadInProject={handleNewThreadInActiveProjectStable} + besideAvailable={besideAvailable} + onOpenBeside={openBesideStable} + newTabShortcutLabel={shortcutLabelForCommand(keybindings, "tab.new")} + hiddenTerminal={hiddenTerminalChip} {...(activeDraftLogicalProjectKey ? { onOpenProjectSettings: handleOpenDraftProjectSettingsStable } : {})} {...(threadFindEnabled ? { onFindInThread: openThreadFindStable } : {})} /> -
+ )}
@@ -3586,10 +3594,13 @@ export default function ChatView(props: ChatViewProps) {
- {showScrollToBottom && !isThreadFindActive && ( - scrollToEnd(true)} - /> - )} + scrollToEnd(true)} + />
{/* Input bar — docked at the bottom; an empty draft centers its headline above it. */} @@ -3720,14 +3735,15 @@ export default function ChatView(props: ChatViewProps) { data-chat-composer-overlay="true" className={ isDraftHeroState - ? "pointer-events-none absolute inset-0 z-20 flex flex-col justify-end pt-1.5 sm:pt-2" + ? "pointer-events-none absolute inset-0 z-20 flex flex-col justify-end pt-1.5 [container:draft-hero/size] sm:pt-2" : "pointer-events-none absolute inset-x-0 bottom-0 z-20 pt-1.5 sm:pt-2" } > {isDraftHeroState ? ( -
+ // A short split pane has no room above the composer. +
{hasThreadNow ? ( -
+
step.status !== "completed")) ? ( + shouldShowComposerTasks({ + steps: composerTasks.steps, + planRunSettled: isLatestRunSettled( + activeActivityRun ?? activeLatestRun, + activeRuntime, + ), + isSendBusy, + }) ? (
- - {embedded ? null : ( - - )}
- {rightPanelPresent && activeThreadRef && !embedded ? ( - - - {panelToggleControls} -
- ) : null - } - surfaces={renderedRightPanelSurfaces} - environmentId={activeThreadRef.environmentId} - activeSurfaceId={renderedRightPanelSurface?.id ?? null} - pendingSurfaceIds={pendingFileSurfaceIds} - previewSessions={activePreviewState.sessions} - desktopByTabId={activePreviewState.desktopByTabId} - previewRuntimeTabId={resolvePreviewRuntimeTabId} - terminalLabelsById={activeTerminalLabelsById} - onActivate={activateRightPanelSurfaceStable} - onCloseSurface={closeRightPanelSurfaceStable} - onRenameDevice={(surfaceId, title) => { - if (activeThreadRef) - useRightPanelStore.getState().renameDevice(activeThreadRef, surfaceId, title); - }} - onCloseOtherSurfaces={closeOtherRightPanelSurfacesStable} - onCloseSurfacesToRight={closeRightPanelSurfacesToRightStable} - onCloseAllSurfaces={closeAllRightPanelSurfacesStable} - onMoveSurface={moveRightPanelSurfaceStable} - onCopyFilePath={copyRightPanelFilePathStable} - onAddBrowser={() => createBrowserSurface()} - onAddBrowserInProfile={createBrowserSurfaceStable} - onAddTerminal={addTerminalSurfaceStable} - onAddDiff={addDiffSurfaceStable} - onAddFiles={addFilesSurfaceStable} - onAddPullRequest={addPullRequestSurfaceStable} - onAddPullRequests={addPullRequestsSurfaceStable} - onAddDevice={addDeviceSurfaceStable} - onAddSideChat={sideChatsOffered ? openSideChatStable : undefined} - browserAvailable={canOperatePreview && browserAvailable} - terminalAvailable={activeProject !== null && canOperateTerminal} - diffAvailable={isServerThread && isGitRepo} - filesAvailable={activeProject !== null} - pullRequestAvailable={pullRequestSurfaceAvailable} - pullRequestsAvailable={pullRequestsSurfaceAvailable} - deviceAvailable={activeThreadRef !== null} - sideChatAvailable={sideChat.available} - sideChatDisabledReason={sideChatDisabledReason} - > - {rightPanelContent} - - ) : null} - [0]; + readonly handoffSelection: Parameters[1]; + readonly providerEnvironmentId: EnvironmentId; + readonly statusLine: ReactNode; + readonly guestThreadRef: ScopedThreadRef | null; + readonly onImageExpand: ComponentProps["onImageExpand"]; + readonly onFileOpen: ComponentProps["onFileOpen"]; +}) { + const providersByEnvironment = useEnvironmentProviders(); + const providers = providersByEnvironment.get(props.providerEnvironmentId) ?? EMPTY_PROVIDERS; + const pendingHandoff = resolvePendingHandoff(props.projection, props.handoffSelection, providers); + return ( + <> + {pendingHandoff === null ? null : ( +
+ +
+ )} + {props.statusLine} + {props.guestThreadRef === null ? null : ( + + )} + + ); +} + function PreviewSessionSync(props: { readonly threadRef: ScopedThreadRef }) { usePreviewSession(props.threadRef); return null; diff --git a/apps/web/src/components/CommandPalette.composition.browser.test.tsx b/apps/web/src/components/CommandPalette.composition.browser.test.tsx index 54801ba13cd94..5fc61a0207e77 100644 --- a/apps/web/src/components/CommandPalette.composition.browser.test.tsx +++ b/apps/web/src/components/CommandPalette.composition.browser.test.tsx @@ -54,7 +54,10 @@ vi.mock("../hooks/useSettings", async (original) => ({ useUpdateEnvironmentSettings: () => () => undefined, })); vi.mock("../hooks/useScratchProject", () => ({ - useScratchProject: () => ({ scratchEnvironmentId: () => null }), + useScratchProject: () => ({ + scratchEnvironmentId: () => null, + scratchUnavailableReason: () => null, + }), })); vi.mock("../hooks/useNewProject", () => ({ useNewProject: () => async () => undefined })); vi.mock("../hooks/useHandleNewThread", () => ({ diff --git a/apps/web/src/components/CommandPalette.logic.test.ts b/apps/web/src/components/CommandPalette.logic.test.ts index 16d2b5193bfd1..344293b420e81 100644 --- a/apps/web/src/components/CommandPalette.logic.test.ts +++ b/apps/web/src/components/CommandPalette.logic.test.ts @@ -10,6 +10,8 @@ import { buildLinkedThreadActionItems, filterPinnedBrowseEntries, filterCommandPaletteGroups, + buildRootGroups, + RECENT_THREAD_LIMIT, reduceCommandPaletteUiState, resolveAddProjectEntry, createCommandPaletteSearchState, @@ -163,6 +165,47 @@ function makeThread(overrides: Partial = {}): Thread { } describe("buildThreadActionItems", () => { + it("keeps main's empty-query actions and twelve recent threads in order", () => { + const items = buildThreadActionItems({ + threads: Array.from({ length: 13 }, (_, index) => + makeThread({ + id: ThreadId.make(`daily-${index}`), + title: "Daily T3 Code trace hotspot check", + latestUserMessageAt: `2026-03-${String(index + 1).padStart(2, "0")}T00:00:00.000Z`, + }), + ), + projectByKey: new Map(), + icon: null, + runThread: async () => undefined, + }); + const action = { ...items[0]!, value: "action:settings", title: "Open settings" }; + const groups = filterCommandPaletteGroups({ + activeGroups: buildRootGroups({ + actionItems: [action], + recentThreadItems: items.slice(0, RECENT_THREAD_LIMIT), + }), + query: "", + isInSubmenu: false, + projectSearchItems: [], + threadSearchItems: items, + }); + expect(groups.flatMap((group) => group.items.map((item) => item.value))).toEqual([ + action.value, + ...items.slice(0, 12).map((item) => item.value), + ]); + const matches = filterCommandPaletteGroups({ + activeGroups: [], + query: "dail", + isInSubmenu: false, + projectSearchItems: [], + threadSearchItems: items, + now: Date.parse("2026-03-14T00:00:00.000Z"), + }); + expect(matches.flatMap((group) => group.items.map((item) => item.value))).toEqual( + items.toReversed().map((item) => item.value), + ); + }); + it("keeps threads apart when two environments share a thread ID", async () => { const local = makeThread(); const remote = makeThread({ environmentId: EnvironmentId.make("remote-copy") }); diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index 233e681efa035..c15827b30d286 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -36,7 +36,7 @@ import { type Project, type SidebarThreadSummary, type Thread } from "../types"; import { isSidebarHiddenThread } from "./Sidebar.logic"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; -export const RECENT_THREAD_LIMIT = 3; +export const RECENT_THREAD_LIMIT = 12; export const ITEM_ICON_CLASS = "size-3.5 text-icon-muted"; export const ADDON_ICON_CLASS = "size-4"; @@ -605,19 +605,16 @@ export function filterCommandPaletteGroups(input: { const searchQuery = isActionsFilter ? input.query.slice(1) : input.query; const normalizedQuery = foldSearchQuery(searchQuery); - const isActionGroup = (group: CommandPaletteGroup) => - group.value === "actions" || group.value === "settings"; - if (normalizedQuery.length === 0) { if (isActionsFilter) { - return input.activeGroups.filter(isActionGroup); + return input.activeGroups.filter((group) => group.value === "actions"); } return [...input.activeGroups]; } let baseGroups = [...input.activeGroups]; if (isActionsFilter) { - baseGroups = baseGroups.filter(isActionGroup); + baseGroups = baseGroups.filter((group) => group.value === "actions"); } else if (!input.isInSubmenu) { baseGroups = baseGroups.filter((group) => group.value !== "recent-threads"); } @@ -649,21 +646,11 @@ export function filterCommandPaletteGroups(input: { }); } if (input.settingsSearchItems && input.settingsSearchItems.length > 0) { - // Settings matches join the root Settings actions so the list shows one Settings group. - const settingsIndex = searchableGroups.findIndex((group) => group.value === "settings"); - const settingsGroup = searchableGroups[settingsIndex]; - if (settingsGroup) { - searchableGroups[settingsIndex] = { - ...settingsGroup, - items: [...settingsGroup.items, ...input.settingsSearchItems], - }; - } else { - searchableGroups.push({ - value: "settings-search", - label: "Settings", - items: input.settingsSearchItems, - }); - } + searchableGroups.push({ + value: "settings-search", + label: "Settings", + items: input.settingsSearchItems, + }); } } @@ -814,9 +801,11 @@ export function getCommandPaletteMode(input: { export function buildRootGroups(input: { actionItems: ReadonlyArray; recentThreadItems: ReadonlyArray; - settingsItems?: ReadonlyArray; }): CommandPaletteGroup[] { const groups: CommandPaletteGroup[] = []; + if (input.actionItems.length > 0) { + groups.push({ value: "actions", label: "Actions", items: input.actionItems }); + } if (input.recentThreadItems.length > 0) { groups.push({ value: "recent-threads", @@ -824,12 +813,6 @@ export function buildRootGroups(input: { items: input.recentThreadItems, }); } - if (input.actionItems.length > 0) { - groups.push({ value: "actions", label: "Actions", items: input.actionItems }); - } - if (input.settingsItems && input.settingsItems.length > 0) { - groups.push({ value: "settings", label: "Settings", items: input.settingsItems }); - } return groups; } diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 1c989af4961cc..a49276952a1ba 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -73,6 +73,8 @@ import { RotateCcwIcon, SettingsIcon, MonitorIcon, + SquareSplitHorizontalIcon, + SquareSplitVerticalIcon, PinIcon, PinOffIcon, PlugIcon, @@ -111,7 +113,7 @@ import { copyPromptLink, promptLinkRepositoryOf, } from "./promptLinks/copyPromptLink"; -import { useClientSettings } from "../hooks/useSettings"; +import { useClientSettings, useScrollingWorkspacesEnabled } from "../hooks/useSettings"; import { useStableCallback } from "../hooks/useStableCallback"; import { readLocalApi } from "../localApi"; import { configuredHostedAppUrl } from "../hostedPairing"; @@ -170,6 +172,7 @@ import { resolvePastedPath } from "@t3tools/shared/path"; import { dispatchCommandPalette, getCommandPaletteReturnFocus, + commandPaletteFocus, useCommandPaletteMount, useCommandPaletteStore, } from "../commandPaletteStore"; @@ -232,6 +235,12 @@ import { } from "./CommandPalette.logic"; import { resolveEnvironmentOptionLabel } from "./BranchToolbar.logic"; import { CommandPaletteContent, type PendingInputSelection } from "./CommandPaletteContent"; +import { buildLayoutPaletteItems, buildOpenBesidePaletteItem } from "../layout/layoutPaletteItems"; +import { + findTerminalSplitTarget, + queueTerminalFocusFromPalette, + restoreTerminalFocusFromPalette, +} from "../lib/terminalFocus"; import { selectCommandPaletteClientSettings, useEnvironmentLookups, @@ -273,22 +282,14 @@ import { stackedThreadToast, toastManager } from "./ui/toast"; import { suggestAppAccessForRemote } from "./sourceControlAppAccessToast"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; import { ComposerHandleContext, useComposerHandleContext } from "../composerHandleContext"; +import { returnKeyboardToFocusedPane } from "../layout/layoutPaneContext"; +import { useOpenLayoutPage } from "../layout/pageNavigation"; import type { ChatComposerHandle } from "./chat/ChatComposer"; import { pickableProjectGroups } from "@t3tools/client-runtime/state/project-grouping"; import { buildSidebarProjectPickerEntries } from "../sidebarProjectGrouping"; import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons"; -import { readPullRequestListPreferences } from "~/components/pullRequest/pullRequestListPreferences"; import { useThreadRouteTarget } from "../hooks/useThreadRouteTarget"; -// Root actions listed under Settings, after Actions. -const SETTINGS_ACTION_VALUES: ReadonlySet = new Set([ - "action:change-theme", - "action:change-appearance", - "action:theme-editor", - "action:settings", - "action:project-settings", -]); - const EMPTY_BROWSE_ENTRIES: FilesystemBrowseResult["entries"] = []; const ADD_PROJECT_ENTRY_GROUP = "add-project"; const NEW_THREAD_IN_GROUP = "projects"; @@ -326,6 +327,14 @@ export function CommandPalette({ children }: { children: ReactNode }) { const openChildSearch = (mode: "files" | "content", context: CommandPaletteReturnContext) => dispatchCommandPalette({ _tag: "OpenChildSearch", mode, context }); const composerHandleRef = useRef(null); + const dismiss = () => { + setOpen(false); + commandPaletteFocus.dismissedRef.current = true; + }; + const leaveOverlay = () => { + if (state.parentSearch) dispatchCommandPalette({ _tag: "BackToCommand" }); + else dismiss(); + }; return ( @@ -335,10 +344,11 @@ export function CommandPalette({ children }: { children: ReactNode }) { onOpenChange={(open, eventDetails) => { if (!open && eventDetails.reason === "escape-key" && state.mode !== "command") { eventDetails.cancel(); - dispatchCommandPalette({ _tag: "BackToCommand" }); + leaveOverlay(); return; } - setOpen(open); + if (open) setOpen(true); + else dismiss(); }} >
@@ -348,6 +358,7 @@ export function CommandPalette({ children }: { children: ReactNode }) { mode={state.mode} session={state.session} openIntent={state.openIntent} + dismiss={dismiss} setOpen={setOpen} openChildSearch={openChildSearch} initialContext={state.restoredSearch} @@ -362,6 +373,7 @@ function CommandPaletteDialog(props: { readonly mode: SearchOverlayMode; readonly session: number; readonly openIntent: CommandPaletteOpenIntent | null; + readonly dismiss: () => void; readonly setOpen: (open: boolean) => void; readonly openChildSearch: ( mode: "files" | "content", @@ -389,18 +401,29 @@ function CommandPaletteDialog(props: { data-testid="command-palette" // Focus goes to the composer, or back to what opened the palette (Settings' Add project) when there is none. finalFocus={() => { - if (restoreWorkspaceFocusFromPalette()) return false; + if (restoreWorkspaceFocusFromPalette() || restoreTerminalFocusFromPalette()) return false; const returnFocus = getCommandPaletteReturnFocus(); if (returnFocus === false) return false; + const opener = commandPaletteFocus.openerRef.current; + // Dismissed, it gives the keyboard back to an opener outside the panes (the sidebar). + if ( + commandPaletteFocus.dismissedRef.current && + opener?.isConnected && + !opener.closest("[data-layout-strip]") + ) { + return opener; + } + // In the layout the keyboard goes back to the focused pane, which an + // action may have changed (a surface opened beside the chat). + if (returnKeyboardToFocusedPane()) return false; const composer = composerHandleRef?.current; - // Behind the Settings modal the composer is inert and cannot take focus. - if (!composer || document.querySelector("[data-app-modal]")) return returnFocus; - composer.focusAtEnd(); - return false; - }} - onBackdropPointerDown={() => { - props.setOpen(false); + if (composer && !document.querySelector("[data-app-modal]")) { + composer.focusAtEnd(); + return false; + } + return returnFocus; }} + onBackdropPointerDown={props.dismiss} > {props.mode === "files" ? ( @@ -431,7 +454,9 @@ function OpenCommandPaletteDialog(props: { ) => void; readonly initialContext: CommandPaletteReturnContext | undefined; }) { + const composerHandleRef = useComposerHandleContext(); const navigate = useNavigate(); + const openLayoutPage = useOpenLayoutPage(); const pathname = useLocation({ select: (location) => location.pathname }); const { openChildSearch, setOpen } = props; // Keyed by palette session: the intent and restored search only seed this @@ -460,6 +485,7 @@ function OpenCommandPaletteDialog(props: { const deferredQuery = useDeferredValue(query); const isActionsOnly = deferredQuery.startsWith(">"); const [highlightedItemValue, setHighlightedItemValue] = useState(null); + const [opener] = useState(() => commandPaletteFocus.openerRef.current); const resultListRef = useRef(null); // A pushed view drops the typed highlight until the user moves it again. const highlightClearedRef = useRef(viewStack.length > 0); @@ -470,13 +496,19 @@ function OpenCommandPaletteDialog(props: { setHighlightedItemValue(null); } const clientSettings = useClientSettings(selectCommandPaletteClientSettings); + const scrollingWorkspaces = useScrollingWorkspacesEnabled(); const createProject = useAtomCommand(projectEnvironment.create, { reportFailure: false, }); const updateProject = useOrchestrationCommand(projectEnvironment.update, { label: "project unarchive", }); - const { scratchEnvironmentId, scratchWorkspaceRootFor, startScratchThread } = useScratchProject(); + const { + scratchEnvironmentId, + scratchWorkspaceRootFor, + scratchUnavailableReason, + startScratchThread, + } = useScratchProject(); const startTriage = useStartTriage(); const lookupRepository = useAtomQueryRunner(sourceControlEnvironment.repository, { reportFailure: false, @@ -741,6 +773,9 @@ function OpenCommandPaletteDialog(props: { const canTriage = useAtomValue(serverEnvironment.prepareTriage.permissionAtom(triageEnvironmentId)) && supportsTriage(triageEnvironmentId); + const scratchOffReason = scratchUnavailableReason( + currentProjectEnvironmentId ?? primaryEnvironmentId, + ); const currentProject = resolveCommandPaletteProject( projectByKey, currentProjectEnvironmentId, @@ -912,7 +947,30 @@ function OpenCommandPaletteDialog(props: { ); }, }); - if (scratchTargetEnvironmentId === null) return projectItems; + const noProjectItem = + scratchTargetEnvironmentId !== null + ? { + kind: "action" as const, + value: NEW_THREAD_NO_PROJECT_VALUE, + searchTerms: ["no project", "without project", "none"], + title: "No project", + icon: , + shortcutCommand: "chat.newWithoutProject" as const, + run: () => startScratchThread(scratchTargetEnvironmentId), + } + : scratchOffReason !== null + ? { + kind: "action" as const, + value: NEW_THREAD_NO_PROJECT_VALUE, + searchTerms: ["no project", "without project", "none"], + title: "No project", + description: scratchOffReason, + disabled: true, + icon: , + run: async () => {}, + } + : null; + if (noProjectItem === null) return projectItems; const noProjectIndex = newThreadNoProjectIndex( projectPickerEntries.map(({ group, targetProject, isPreferred }) => ({ @@ -926,15 +984,7 @@ function OpenCommandPaletteDialog(props: { ); return [ ...projectItems.slice(0, noProjectIndex), - { - kind: "action" as const, - value: NEW_THREAD_NO_PROJECT_VALUE, - searchTerms: ["no project", "without project", "none"], - title: "No project", - icon: , - shortcutCommand: "chat.newWithoutProject" as const, - run: () => startScratchThread(scratchTargetEnvironmentId), - }, + noProjectItem, ...projectItems.slice(noProjectIndex), ]; })(), @@ -951,13 +1001,39 @@ function OpenCommandPaletteDialog(props: { (item) => projectJumpItems.get(item.value) ?? item, ); + const submenuGroups = + currentView?.groups[0]?.value === NEW_THREAD_IN_GROUP + ? [{ value: NEW_THREAD_IN_GROUP, label: "Projects", items: projectThreadItems }] + : currentView?.groups[0]?.value === "themes" + ? changeThemeItem.groups + : currentView?.groups[0]?.value === "appearance" + ? changeAppearanceItem.groups + : currentView?.groups; + const searchesAllThreads = + deferredQuery.trim().length > 0 && + !isActionsOnly && + (currentView?.groups[0]?.value === ADD_PROJECT_ENTRY_GROUP || + currentView?.groups[0]?.value === `sources:${addProjectEnvironmentId}` || + submenuGroups === undefined || + filterCommandPaletteGroups({ + activeGroups: submenuGroups, + query: deferredQuery, + isInSubmenu: true, + projectSearchItems: [], + threadSearchItems: [], + }).length === 0); + const { threadItems: allThreadItems, recentThreadItems, threadMessageItems, now: searchNow, isSearchPending: isThreadSearchPending, - } = useCommandPaletteThreadItems({ activeThread: activeThread ?? null, threadSearchQuery }); + } = useCommandPaletteThreadItems({ + activeThread: activeThread ?? null, + threadSearchQuery, + includeAllThreads: searchesAllThreads, + }); const pushPaletteView = useStableCallback((view: CommandPaletteView): void => { browseNavigation.invalidate(); dispatchSearch({ type: "push", view }); @@ -1063,7 +1139,7 @@ function OpenCommandPaletteDialog(props: { const openSourceControlSettings = () => { setOpen(false); - void navigate({ to: "/settings/source-control" }); + void navigate({ to: "/settings/source-control", search: { pane: undefined } }); }; const buildAddProjectSourceGroups = ( @@ -1238,7 +1314,7 @@ function OpenCommandPaletteDialog(props: { const openConnectionSettings = () => { setOpen(false); - void navigate({ to: "/settings/connections" }); + void navigate({ to: "/settings/connections", search: { pane: undefined } }); }; const addProjectEntry = resolveAddProjectEntry( addProjectEnvironmentOptions, @@ -1306,8 +1382,23 @@ function OpenCommandPaletteDialog(props: { if (firstOption) startNewProject(firstOption.environmentId, null); }; + const chooseWorkspace = useStableCallback(async () => { + composerHandleRef?.current?.openControl("composer.workspace"); + }); const actionItems: Array = []; + if (activeThread || activeDraftThread) { + actionItems.push({ + kind: "action", + value: "action:choose-workspace", + title: "Choose workspace", + searchTerms: ["branch", "worktree", "checkout", "workspace"], + icon: , + shortcutCommand: "composer.workspace", + run: chooseWorkspace, + }); + } + if (projects.length > 0) { const activeProjectTitle = projectPickerEntries.find((entry) => entry.isPreferred)?.group.displayName ?? @@ -1359,6 +1450,17 @@ function OpenCommandPaletteDialog(props: { shortcutCommand: "chat.newWithoutProject", run: () => startScratchThread(scratchTargetEnvironmentId), }); + } else if (scratchOffReason !== null) { + actionItems.push({ + kind: "action", + value: "action:new-thread-without-project", + searchTerms: ["new thread", "no project", "without project", "none", "chat"], + title: "New thread without a project", + description: scratchOffReason, + disabled: true, + icon: , + run: async () => {}, + }); } if (triageEnvironmentId !== null && canTriage) { @@ -1547,6 +1649,51 @@ function OpenCommandPaletteDialog(props: { }); } + // What the terminal's split keys act on, from where focus was when the palette opened. + const terminalSplitTarget = findTerminalSplitTarget( + opener, + previewPanelThreadRef ? scopedThreadKey(previewPanelThreadRef) : null, + ); + if (terminalSplitTarget !== null) { + const target = terminalSplitTarget; + actionItems.push( + { + kind: "action", + value: "action:terminal-split", + searchTerms: ["split terminal", "terminal", "split", "horizontal"], + title: "Split terminal", + icon: , + shortcutCommand: "terminal.split", + disabled: !target.canSplit(), + run: async () => { + target.split("horizontal"); + queueTerminalFocusFromPalette(target.focusActive); + }, + }, + { + kind: "action", + value: "action:terminal-split-vertical", + searchTerms: ["split terminal vertically", "terminal", "split", "vertical"], + title: "Split terminal vertically", + icon: , + shortcutCommand: "terminal.splitVertical", + disabled: !target.canSplit(), + run: async () => { + target.split("vertical"); + queueTerminalFocusFromPalette(target.focusActive); + }, + }, + ); + } + + // The side pane's tabs and the terminal, beside the focused chat. + const openBesideItem = activeThread || activeDraftThread ? buildOpenBesidePaletteItem() : null; + if (openBesideItem) actionItems.push(openBesideItem); + // The index shows the layout too once there is a project. + if (activeThread || activeDraftThread || (pathname === "/" && projects.length > 0)) { + actionItems.push(...buildLayoutPaletteItems(scrollingWorkspaces)); + } + actionItems.push({ kind: "action", value: "action:open-file-picker", @@ -1661,7 +1808,7 @@ function OpenCommandPaletteDialog(props: { title: "Open pull requests", icon: , run: async () => { - await navigate({ to: "/pull-requests", search: readPullRequestListPreferences() }); + await openLayoutPage("pull-requests"); }, }); } @@ -1728,7 +1875,7 @@ function OpenCommandPaletteDialog(props: { title: "Open settings", icon: , run: async () => { - await navigate({ to: "/settings" }); + await navigate({ to: "/settings", search: { pane: undefined } }); }, }); @@ -1802,11 +1949,7 @@ function OpenCommandPaletteDialog(props: { }); } - const rootGroups = buildRootGroups({ - actionItems: actionItems.filter((item) => !SETTINGS_ACTION_VALUES.has(item.value)), - recentThreadItems, - settingsItems: actionItems.filter((item) => SETTINGS_ACTION_VALUES.has(item.value)), - }); + const rootGroups = buildRootGroups({ actionItems, recentThreadItems }); // Settings use the same matcher as threads, including word starts and typo tolerance. const settingsSearchItems: CommandPaletteActionItem[] = settingsForThisPlatform( availableSettingsSearchItems, @@ -1821,6 +1964,7 @@ function OpenCommandPaletteDialog(props: { run: async () => { await navigate({ to: item.to, + search: { pane: undefined }, hash: item.targetId ?? item.id, replace: pathname === item.to, hashScrollIntoView: false, @@ -3011,14 +3155,9 @@ function OpenCommandPaletteDialog(props: { ...(isSubmenu ? { startAddon: ( - + ), } : isBrowsing diff --git a/apps/web/src/components/CommandPaletteAddProject.tsx b/apps/web/src/components/CommandPaletteAddProject.tsx index 04921cb24d67e..a069207eb9dc5 100644 --- a/apps/web/src/components/CommandPaletteAddProject.tsx +++ b/apps/web/src/components/CommandPaletteAddProject.tsx @@ -109,7 +109,7 @@ export function NewProjectPublishHostPicker(props: { {props.targets.map((target) => ( - + {sourceControlHostIcon(target.definition, "size-3.5")} {target.definition.label} diff --git a/apps/web/src/components/CommandPaletteResults.tsx b/apps/web/src/components/CommandPaletteResults.tsx index 010601dce8cbe..b46eb74e36c80 100644 --- a/apps/web/src/components/CommandPaletteResults.tsx +++ b/apps/web/src/components/CommandPaletteResults.tsx @@ -203,7 +203,7 @@ function ThreadRowContext({ render={ } > @@ -257,7 +257,12 @@ function CommandPaletteRowContent({ item }: { item: CommandPaletteResultItem }) ) : item.matchHint ? ( ) : item.description ? ( - + {item.description} ) : null} @@ -285,8 +290,12 @@ function CommandPaletteResultRow(props: { keybindings: ResolvedKeybindingsConfig; onExecuteItem: (item: CommandPaletteResultItem) => void; }) { + // Terminal-only keys (splits) have no binding outside a terminal; show the terminal's. const shortcutLabel = props.item.shortcutCommand - ? shortcutLabelForCommand(props.keybindings, props.item.shortcutCommand) + ? (shortcutLabelForCommand(props.keybindings, props.item.shortcutCommand) ?? + shortcutLabelForCommand(props.keybindings, props.item.shortcutCommand, { + context: { terminalFocus: true }, + })) : null; return ( diff --git a/apps/web/src/components/CommandPaletteThreadItems.tsx b/apps/web/src/components/CommandPaletteThreadItems.tsx index ac52759d59e0d..1b50a4a7bccda 100644 --- a/apps/web/src/components/CommandPaletteThreadItems.tsx +++ b/apps/web/src/components/CommandPaletteThreadItems.tsx @@ -3,6 +3,7 @@ import { findForMessageMatch, openThreadFindOnQuery } from "./chat/threadFindSto import { claimThreadOpen, nextShownOrder, + threadSearchSubject, type EnvironmentThreadSearchMatch, type ShownOrder, } from "@t3tools/client-runtime/state/thread-search"; @@ -44,6 +45,7 @@ import { type CommandPaletteActionItem, } from "./CommandPalette.logic"; import { useEnvironmentLookups, useProjectLookups } from "./CommandPaletteLookups"; +import { isSidebarHiddenThread } from "./Sidebar.logic"; import { ThreadCommandSubtitle } from "./ThreadCommandSubtitle"; import { ThreadRowLeadingStatus, @@ -97,6 +99,32 @@ function useConnectedThreadSearch(query: string) { ); } +function useRecentThreads( + activeThread: { readonly environmentId: EnvironmentId; readonly id: ThreadId } | null, +) { + const threads = useVisibleThreadShells(); + const { projectByKey } = useProjectLookups(); + return threads + .filter( + (thread) => + thread.archivedAt === null && + !isSidebarHiddenThread(thread) && + !(thread.id === activeThread?.id && thread.environmentId === activeThread.environmentId), + ) + .map((thread) => ({ + thread, + usedAt: threadRecentUse( + threadSearchSubject( + thread, + projectByKey.get(`${thread.environmentId}:${thread.projectId}`)?.title, + ), + ), + })) + .toSorted((left, right) => right.usedAt - left.usedAt) + .slice(0, RECENT_THREAD_LIMIT) + .map(({ thread }) => thread); +} + /** * Every visible thread as a palette item, plus, for a non-empty * `threadSearchQuery`, the threads whose messages match, best first. @@ -104,10 +132,12 @@ function useConnectedThreadSearch(query: string) { export function useCommandPaletteThreadItems(input: { readonly activeThread: { readonly environmentId: EnvironmentId; readonly id: ThreadId } | null; readonly threadSearchQuery: string; + readonly includeAllThreads: boolean; }) { - const { activeThread, threadSearchQuery } = input; + const { activeThread, threadSearchQuery, includeAllThreads } = input; const navigate = useNavigate(); const threads = useVisibleThreadShells(); + const recentThreads = useRecentThreads(activeThread); const { projectEnvironmentLocationById } = useEnvironmentLookups(); const providerEntryByEnvironmentAndInstanceId = useProviderEntryByEnvironmentAndInstanceId(); const messageSearch = useConnectedThreadSearch(threadSearchQuery); @@ -117,10 +147,10 @@ export function useCommandPaletteThreadItems(input: { const [now] = useState(() => Date.now()); const nowMinute = useNowMinute(); - const nowMs = Date.parse(`${nowMinute}:00.000Z`); const { environments } = useEnvironments(); + const nowMs = Date.parse(`${nowMinute}:00.000Z`); const threadItems = buildThreadActionItems({ - threads, + threads: includeAllThreads ? threads : recentThreads, ...(environments.length > 1 ? { environmentLabelOf: (thread: (typeof threads)[number]) => @@ -254,56 +284,41 @@ export function useCommandPaletteThreadItems(input: { .flatMap((value) => messageItemByValue.get(value) ?? []); // Recent threads stay on one line: provider logo, title, then the dim branch. - const recentThreadItems = (() => { - const threadByValue = new Map( - threads.map((thread) => [`thread:${thread.environmentId}:${thread.id}`, thread]), - ); - const activeThreadValue = activeThread - ? `thread:${activeThread.environmentId}:${activeThread.id}` - : null; - return threadItems - .filter((item) => item.value !== activeThreadValue) - .toSorted( - (left, right) => - (right.threadSearch ? threadRecentUse(right.threadSearch) : 0) - - (left.threadSearch ? threadRecentUse(left.threadSearch) : 0), - ) - .slice(0, RECENT_THREAD_LIMIT) - .map(({ description: _description, ...item }): CommandPaletteActionItem => { - const thread = threadByValue.get(item.value); - if (!thread) return item; - const modelInstanceId = - thread.runtime?.providerInstanceId ?? thread.modelSelection.instanceId; - const providerEntry = - providerEntryByEnvironmentAndInstanceId.get( - `${thread.environmentId}:${modelInstanceId}`, - ) ?? null; - return { - ...item, - icon: providerEntry ? ( - - ) : ( - item.icon - ), - title: ( - <> - {thread.title} - {thread.branch ? ( - - {thread.branch} - - ) : null} - - ), - }; - }); - })(); + const recentThreadItems = recentThreads.flatMap((thread): CommandPaletteActionItem[] => { + const original = itemByValue.get(`thread:${thread.environmentId}:${thread.id}`); + if (!original) return []; + const { description: _description, ...item } = original; + const modelInstanceId = thread.runtime?.providerInstanceId ?? thread.modelSelection.instanceId; + const providerEntry = + providerEntryByEnvironmentAndInstanceId.get(`${thread.environmentId}:${modelInstanceId}`) ?? + null; + return [ + { + ...item, + icon: providerEntry ? ( + + ) : ( + item.icon + ), + title: ( + <> + {thread.title} + {thread.branch ? ( + + {thread.branch} + + ) : null} + + ), + }, + ]; + }); return { threadItems, diff --git a/apps/web/src/components/ComposerContextLabel.tsx b/apps/web/src/components/ComposerContextLabel.tsx index 0dbcb574a6e11..d3ab5ee79df37 100644 --- a/apps/web/src/components/ComposerContextLabel.tsx +++ b/apps/web/src/components/ComposerContextLabel.tsx @@ -22,8 +22,8 @@ export function ComposerContextLabel({ displayMode === "panel" ? "flex-1 overflow-x-clip overflow-y-visible text-left" : compactable - ? "max-w-[240px] group-data-[compact]/composer-context:max-w-0" - : "max-w-[240px] group-data-[compact=branch]/composer-context:max-w-24", + ? "max-w-[240px] transition-[margin-inline-start] duration-180 ease-drawer group-data-[compact]/composer-context:-ms-1.5 group-data-[compact]/composer-context:max-w-0 motion-reduce:transition-none" + : "group-data-[compact=branch]/composer-context:max-w-24", )} > false, + placeholder = "", }: { value: string; + placeholder?: string; richTextEnabled: boolean; skills: ReadonlyArray; onChange?: ComposerPromptEditorProps["onChange"] | undefined; @@ -71,7 +75,7 @@ function Composer({ contextRecords={new Map()} skills={skills} disabled={false} - placeholder="" + placeholder={placeholder} onChange={(...args) => { setPrompt({ value: args[0], cursor: args[1] }); onChange?.(...args); @@ -379,6 +383,27 @@ it.each([false, true])( }, ); +it("draws typed text on the placeholder's pixels", async () => { + const text = "Run the tests again"; + await using screen = await render( + , + ); + const textRect = (root: Element | null) => { + const node = root && document.createTreeWalker(root, NodeFilter.SHOW_TEXT).nextNode(); + if (!node) throw new Error("Text is missing"); + const range = document.createRange(); + range.selectNodeContents(node); + const { x, y, width, height } = range.getBoundingClientRect(); + return { x, y, width, height }; + }; + const shown = textRect(screen.container.querySelector("[data-composer-placeholder]")); + const editor = editorIn(screen.container); + await act(async () => { + editor.commands.insertContent(text); + }); + expect(textRect(editor.view.dom.querySelector("p"))).toEqual(shown); +}); + it("lets composition replace selected text without surrounding it", async () => { await using screen = await mountEditor("word"); const { editor } = screen; diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.tsx index 4946e5034ea98..0265dd1a143bc 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.tsx @@ -253,6 +253,9 @@ function resolvedThemeFromDocument(): "light" | "dark" { return document.documentElement.classList.contains("dark") ? "dark" : "light"; } +// The editor and its placeholder share one text box, so accepted text lands on the placeholder's pixels. +const COMPOSER_TEXT_CLASS_NAME = "composer-text -m-1 p-1"; + const CHIP_NODE_SELECTION_CLASS_NAME = "relative inline-flex min-w-0 max-w-full select-none items-baseline data-[composer-chip-selected]:after:pointer-events-none data-[composer-chip-selected]:after:absolute data-[composer-chip-selected]:after:inset-0 data-[composer-chip-selected]:after:rounded-sm data-[composer-chip-selected]:after:bg-[Highlight] data-[composer-chip-selected]:after:opacity-30 data-[composer-chip-selected]:after:content-['']"; @@ -1086,7 +1089,8 @@ function ComposerPromptEditorTiptapInner( const editorAttributes = { class: cn( - "composer-tiptap -m-1 block max-h-42 min-h-5 overflow-y-auto p-1 whitespace-pre-wrap wrap-break-word bg-transparent leading-5 text-foreground focus:outline-none", + "composer-tiptap block max-h-42 min-h-5 overflow-y-auto bg-transparent text-foreground focus:outline-none", + COMPOSER_TEXT_CLASS_NAME, className, ), "data-testid": "composer-editor", @@ -1526,7 +1530,7 @@ function ComposerPromptEditorTiptapInner( // Push attributes before layout measurements; Tiptap updates them later in a passive effect. // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- pushes DOM attributes into the ProseMirror view before layout measurements read them useLayoutEffect(() => { - if (!editor?.isInitialized) return; + if (!editor?.isInitialized || editor.isDestroyed) return; editor.view.setProps({ attributes: editorAttributes }); }, [editor, editorAttributes]); @@ -1588,7 +1592,7 @@ function ComposerPromptEditorTiptapInner( // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- reconciles the controlled Tiptap document before paint, outside React's commit useLayoutEffect(() => { latestValueRef.current = value; - if (!editor) return; + if (!editor || editor.isDestroyed) return; // Tiptap mounts React node views with flushSync; leave React's commit before updating them. let active = true; const applyControlledUpdate = () => { @@ -1701,14 +1705,15 @@ function ComposerPromptEditorTiptapInner( // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- restores ProseMirror focus and caret scroll when the keyed editor remounts, capturing focus on teardown useLayoutEffect(() => { if (!editor) return; - if (restoreFocusRef.current) { + if (restoreFocusRef.current && !editor.isDestroyed) { restoreFocusRef.current = false; editor.view.focus(); scrollTiptapCaretIntoView(editor); } return () => { - // Capture focus before the keyed editor's DOM is removed. - restoreFocusRef.current = editor.view.hasFocus(); + // Capture focus before the keyed editor's DOM is removed. A pane move can unmount + // the view first; reading it then throws. + restoreFocusRef.current = !editor.isDestroyed && editor.view.hasFocus(); }; }, [editor, restoreFocusRef]); @@ -1858,13 +1863,15 @@ function ComposerPromptEditorTiptapInner(
{ if ( event.key === "Control" || @@ -1910,21 +1917,32 @@ function ComposerPromptEditorTiptapInner(
- {narrowPlaceholder ? ( - <> - {placeholder} - - {narrowPlaceholder} +
+ {narrowPlaceholder ? ( + <> + {placeholder} + + {narrowPlaceholder} + + + ) : ( + placeholder + )} + {placeholderSuffix ? ( + // No width and one top-aligned line tall: a suffix can neither wrap onto a line of + // its own nor push the baseline, so accepted text keeps the placeholder's layout. + + {placeholderSuffix} - - ) : ( - placeholder - )} - {placeholderSuffix} + ) : null} +
) : null}
diff --git a/apps/web/src/components/ConfirmDialogHost.tsx b/apps/web/src/components/ConfirmDialogHost.tsx index 7853ff28cea82..06f1cdf7106f9 100644 --- a/apps/web/src/components/ConfirmDialogHost.tsx +++ b/apps/web/src/components/ConfirmDialogHost.tsx @@ -1,5 +1,5 @@ import { Trash2Icon } from "lucide-react"; -import { useSyncExternalStore } from "react"; +import { useRef, useSyncExternalStore } from "react"; import { completeConfirmDialogClose, @@ -7,6 +7,7 @@ import { respondToConfirmDialog, subscribeConfirmDialogHost, } from "../confirmDialog"; +import { returnKeyboardToFocusedPane } from "../layout/layoutPaneContext"; import { AlertDialog, AlertDialogClose, @@ -18,6 +19,7 @@ import { AlertDialogTitle, } from "./ui/alert-dialog"; import { Button } from "./ui/button"; +import { useDialogActionInitialFocus } from "./ui/dialog"; type ConfirmationCopy = { readonly title: string; @@ -59,6 +61,9 @@ export function ConfirmDialogHost() { readConfirmDialogState, ); + const openerRef = useRef(null); + const { popupRef, initialFocus } = useDialogActionInitialFocus(); + const copy = state.status !== "idle" && state.title ? { title: state.title, description: state.message.trim() || null } @@ -81,7 +86,25 @@ export function ConfirmDialogHost() { if (!open) completeConfirmDialogClose(); }} > - + { + openerRef.current = document.activeElement; + return initialFocus(openType); + }} + finalFocus={() => { + const opener = openerRef.current; + if (opener?.isConnected && !opener.closest("[data-layout-strip]")) return true; + if (returnKeyboardToFocusedPane()) return false; + // A chat pane takes it in its composer. + return ( + document.querySelector( + "[data-layout-pane-focused] [contenteditable='true']", + ) ?? true + ); + }} + > {copy.title} {copy.description ? ( diff --git a/apps/web/src/components/ConnectionStatusDot.tsx b/apps/web/src/components/ConnectionStatusDot.tsx deleted file mode 100644 index f365a03c872a1..0000000000000 --- a/apps/web/src/components/ConnectionStatusDot.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; - -import { cn } from "~/lib/utils"; -import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; - -export function connectionPhaseDotClassName(phase: EnvironmentConnectionPhase): string { - switch (phase) { - case "connected": - return "bg-success"; - case "connecting": - case "reconnecting": - return "bg-warning"; - case "error": - return "bg-error"; - default: - return "bg-dim-foreground"; - } -} - -export function connectionPhasePingClassName(phase: EnvironmentConnectionPhase): string | null { - return phase === "connecting" || phase === "reconnecting" ? "bg-warning/60 duration-2000" : null; -} - -type ConnectionStatusDotProps = { - tooltipText?: string | null; - dotClassName: string; - pingClassName?: string | null; -}; - -export function ConnectionStatusDot({ - tooltipText, - dotClassName, - pingClassName, -}: ConnectionStatusDotProps) { - const dotContent = ( - <> - {pingClassName ? ( - - ) : null} - - - ); - - if (!tooltipText) { - return ( - - {dotContent} - - ); - } - - const dot = ( - - ); - - return ( - - - - {tooltipText} - - - ); -} diff --git a/apps/web/src/components/DevReplayHost.tsx b/apps/web/src/components/DevReplayHost.tsx index 318e6c72c1d1d..52a604dfdf700 100644 --- a/apps/web/src/components/DevReplayHost.tsx +++ b/apps/web/src/components/DevReplayHost.tsx @@ -91,6 +91,7 @@ export function DevReplayHost() { // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- exposes the console API on window and listens for the hidden window hotkey useEffect(() => { window.t3dev = { + ...window.t3dev, replayScenarios: () => listScenarios(), replay: (scenario) => replayFromConsole(scenario), }; @@ -103,7 +104,10 @@ export function DevReplayHost() { }; window.addEventListener("keydown", onKeyDown); return () => { - delete window.t3dev; + if (window.t3dev) { + delete window.t3dev.replay; + delete window.t3dev.replayScenarios; + } window.removeEventListener("keydown", onKeyDown); }; }, []); diff --git a/apps/web/src/components/DiffPanel.browser.test.tsx b/apps/web/src/components/DiffPanel.browser.test.tsx index e87a8792e8ae7..6de00d985e211 100644 --- a/apps/web/src/components/DiffPanel.browser.test.tsx +++ b/apps/web/src/components/DiffPanel.browser.test.tsx @@ -103,6 +103,7 @@ vi.mock("./diffs/DiffFileTree", () => ({ DiffFileTree: () => null })); vi.mock("./DiffFilePathCopyButton", () => ({ DiffFilePathCopyButton: () => null })); import DiffPanel from "./DiffPanel"; +import { PanelTabSlotsContext } from "./PanelTabBreadcrumb"; import { useDiffPanelSessionStore, useDiffPanelStore } from "~/diffPanelStore"; import { selectThreadRightPanelState, useRightPanelStore } from "~/rightPanelStore"; @@ -228,10 +229,17 @@ it.each([ using context = setup(failedBaseRef); await using screen = await render( - + () => {} }} + > + + , ); await expect.element(screen.getByText(errorMessage, { exact: true })).toBeVisible(); + await expect + .element(screen.getByRole("button", { name: "Diff scope: Branch changes" })) + .toBeVisible(); const selector = screen.getByRole("combobox", { name: /Change comparison target/ }); await vi.waitFor(() => expect(selector.query()).not.toBeNull()); await userEvent.click(selector); diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index 74a1b85d87a9e..5de51710da64d 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -9,6 +9,7 @@ import { scopedThreadKey } from "@t3tools/client-runtime/environment"; import { CHECKPOINT_DIFF_TRUNCATED_MARKER, type ReviewBranchCommitsResult, + type ScopedProjectRef, type ScopedThreadRef, type RunId, } from "@t3tools/contracts"; @@ -52,6 +53,7 @@ import { useStableCallback } from "../hooks/useStableCallback"; import { buildFileDiffEntries, getDiffCollapseIconClassName, + getDiffLineStat, getRenderablePatch, resolveDiffThemeName, resolveFileDiffPath, @@ -123,7 +125,6 @@ import { DiffFileChangeIcon, DiffFileHeaderStat, diffHeaderTitlePath, - fileDiffLineStat, } from "./diffs/DiffFileHeaderParts"; type DiffThemeType = "light" | "dark"; @@ -215,9 +216,19 @@ function DiffFileHeaderSuffix({ interface DiffPanelProps { mode?: DiffPanelMode; - composerDraftTarget: ScopedThreadRef | DraftId; + /** The composer comments go to; without one (a threadless surface) there are none. */ + composerDraftTarget?: ScopedThreadRef | DraftId | undefined; workspaceMutationId: string | null; visible: boolean; + /** + * The thread whose changes to show. A chat beside others in the layout + * passes its own; unset follows the route, as a lone thread view does. + */ + threadRef?: ScopedThreadRef | null | undefined; + /** With no thread to follow (a threadless surface), the project checkout whose changes to show. */ + projectRef?: ScopedProjectRef | null | undefined; + /** With no project either, the folder whose changes to show: the server's home. */ + folder?: string | null | undefined; } export default function DiffPanel({ @@ -225,6 +236,9 @@ export default function DiffPanel({ composerDraftTarget, workspaceMutationId, visible, + threadRef, + projectRef, + folder, }: DiffPanelProps) { const resolvedTheme = useRenderedAppearance(); const now = useNowMinute(); @@ -252,9 +266,11 @@ export default function DiffPanel({ // The comments written here since the review tray was last dismissed, for its one-press Undo. // Tied to the draft they went into, so switching threads does not carry a batch across. const composerDraftKey = - typeof composerDraftTarget === "string" - ? composerDraftTarget - : scopedThreadKey(composerDraftTarget); + composerDraftTarget === undefined + ? "" + : typeof composerDraftTarget === "string" + ? composerDraftTarget + : scopedThreadKey(composerDraftTarget); const [reviewBatch, setReviewBatch] = useState<{ draftKey: string; commentIds: ReadonlyArray; @@ -268,9 +284,21 @@ export default function DiffPanel({ })); const dismissReviewBatch = () => setReviewBatch(null); - const routeThreadRef = useThreadRouteRef(); + const urlThreadRef = useThreadRouteRef(); + const routeThreadRef = threadRef === undefined ? urlThreadRef : threadRef; const activeThreadId = routeThreadRef?.threadId ?? null; - const activeThread = useQuietThreadShell(routeThreadRef); + const threadShell = useQuietThreadShell(routeThreadRef); + const projectEnvironmentId = projectRef?.environmentId ?? null; + const projectCheckoutId = projectRef?.projectId ?? null; + const folderEnvironmentId = folder ? (routeThreadRef?.environmentId ?? null) : null; + // A project's own checkout, or a folder, stands in for a thread's; it has no turns. + const projectCheckout = + projectEnvironmentId !== null && projectCheckoutId !== null + ? { environmentId: projectEnvironmentId, projectId: projectCheckoutId, worktreePath: null } + : folder && folderEnvironmentId !== null + ? { environmentId: folderEnvironmentId, projectId: null, worktreePath: folder } + : null; + const activeThread = threadShell ?? projectCheckout; const fileAccess = useFilesystemReadAccess(activeThread?.environmentId ?? null); const { canReadFiles } = fileAccess; const activeProjectId = activeThread?.projectId ?? null; @@ -328,7 +356,7 @@ export default function DiffPanel({ ); const toolCallItem = toolCallDetail.data?.item; const isGitRepo = gitStatusQuery.data?.isRepo ?? true; - const turnDiffSummaries = useThreadCheckpointSummaries(routeThreadRef); + const turnDiffSummaries = useThreadCheckpointSummaries(projectCheckout ? null : routeThreadRef); const orderedTurnDiffSummaries = turnDiffSummaries.toSorted((left, right) => { if (left.checkpointTurnCount !== right.checkpointTurnCount) { return right.checkpointTurnCount - left.checkpointTurnCount; @@ -852,331 +880,323 @@ export default function DiffPanel({ } }; - const headerRow = ( - <> -
- - } - className={cn("max-w-full", headerCommitSha !== null && "min-w-0 shrink")} - aria-label={`Diff scope: ${selectedScopeLabel}`} - data-workspace-focus-default - > - {selectedScopeLabel} - - - {headerCommitSha !== null ? ( - selectedCommitUrl ? ( - - - } - onClick={(event) => openCommitUrl(event, selectedCommitUrl)} - /> - } - > - - {headerCommitSha.slice(0, 7)} - - - {selectedCommitUrl} - - ) : ( - - {headerCommitSha.slice(0, 7)} - - ) - ) : null} - - - - Working tree - - - Branch changes - - - Latest turn - - - - - Turn - - - {orderedTurnDiffSummaries.map((summary) => { - const turnCount = summary.checkpointTurnCount; - return ( - - - Turn {turnCount} - - {formatShortTimestamp(summary.completedAt, settings.timestampFormat)} - - - - ); - })} - - - - { - setOpenCommitMenu(open ? (branchCommitsQuery.data ?? null) : null); - if (!open) return; - // The menu keeps the list it opened with; the refreshed one shows next time. - branchCommitsQuery.refresh(); - }} - > - Commits - - - {branchCommits?.commits.map((commit) => { - const commitUrl = projectCommitUrl( - activeProject?.repositoryIdentity, - commit.sha, - ); - return ( -
- - - } - > - - {commit.subject} - - {formatRelativeTimeLabel(commit.committedAt, Date.parse(now))} - - - - - {commit.subject} - - - {commitUrl ? ( - - } - onClick={(event) => openCommitUrl(event, commitUrl)} - > - - {commit.sha.slice(0, 7)} - - - ) : ( - - {commit.sha.slice(0, 7)} - - )} -
- ); - })} -
- {branchCommitsQuery.error ? ( - {branchCommitsQuery.error} - ) : !canReadFiles ? ( - - {fileAccess.isPending - ? "Checking file access..." - : (fileAccess.error ?? "This connection cannot read local diffs.")} - - ) : branchCommits === null ? ( - Loading commits... - ) : branchCommits.commits.length === 0 ? ( - No commits on this branch. - ) : branchCommits.truncated ? ( - - {`Showing latest ${branchCommits.commits.length} commits`} - - ) : null} -
-
-
-
- {canSelectBranchBase && ( -
+ const scopeCrumb = ( + + + } + className={headerCommitSha === null ? "shrink-0" : "min-w-0 shrink"} + title={selectedScopeLabel} + aria-label={`Diff scope: ${selectedScopeLabel}`} + data-workspace-focus-default + > + {selectedScopeLabel} + + + {headerCommitSha !== null ? ( + selectedCommitUrl ? ( + + } + onClick={(event) => openCommitUrl(event, selectedCommitUrl)} + /> } > - {comparisonHeadRef} - - - - {`${comparisonHeadRef} → ${comparisonBaseRef}`} - - - - } - > - vs + + {headerCommitSha.slice(0, 7)} + - - {`${comparisonHeadRef} → ${comparisonBaseRef}`} - + {selectedCommitUrl} - { - if (!open) setBaseRefQuery(""); - }} - onValueChange={(value) => { - if (!value) return; - selectBranchBaseRef(value === AUTOMATIC_BASE_REF ? null : value); - }} + ) : ( + + {headerCommitSha.slice(0, 7)} + + ) + ) : null} + + + + Working tree + + + Branch changes + + + Latest turn + + + + + Turn + + + {orderedTurnDiffSummaries.map((summary) => { + const turnCount = summary.checkpointTurnCount; + return ( + + + Turn {turnCount} + + {formatShortTimestamp(summary.completedAt, settings.timestampFormat)} + + + + ); + })} + + + + { + setOpenCommitMenu(open ? (branchCommitsQuery.data ?? null) : null); + if (!open) return; + // The menu keeps the list it opened with; the refreshed one shows next time. + branchCommitsQuery.refresh(); + }} + > + Commits + + + {branchCommits?.commits.map((commit) => { + const commitUrl = projectCommitUrl(activeProject?.repositoryIdentity, commit.sha); + return ( +
+ + + } + > + + {commit.subject} + + {formatRelativeTimeLabel(commit.committedAt, Date.parse(now))} + + + + + {commit.subject} + + + {commitUrl ? ( + + } + onClick={(event) => openCommitUrl(event, commitUrl)} + > + + {commit.sha.slice(0, 7)} + + + ) : ( + + {commit.sha.slice(0, 7)} + + )} +
+ ); + })} +
+ {branchCommitsQuery.error ? ( + {branchCommitsQuery.error} + ) : !canReadFiles ? ( + + {fileAccess.isPending + ? "Checking file access..." + : (fileAccess.error ?? "This connection cannot read local diffs.")} + + ) : branchCommits === null ? ( + Loading commits... + ) : branchCommits.commits.length === 0 ? ( + No commits on this branch. + ) : branchCommits.truncated ? ( + + {`Showing latest ${branchCommits.commits.length} commits`} + + ) : null} +
+
+
+
+ {canSelectBranchBase && ( +
+ + + } > - } - className="min-w-9 max-w-48 shrink-0" - aria-label={`Change comparison target. Currently ${comparisonBaseRef}`} - > - {comparisonBaseRef} - - - - setBaseRefQuery(event.target.value)} - /> -
-
+ No matching refs. + + + Automatic + + {baseRefChoices.map((choice) => { + const item = valueForBaseRefChoice(choice); + const hasBoth = choice.local !== null && choice.remote !== null; + const useRemote = choice.remote?.name === item; + return ( + +
+ {choice.label} + {hasBoth ? ( +
event.stopPropagation()} + onPointerDown={(event) => event.stopPropagation()} + > + { + const nextRef = checked ? choice.remote?.name : choice.local?.name; + if (nextRef) selectBranchBaseRef(nextRef); + }} + /> +
+ ) : choice.remote ? ( + + + + + } + /> + Remote only + + ) : null} +
+
+ ); + })} +
+
+ +
+ )} +
+ ); + const viewControls = ( + + useDiffPanelSessionStore.getState().setIgnoreWhitespace(value) + } + hasFiles={diffFileKeys.length > 0} + testFileCount={testFileCount} + hideTestFiles={hideTestFiles} + onHideTestFilesChange={setHideTestFiles} + foldBelow="2xl" + fileTreeOpen={fileTreeOpen} + onFileTreeOpenChange={setFileTreeOpen} + allFilesCollapsed={allDiffFilesCollapsed} + onToggleAllFiles={toggleDiffFileCollapse} + refresh={ + canRefreshGitDiff + ? { refreshing: isRefreshingDiff, onRefresh: refreshDiffFromUserAction } + : undefined + } + /> + ); + const headerRow = ( + <> +
+ {scopeCrumb}
- - useDiffPanelSessionStore.getState().setIgnoreWhitespace(value) - } - hasFiles={diffFileKeys.length > 0} - testFileCount={testFileCount} - hideTestFiles={hideTestFiles} - onHideTestFilesChange={setHideTestFiles} - fileTreeOpen={fileTreeOpen} - onFileTreeOpenChange={setFileTreeOpen} - allFilesCollapsed={allDiffFilesCollapsed} - onToggleAllFiles={toggleDiffFileCollapse} - refresh={ - canRefreshGitDiff - ? { refreshing: isRefreshingDiff, onRefresh: refreshDiffFromUserAction } - : undefined - } - /> + {viewControls}
); @@ -1193,7 +1213,7 @@ export default function DiffPanel({ } title="Not a git repository" - description="Diffs compare git checkpoints, so this project has none to show." + description="Diffs compare git checkpoints, so this folder has none to show." /> ) : selectedRunId !== null && orderedTurnDiffSummaries.length === 0 ? ( -
+
{preview?.incomplete && ( -

+

{selectedPatchError ? "Showing tracked changes. Untracked changes could not be loaded. Refresh to retry." : "Showing tracked changes. Checking untracked files..."} @@ -1375,7 +1402,7 @@ export default function DiffPanel({ // A lazily loaded file's hunks may not be here yet; the scope's stats are. const stat = lazySource ? fileStats.get(resolveFileDiffPath(fileDiff)) - : fileDiffLineStat(fileDiff); + : getDiffLineStat([fileDiff]); return stat ? ( ) : null; @@ -1419,6 +1446,7 @@ export default function DiffPanel({

{fileTreeOpen ? ( ) : ( -
+

{renderablePatch?.kind === "raw" ? renderablePatch.reason : null} @@ -1453,11 +1481,13 @@ export default function DiffPanel({

)} - + {composerDraftTarget ? ( + + ) : null}
)} diff --git a/apps/web/src/components/DiffPanelShell.tsx b/apps/web/src/components/DiffPanelShell.tsx index 99d4f2e5ee7ec..4823fe9cf181d 100644 --- a/apps/web/src/components/DiffPanelShell.tsx +++ b/apps/web/src/components/DiffPanelShell.tsx @@ -93,7 +93,7 @@ function DiffCodeLineSkeleton({ width }: { width: "short" | "medium" | "long" }) export function DiffPanelLoadingState(props: { label: string }) { return (
{ }); describe("when: local changes are behind upstream", () => { + it("blocks pulling a diverged branch until it is rebased or merged", () => { + const diverged = status({ aheadCount: 1, behindCount: 1 }); + const items = buildSyncMenuItems(diverged, false); + assert.isTrue(items.pull?.disabled); + assert.equal( + items.pull?.disabledReason, + "Branch has diverged from upstream. Rebase/merge first.", + ); + assert.isFalse(buildSyncMenuItems(status({ behindCount: 1 }), false).pull?.disabled); + }); + it("keeps the commit action available without pushing, with or without an open PR", () => { const behind = status({ hasWorkingTreeChanges: true, behindCount: 2 }); const commit = { diff --git a/apps/web/src/components/GitActionsControl.logic.ts b/apps/web/src/components/GitActionsControl.logic.ts index 7ccf4b1b23522..09c0cb7f08184 100644 --- a/apps/web/src/components/GitActionsControl.logic.ts +++ b/apps/web/src/components/GitActionsControl.logic.ts @@ -630,11 +630,13 @@ export function buildSyncMenuItems( "Pull", busyReason ?? detachedReason ?? - (!isBehind - ? "Already up to date with upstream." - : hasChanges - ? "Commit or stash local changes before pulling." - : null), + (isAhead && isBehind + ? "Branch has diverged from upstream. Rebase/merge first." + : !isBehind + ? "Already up to date with upstream." + : hasChanges + ? "Commit or stash local changes before pulling." + : null), ); const sync = syncMenuItem( "sync", diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 994b06a793c97..d99f9f40737a9 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -23,6 +23,7 @@ import { EllipsisIcon, BookUp2Icon, CheckIcon, + ChevronDownIcon, CloudDownloadIcon, CloudUploadIcon, FileDiffIcon, @@ -62,6 +63,7 @@ import { import { GitCommitDialog, type GitCommitDialogSubmission } from "./GitCommitDialog"; import { PublishRepositoryDialog } from "./PublishRepositoryDialog"; import { Button } from "~/components/ui/button"; +import { Group, GroupSeparator } from "~/components/ui/group"; import { Dialog, DialogDescription, @@ -116,11 +118,12 @@ import { type DraftId, useComposerDraftStore } from "~/composerDraftStore"; import { THREAD_DETAILS_PANEL_LABEL_CLASS } from "./chat/threadDetailsPanelStyles"; import { getSourceControlPresentation } from "~/sourceControlPresentation"; import { scopeProjectRef } from "@t3tools/client-runtime/environment"; +import { PullRequestGlyph } from "./pullRequest/pullRequestIcons"; import { formatDiffCount } from "@t3tools/client-runtime/diff-count"; interface GitActionsControlProps { /** "shortcut" keeps the commit and push shortcut and its dialogs live. */ - presentation?: "shortcut"; + presentation?: "shortcut" | "toolbar"; displayMode?: "panel"; gitCwd: string | null; activeThreadRef: ScopedThreadRef | null; @@ -343,10 +346,9 @@ export default function GitActionsControl({ showChanges = true, onOpenChanges, workspaceMutationId = null, - keybindings: panelKeybindings, }: GitActionsControlProps) { const progressLabelId = useId(); - const isPanel = presentation !== "shortcut"; + const isPanel = presentation === undefined; const updateThreadMetadata = useAtomCommand( threadEnvironment.updateMetadata, "thread branch metadata update", @@ -458,7 +460,10 @@ export default function GitActionsControl({ const { data: gitStatus, error: gitStatusError } = gitStatusQuery; const sourceControlPresentation = getSourceControlPresentation(gitStatus?.sourceControlProvider); const changeRequestTerminology = sourceControlPresentation.terminology; - const SourceControlIcon = sourceControlPresentation.Icon; + const SourceControlIcon = + gitStatus?.sourceControlProvider?.kind === "gitcafe" + ? PullRequestGlyph.pullRequest + : sourceControlPresentation.Icon; const isRepo = gitStatus?.isRepo ?? true; const hasPrimaryRemote = gitStatus?.hasPrimaryRemote ?? false; const gitStatusForActions = gitStatus; @@ -767,14 +772,6 @@ export default function GitActionsControl({ data: successToastData, }), ); - } else { - resultToastId = toastManager.add({ - type: "success", - title: actionResult.toast.title, - description: actionResult.toast.description, - timeout: successToastTiming.timeout, - data: successToastData, - }); } return true; } @@ -824,27 +821,11 @@ export default function GitActionsControl({ pullResult.status === "pulled" ? `Updated ${pullResult.refName} from ${pullResult.upstreamRef ?? "upstream"}` : `${pullResult.refName} is already synchronized.`; - if (isPanel) { - setInlineSuccess({ - title, - description, - scopeKey: successScopeKey, - expiresAtMs: inlineSuccessExpiresAt(), - }); - return; - } - const successToastTiming = resolveGitActionResultToastTiming("success"); - toastManager.add({ - type: "success", + setInlineSuccess({ title, description, - timeout: successToastTiming.timeout, - data: { - ...threadToastData, - ...(successToastTiming.dismissAfterVisibleMs !== null - ? { dismissAfterVisibleMs: successToastTiming.dismissAfterVisibleMs } - : {}), - }, + scopeKey: successScopeKey, + expiresAtMs: inlineSuccessExpiresAt(), }); })(); }; @@ -888,7 +869,7 @@ export default function GitActionsControl({ }; const runCommitPushFromShortcut = useEffectEvent(runCommitPush); - const handlesCommitPushShortcut = presentation === "shortcut" && isRepo; + const handlesCommitPushShortcut = presentation !== undefined && isRepo; // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- subscribes to window keydown for the mounted git control's commit and push shortcuts useEffect(() => { if (!listenForShortcuts || !handlesCommitPushShortcut) return; @@ -1013,24 +994,24 @@ export default function GitActionsControl({ readonly disabledReason: string | null; readonly trailing?: ReactNode; readonly onClick: () => void; - }) => ( -
- + }) => { + const disabled = !canWriteSourceControl || row.disabled; + const item = ( + {row.icon} {row.label} {row.trailing} - {row.disabled && row.disabledReason ? ( -

- {row.disabledReason} -

- ) : null} -
- ); + ); + return disabled && row.disabledReason ? ( + + }>{item} + {row.disabledReason} + + ) : ( + item + ); + }; const renderActionMenuItem = (item: GitActionMenuItem) => renderMenuRow({ key: `${item.id}-${item.label}`, @@ -1180,8 +1161,12 @@ export default function GitActionsControl({ return ( <> {presentation === "shortcut" ? null : displayMode === "panel" && isRepo ? ( -
- {showActions ? ( + showActions ? ( +
{"Push branch"} - ) : null} + { + if (open) requestVcsStatusRefresh(refreshVcsStatus, activeEnvironmentId, gitCwd); + }} + > + + } + /> + } + > + + + Git action options + + + {gitItems} + + +
+ ) : null + ) : presentation === "toolbar" && isRepo ? ( + + + { if (open) requestVcsStatusRefresh(refreshVcsStatus, activeEnvironmentId, gitCwd); }} > - } - > - - + + + } + /> + } + > + + + Git action options + - {showChanges ? ( - - - View changes - {changesSummary} - - ) : null} - {showActions ? gitItems : null} + {gitItems} -
+ ) : !isRepo ? ( showActions ? ( - - View changes - {!showActions ? changesSummary : null} - {keybindings && ( - - {shortcutLabelForCommand(panelKeybindings ?? keybindings, "diff.toggle")} - - )} - - )} {showActions ? ( { @@ -1289,7 +1309,11 @@ export default function GitActionsControl({ Git actions - + {gitItems} @@ -1297,6 +1321,43 @@ export default function GitActionsControl({
)} + {isPanel && showChanges && isRepo && (displayMode === "panel" || !quietEmptyDraft) ? ( +
+ + + + Changes + {changesBaseLabel ? ( + + } + > + {changesBaseLabel} + + {changesBaseLabel} + + ) : null} + + + +{formatDiffCount(changesTotals?.insertions ?? 0)} + + + -{formatDiffCount(changesTotals?.deletions ?? 0)} + + + + +
+ ) : null} + { if (gesture === null) { gesture = - event.ctrlKey || event.deltaMode !== WheelEvent.DOM_DELTA_PIXEL + event.ctrlKey || + (event.metaKey && event.altKey) || + event.deltaMode !== WheelEvent.DOM_DELTA_PIXEL ? { kind: "ignored" } : { kind: "pending", path: event.composedPath(), y: event.clientY, sumX: 0, sumY: 0 }; } diff --git a/apps/web/src/components/NoProjectsHero.tsx b/apps/web/src/components/NoProjectsHero.tsx index 84f9e4a7af762..79cef7160aacf 100644 --- a/apps/web/src/components/NoProjectsHero.tsx +++ b/apps/web/src/components/NoProjectsHero.tsx @@ -1,7 +1,14 @@ import { workspaceAreaProps } from "../lib/workspaceFocus"; import { useAtomValue } from "@effect/atom-react"; -import { DownloadIcon, FolderPlusIcon, MessageSquareDashedIcon } from "lucide-react"; +import { + DownloadIcon, + FolderPlusIcon, + MessageSquareDashedIcon, + PlusIcon, + SquarePenIcon, +} from "lucide-react"; import { useNavigate } from "@tanstack/react-router"; +import type { ReactNode } from "react"; import { openCommandPalette } from "../commandPaletteBus"; import { isElectron } from "../env"; @@ -10,7 +17,13 @@ import { shortcutLabelForCommand } from "../keybindings"; import { usePrimaryEnvironmentId } from "../state/environments"; import { primaryServerKeybindingsAtom } from "../state/server"; import { DraftProjectGroupLabel, DraftProjectRow } from "./chat/DraftHeroHeadline"; +import { NoProjectsComposer } from "./chat/NoProjectsComposer"; +import { PanelLayoutControls } from "./chat/PanelLayoutControls"; +import { ChatPaneHeaderControls } from "./chat/ChatPaneHeader"; +import { PANE_TAB_CLASS, PaneNotch } from "./PaneNotch"; +import { Button } from "./ui/button"; import { SidebarInset } from "./ui/sidebar"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; import { WorkspacePageHeader } from "./WorkspacePageHeader"; /** @@ -19,23 +32,19 @@ import { WorkspacePageHeader } from "./WorkspacePageHeader"; */ export function NoProjectsHero() { const navigate = useNavigate(); - const contentArea = workspaceAreaProps("home", 1, "Home"); const primaryEnvironmentId = usePrimaryEnvironmentId(); - const { scratchEnvironmentId, startScratchThread } = useScratchProject(); + const { scratchEnvironmentId, scratchUnavailableReason, startScratchThread } = + useScratchProject(); const scratchTargetEnvironmentId = scratchEnvironmentId(primaryEnvironmentId); + const scratchOffReason = scratchUnavailableReason(primaryEnvironmentId); const keybindings = useAtomValue(primaryServerKeybindingsAtom); const openAddProject = () => openCommandPalette({ open: "add-project" }); return ( - -
- {/* The desktop window only moves where CSS opts in, so keep a titlebar strip. */} - {isElectron ? : null} -
+ + {/* Like a draft: the list centered above the composer docked at the bottom. */} +
+

Add a project to start

@@ -52,7 +61,7 @@ export function NoProjectsHero() { detail="Continue from Claude Code, Codex or Pi" onSelect={() => void navigate({ to: "/welcome" })} /> - {scratchTargetEnvironmentId === null ? null : ( + {scratchTargetEnvironmentId === null && scratchOffReason === null ? null : ( <>
} label="No project" - detail="Its own folder, no git" - shortcut={shortcutLabelForCommand(keybindings, "chat.newWithoutProject")} - onSelect={() => void startScratchThread(scratchTargetEnvironmentId)} + detail={scratchOffReason ?? "Its own folder, no git"} + disabled={scratchTargetEnvironmentId === null} + shortcut={ + scratchTargetEnvironmentId === null + ? null + : shortcutLabelForCommand(keybindings, "chat.newWithoutProject") + } + onSelect={() => { + if (scratchTargetEnvironmentId !== null) { + void startScratchThread(scratchTargetEnvironmentId); + } + }} /> )}
+ {primaryEnvironmentId === null ? null : ( +
+
+ + void startScratchThread(scratchTargetEnvironmentId, { + sendCarriedDraft: true, + }) + } + startUnavailableReason={scratchOffReason} + /> +
+
+
+ )} +
+ + ); +} + +/** + * The pane a new draft opens in, with its one "New thread" tab in the notch. + * Empty, it holds the place while the app loads its projects. + */ +function NewThreadPane({ + addDisabledReason, + children, +}: { + readonly addDisabledReason: string; + readonly children?: ReactNode; +}) { + return ( + +
+ {/* The pane header a draft would have, with its one tab in the notch. */} + +
+ +
+ + New thread +
+ {/* A draft's "+" and details toggle, with nothing to act on yet. */} + + }> + + + {addDisabledReason} + +
+
+ + {}} + disabledReason="No thread yet" + /> + +
+ {children}
); diff --git a/apps/web/src/components/PaneNotch.tsx b/apps/web/src/components/PaneNotch.tsx index 7c337e734836e..66430ece9be6d 100644 --- a/apps/web/src/components/PaneNotch.tsx +++ b/apps/web/src/components/PaneNotch.tsx @@ -1,9 +1,13 @@ import { mergeProps } from "@base-ui/react/merge-props"; import { useRender } from "@base-ui/react/use-render"; -import type { ReactNode } from "react"; +import { useLayoutEffect, useRef, type ReactNode } from "react"; import { useMediaQuery } from "../hooks/useMediaQuery"; +import { useTheme } from "../hooks/useTheme"; +import { useLayoutPane } from "../layout/layoutPaneContext"; +import { observeResize } from "../lib/observeResize"; import { cn } from "../lib/utils"; +import { holdEndCornerWhileSliding } from "../motion/paneMotion"; /** * A tab in the notch (pen N1JIfM): 28px, r8, 10px sides, 7px from the icon to @@ -12,7 +16,14 @@ import { cn } from "../lib/utils"; * sits on the pane edge, where the pane clip would cut an outer ring. */ export const PANE_TAB_CLASS = - "inline-flex h-7 min-w-0 max-w-80 items-center gap-1.75 rounded-control px-2.5 text-control text-strong-foreground outline-none [-webkit-app-region:no-drag] focus-visible:inset-ring-2 focus-visible:inset-ring-ring"; + "inline-flex h-7 min-w-0 max-w-80 items-center gap-1.75 rounded-control surface-pane-tab px-2.5 text-control text-strong-foreground outline-none [-webkit-app-region:no-drag] focus-visible:inset-ring-2 focus-visible:inset-ring-ring"; + +export const PANE_TAB_TITLE_CLASS = "max-w-68 truncate"; +/** The connected pane tabs widen it to the active title's, so activating a tab moves nothing. */ +export const PANE_INACTIVE_TAB_TITLE_CLASS = + "max-w-[var(--pane-inactive-tab-title-max,10rem)] truncate"; +/** A ghost tab's title (`LayoutTab.ghostOf`): italic until the tab is kept, as preview tabs are. */ +export const PANE_GHOST_TAB_TITLE_CLASS = "italic"; /** The thread tab opens its action menu while keeping the notch's tab geometry. */ export function PaneTabButton({ className, render, ...props }: useRender.ComponentProps<"button">) { @@ -24,7 +35,7 @@ export function PaneTabButton({ className, render, ...props }: useRender.Compone type: render ? undefined : "button", className: cn( PANE_TAB_CLASS, - "cursor-pointer text-left hover:surface-ghost-hover", + "cursor-pointer text-left hover:surface-ghost-hover group-hover/tab:surface-ghost-hover", className, ), }, @@ -33,20 +44,184 @@ export function PaneTabButton({ className, render, ...props }: useRender.Compone }); } -/** Render first in a positioned pane header; index.css paints the notch on inset panes. - * The final child may use data-pane-notch-add to retain tab-end padding without a surface. +/** + * The body under a pane header that has no surface of its own (a surface tab's, + * the launcher's, Settings'): it may reach up under the header by + * --pane-surface-reach (see [data-surface-reaches-under-header] in index.css), + * and stays a layer below it whatever its own z-indexes. */ -export function PaneNotch({ children }: { readonly children: ReactNode }) { +export const PANE_BODY_UNDER_HEADER_CLASS = + "isolate [--pane-surface-reach:var(--pane-header-overlap,0px)]"; + +// Notches inside each pane or inset. index.css squares the corner of a host +// marked data-pane-notched; matching hosts with :has([data-pane-notch]) +// instead had the browser recheck their whole subtree on every DOM change. +const notchCounts = { + left: new WeakMap(), + right: new WeakMap(), +}; + +function markNotchHost(host: Element | null, side: "left" | "right"): () => void { + if (!host) return () => {}; + const counts = notchCounts[side]; + const attribute = side === "left" ? "data-pane-notched" : "data-pane-notched-right"; + counts.set(host, (counts.get(host) ?? 0) + 1); + host.setAttribute(attribute, ""); + return () => { + const count = (counts.get(host) ?? 1) - 1; + counts.set(host, count); + if (count === 0) host.removeAttribute(attribute); + }; +} + +/* ── The notch: light, black chrome only. Elsewhere the row is a bar and the + tabs join the pane there (index.css, connected pane tabs). Close notches of + one header merge into one chrome row. ── */ +const PANE_NOTCH_MERGE_MARGIN_PX = 4; +const notchHeaders = new WeakMap< + HTMLElement, + { + readonly notches: Map; + readonly refresh: () => void; + } +>(); + +/** One subscription per header, including breakpoint-switched controls. */ +function observeNotchGap(notch: HTMLDivElement, side: "left" | "right"): () => void { + const header = notch.closest("[data-pane-header]"); + if (!header) return () => {}; + let registration = notchHeaders.get(header); + if (!registration) { + const notches = new Map(); + let stopObserving = () => {}; + const update = () => { + let left: HTMLDivElement | undefined; + let right: HTMLDivElement | undefined; + for (const [element, notchSide] of notches) { + if (element.offsetWidth === 0) continue; + if (notchSide === "left") left = element; + else right = element; + } + let merged = false; + if (left && right) { + const leftRect = left.getBoundingClientRect(); + const rightRect = right.getBoundingClientRect(); + // The pseudo-element resolves --pane-notch-tail to pixels, including rem sizing. + const tail = Number.parseFloat(getComputedStyle(left, "::after").width); + const mergeGap = 2 * tail + PANE_NOTCH_MERGE_MARGIN_PX; + // Compare in layout pixels even when the overview scales the pane. + const scale = leftRect.width / left.offsetWidth; + merged = scale > 0 && (rightRect.left - leftRect.right) / scale < mergeGap; + } + if (header.hasAttribute("data-pane-notches-merged") !== merged) { + header.toggleAttribute("data-pane-notches-merged", merged); + } + }; + const refresh = () => { + stopObserving(); + const sides = new Set(notches.values()); + // Header resizing moves fixed-width notches without resizing either notch. + stopObserving = + sides.size === 2 ? observeResize([header, ...notches.keys()], update) : () => {}; + update(); + }; + registration = { notches, refresh }; + notchHeaders.set(header, registration); + } + const { notches, refresh } = registration; + notches.set(notch, side); + refresh(); + return () => { + notches.delete(notch); + refresh(); + if (notches.size === 0) notchHeaders.delete(header); + }; +} + +/** + * Whether pane tabs sit on a bar and the active one joins its pane (index.css, + * connected pane tabs): from md up, except under light, black chrome, which + * keeps the notch. + */ +function useConnectedPaneTabs(): boolean { + const isMobile = useMediaQuery("max-md"); + const { blackChrome, resolvedTheme } = useTheme(); + return !isMobile && !(blackChrome && resolvedTheme === "light"); +} + +/** Render first inside a positioned pane header starting at the pane edge. Keep the plus last so the + * notch includes its trailing padding. */ +export function PaneNotch({ + children, + side = "left", + marksHost = true, + className, +}: { + readonly children: ReactNode; + readonly side?: "left" | "right"; + /** Off for a notch drawn where it cannot show (a hidden overview thumbnail). */ + readonly marksHost?: boolean; + readonly className?: string; +}) { // The tab sits on the window chrome where the notch shows, so light, black // chrome gives it the dark tokens there (index.css). const isMobile = useMediaQuery("max-md"); + // Only the notch has two ends that may merge; the bar spans the row. + const connected = useConnectedPaneTabs(); + const notchRef = useRef(null); + const { split, focused, visible } = useLayoutPane(); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Mark the mounted notch ancestors and remove those external DOM markers on cleanup. + useLayoutEffect(() => { + const notch = notchRef.current; + if (!notch || !marksHost || !visible) return; + const unmarkPane = markNotchHost( + notch.closest( + side === "right" ? "[data-layout-tab-id], [data-pane-notch-host]" : "[data-layout-tab-id]", + ), + side, + ); + const unmarkInset = markNotchHost(notch.closest('[data-slot="sidebar-inset"]'), side); + return () => { + unmarkPane(); + unmarkInset(); + }; + }, [marksHost, side, visible]); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Observe the notch DOM gap before paint and stop observing when its host changes. + useLayoutEffect(() => { + const notch = notchRef.current; + if (!notch || !marksHost || isMobile || connected) return; + return observeNotchGap(notch, side); + }, [connected, isMobile, marksHost, side]); return ( <> - + + {side === "left" ? ( + <> + + + + ) : null}
[data-pane-notch-add]]:pe-3.5" + : "md:ps-(--pane-notch-tail)", + className, + )} > {children}
diff --git a/apps/web/src/components/PanelTabBreadcrumb.browser.test.tsx b/apps/web/src/components/PanelTabBreadcrumb.browser.test.tsx new file mode 100644 index 0000000000000..674dcf6648151 --- /dev/null +++ b/apps/web/src/components/PanelTabBreadcrumb.browser.test.tsx @@ -0,0 +1,63 @@ +import { useState, type ReactNode } from "react"; +import { expect, it } from "vite-plus/test"; + +import { render } from "../../test/render"; +import { + PanelTabActions, + PanelTabSlotsContext, + usePanelTabActionsHost, +} from "./PanelTabBreadcrumb"; + +function TabBar({ children }: { children: ReactNode }) { + const { hasActions, registerActions } = usePanelTabActionsHost(); + const [actions, setActions] = useState(null); + return ( + <> + {hasActions ?
: null} + + {children} + + + ); +} + +function Surface({ actions }: { actions: readonly string[] }) { + return actions.map((label) => ( + + + + )); +} + +it("draws the actions notch only while a surface provides actions", async () => { + await using screen = await render( + + + , + { router: false }, + ); + const notch = screen.getByTestId("actions-notch"); + await expect.element(notch).not.toBeInTheDocument(); + + await screen.rerender( + + + , + ); + await expect.element(notch.getByRole("button", { name: "Wrap" })).toBeVisible(); + await expect.element(notch.getByRole("button", { name: "Refresh" })).toBeVisible(); + + await screen.rerender( + + + , + ); + await expect.element(notch.getByRole("button", { name: "Refresh" })).toBeVisible(); + + await screen.rerender( + + + , + ); + await expect.element(notch).not.toBeInTheDocument(); +}); diff --git a/apps/web/src/components/PanelTabBreadcrumb.tsx b/apps/web/src/components/PanelTabBreadcrumb.tsx new file mode 100644 index 0000000000000..dd7c2347816dc --- /dev/null +++ b/apps/web/src/components/PanelTabBreadcrumb.tsx @@ -0,0 +1,54 @@ +import { createContext, useContext, useLayoutEffect, useState, type ReactNode } from "react"; +import { createPortal } from "react-dom"; + +/** Header action slots shared by terminal and side chat surfaces. */ +export interface PanelTabSlots { + readonly breadcrumb: HTMLElement | null; + readonly actions: HTMLElement | null; + readonly registerActions: () => () => void; +} + +export const PanelTabSlotsContext = createContext(null); + +/** + * Whether the surface renders inside a panel tab bar, which then carries its + * actions. A surface drops its own header row when it does. + */ +export function useInPanelTabBar(): boolean { + return useContext(PanelTabSlotsContext) !== null; +} + +export function usePanelTabActionsHost(): { + hasActions: boolean; + registerActions: () => () => void; +} { + const [count, setCount] = useState(0); + const registerActions = () => { + setCount((current) => current + 1); + return () => setCount((current) => current - 1); + }; + return { hasActions: count > 0, registerActions }; +} + +function PanelTabPortal({ + slot, + children, +}: { + slot: "breadcrumb" | "actions"; + children: ReactNode; +}) { + const slots = useContext(PanelTabSlotsContext); + if (!slots) return children; + const element = slots[slot]; + return element ? createPortal(children, element) : null; +} + +/** + * Surface actions render at the end of the tab row, or in place outside it. + */ +export function PanelTabActions({ children }: { children: ReactNode }) { + const registerActions = useContext(PanelTabSlotsContext)?.registerActions; + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Tell the tab bar before paint, so its actions notch never draws empty. + useLayoutEffect(() => registerActions?.(), [registerActions]); + return {children}; +} diff --git a/apps/web/src/components/ProjectScriptsControl.tsx b/apps/web/src/components/ProjectScriptsControl.tsx index f3fae7a2e5c02..d13b384c98faa 100644 --- a/apps/web/src/components/ProjectScriptsControl.tsx +++ b/apps/web/src/components/ProjectScriptsControl.tsx @@ -409,9 +409,10 @@ export default function ProjectScriptsControl({ )} ) : isPanel ? ( -
+
{primaryScript ? ( onRunScript?.(primaryScript)} @@ -425,27 +426,34 @@ export default function ProjectScriptsControl({ {primaryScript.name} ) : ( - + Add project action… )} {supportsStop && runningScriptServer?.pid != null ? ( - { - setStoppingServer(true); - await stopServer({ - environmentId, - input: { pid: runningScriptServer.pid!, port: runningScriptServer.port }, - }); - setStoppingServer(false); - }} - > - - + + { + setStoppingServer(true); + await stopServer({ + environmentId, + input: { pid: runningScriptServer.pid!, port: runningScriptServer.port }, + }); + setStoppingServer(false); + }} + /> + } + > + + + {`Stop ${primaryScript?.name}`} + ) : null} - }> - - + + } + /> + } + > + + + Project actions + {scriptItems} diff --git a/apps/web/src/components/ProviderUpdateLaunchNotification.tsx b/apps/web/src/components/ProviderUpdateLaunchNotification.tsx deleted file mode 100644 index c5be98b4a4af3..0000000000000 --- a/apps/web/src/components/ProviderUpdateLaunchNotification.tsx +++ /dev/null @@ -1,82 +0,0 @@ -import { useAtomValue } from "@effect/atom-react"; -import { PROVIDER_DISPLAY_NAMES } from "@t3tools/contracts"; -import { useEffect, useRef } from "react"; - -import { writeTextToClipboard } from "../hooks/useCopyToClipboard"; -import { primaryServerProvidersAtom } from "../state/server"; -import { CommandBlock } from "./CommandBlock"; -import { ProviderInstanceIcon } from "./chat/ProviderInstanceIcon"; -import { - collectProviderUpdateCandidates, - isProviderUpdateActive, -} from "./ProviderUpdateLaunchNotification.logic"; -import { toastManager } from "./ui/toast"; - -/** Announce each available provider version once per client session, using the server's command. */ -export function ProviderUpdateLaunchNotification() { - const providers = useAtomValue(primaryServerProvidersAtom); - const shown = useRef(new Set()); - const notices = useRef(new Map>()); - - // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- reconciles server version advisories with the external toast manager - useEffect(() => { - const candidates = collectProviderUpdateCandidates(providers).filter( - (provider) => !isProviderUpdateActive(provider), - ); - const active = new Set( - candidates.map((provider) => `${provider.driver}:${provider.versionAdvisory.latestVersion}`), - ); - for (const [key, id] of notices.current) { - if (!active.has(key)) { - toastManager.close(id); - notices.current.delete(key); - } - } - for (const provider of candidates) { - const advisory = provider.versionAdvisory; - const key = `${provider.driver}:${advisory.latestVersion}`; - const command = advisory.updateCommand; - if (shown.current.has(key) || command === null) continue; - shown.current.add(key); - const name = PROVIDER_DISPLAY_NAMES[provider.driver] ?? provider.driver; - const current = advisory.currentVersion ?? provider.version; - notices.current.set( - key, - toastManager.add({ - type: "info", - title: `${name} ${advisory.latestVersion} is available`, - description: current - ? `You're on ${current}. New sessions will use the updated provider.` - : "New sessions will use the updated provider.", - timeout: 0, - actionProps: { - children: "Copy update command", - onClick: () => { - void writeTextToClipboard(command, "update command"); - }, - }, - data: { - variant: "notification", - leadingIcon: ( - - ), - body: , - }, - }), - ); - } - }, [providers]); - - // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- removes this coordinator's external notices when its authenticated shell unmounts - useEffect(() => { - const owned = notices.current; - return () => { - for (const id of owned.values()) toastManager.close(id); - }; - }, []); - return null; -} diff --git a/apps/web/src/components/ReopenClosedViewShortcut.browser.test.tsx b/apps/web/src/components/ReopenClosedViewShortcut.browser.test.tsx index dd12cedfbdefd..85e577899a738 100644 --- a/apps/web/src/components/ReopenClosedViewShortcut.browser.test.tsx +++ b/apps/web/src/components/ReopenClosedViewShortcut.browser.test.tsx @@ -148,6 +148,33 @@ function press(target: Element = document.body) { } describe("root reopen shortcut", () => { + it.each(["dialog", "alert-dialog", "app"])("yields to an open %s modal", async (kind) => { + setup(); + const entry = useClosedViewStore + .getState() + .remember({ kind: "browser", threadRef: ref, snapshot }); + state.openPreview.mockResolvedValue(AsyncResult.success({ ...snapshot, tabId: "new-tab" })); + await using view = await renderShortcut(); + const modal = view.append(document.createElement("div")); + if (kind === "app") modal.setAttribute("data-app-modal", ""); + else { + modal.setAttribute("data-slot", `${kind}-popup`); + modal.setAttribute("data-open", ""); + } + await act(() => { + press(modal); + menuAction?.("view.reopenClosed"); + }); + expect(state.openPreview).not.toHaveBeenCalled(); + expect(useClosedViewStore.getState().entries.map((item) => item.id)).toEqual([entry]); + modal.remove(); + await act(() => { + press(); + }); + expect(state.openPreview).toHaveBeenCalledOnce(); + expect(useClosedViewStore.getState().entries).toEqual([]); + }); + it("serializes deliberate presses while a browser restore waits for RPC", async () => { setup(); useClosedViewStore diff --git a/apps/web/src/components/ReopenClosedViewShortcut.tsx b/apps/web/src/components/ReopenClosedViewShortcut.tsx index f49e1b54ceed6..295e5de61bebd 100644 --- a/apps/web/src/components/ReopenClosedViewShortcut.tsx +++ b/apps/web/src/components/ReopenClosedViewShortcut.tsx @@ -14,11 +14,13 @@ import { useComposerDraftStore } from "../composerDraftStore"; import { environmentCatalog } from "../connection/catalog"; import { effectiveShortcutsForCommand, resolveShortcutCommand } from "../keybindings"; import { isEditableFocused } from "../lib/editableFocus"; +import { isModalDialogOpen } from "../lib/modalDialog"; import { isPreviewFocused } from "../lib/previewFocus"; import { isTerminalFocused } from "../lib/terminalFocus"; import { isModelPickerOpen } from "../modelPickerVisibility"; import { planNextReopen, + closedChatTarget, pullRequestsSearchForRestore, reopenClosedView, } from "../reopenClosedView"; @@ -36,6 +38,11 @@ import { primaryServerKeybindingsAtom } from "../state/server"; import { environmentShell } from "../state/shell"; import { useAtomCommand } from "../state/use-atom-command"; import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; +import { + isThreadlessSurfacesRef, + readThreadlessHome, + threadlessSurfacesProjectRef, +} from "../threadlessSurfaces"; import { buildDraftThreadRouteParams, buildThreadRouteParams, @@ -47,7 +54,6 @@ const PREVIEW_FORWARDED_COMMANDS = [ "workspace.focusNext", "workspace.focusPrevious", "sidebar.toggle", - "rightPanel.toggle", "preview.focusUrl", "preview.zoomIn", "preview.zoomOut", @@ -100,10 +106,18 @@ export function ReopenClosedViewShortcut() { panel, }; } + // A threadless surface's owner is its project, or nothing to lose. + const threadlessProject = threadlessSurfacesProjectRef(ref); return { environmentKnown: catalog.entries.has(ref.environmentId), catalogReady: catalog.isReady, - ownerExists: readThreadShell(ref) !== null || drafts.getDraftThreadByRef(ref) !== null, + ownerExists: isThreadlessSurfacesRef(ref) + ? threadlessProject === null || readProject(threadlessProject) !== null + : readThreadShell(ref) !== null || + drafts.getDraftThreadByRef(ref) !== null || + (entry.kind === "chat" && + entry.draftId !== undefined && + drafts.getDraftSession(entry.draftId) !== null), sideChatExists: entry.kind !== "panel-tab" || entry.surface.kind !== "side-chat" || @@ -120,12 +134,31 @@ export function ReopenClosedViewShortcut() { const globalPullRequests = isGlobalPullRequests(ref); const thread = globalPullRequests ? null : readThreadShell(ref); const owner = thread ?? drafts.getDraftThreadByRef(ref); - const project = owner ? readProject(scopeProjectRef(ref.environmentId, owner.projectId)) : null; - if (!(await reopenClosedView(restore, { openPreview, workspaceAvailable: project !== null }))) { + const ownerProject = owner + ? scopeProjectRef(ref.environmentId, owner.projectId) + : threadlessSurfacesProjectRef(ref); + const project = ownerProject ? readProject(ownerProject) : null; + // A project-less threadless ref's files are the server's home. + const workspaceAvailable = project !== null || readThreadlessHome(ref) !== null; + if (!(await reopenClosedView(restore, { openPreview, workspaceAvailable }))) { useClosedViewStore.getState().defer(restore.id); return; } useClosedViewStore.getState().remove(restore.id); + if (restore.kind === "chat") { + const target = closedChatTarget(restore); + if (target.kind === "draft") + await navigate({ + to: "/draft/$draftId", + params: buildDraftThreadRouteParams(target.draftId), + }); + else + await navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams(target.threadRef), + }); + return; + } if (globalPullRequests) { const selected = selectSelectedRightPanelSurface( useRightPanelStore.getState().byThreadKey, @@ -135,7 +168,8 @@ export function ReopenClosedViewShortcut() { to: "/pull-requests", search: (previous) => pullRequestsSearchForRestore(previous, selected), }); - } else { + } else if (!isThreadlessSurfacesRef(ref)) { + // A threadless surface has no route; its tab shows it. const draftId = thread === null ? drafts.getDraftIdByRef(ref) : null; if (draftId !== null) await navigate({ to: "/draft/$draftId", params: buildDraftThreadRouteParams(draftId) }); @@ -161,6 +195,7 @@ export function ReopenClosedViewShortcut() { if ( event.defaultPrevented || isCommandPaletteOpen() || + isModalDialogOpen() || useClosedViewStore.getState().entries.length === 0 || (event.target instanceof HTMLElement && event.target.closest("[data-keybinding-capture]")) ) @@ -182,7 +217,8 @@ export function ReopenClosedViewShortcut() { }; window.addEventListener("keydown", onKeyDown, true); const unsubscribe = window.desktopBridge?.onMenuAction((action) => { - if (action === "view.reopenClosed" && !isCommandPaletteOpen()) enqueueReopen(); + if (action === "view.reopenClosed" && !isCommandPaletteOpen() && !isModalDialogOpen()) + enqueueReopen(); }); return () => { window.removeEventListener("keydown", onKeyDown, true); diff --git a/apps/web/src/components/RightPanelSheet.browser.test.tsx b/apps/web/src/components/RightPanelSheet.browser.test.tsx deleted file mode 100644 index c99e1e218b41c..0000000000000 --- a/apps/web/src/components/RightPanelSheet.browser.test.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { expect, it } from "vite-plus/test"; -import { page } from "vite-plus/test/browser"; - -import { render } from "../../test/render"; -import { RightPanelSheet } from "./RightPanelSheet"; - -it("names the open right-panel dialog", async () => { - await using _screen = await render( - {}}> -
Panel content
-
, - ); - const dialog = page.getByRole("dialog"); - await expect.element(dialog).toBeVisible(); - await expect.element(dialog).not.toHaveAccessibleName(""); -}); diff --git a/apps/web/src/components/RightPanelSheet.tsx b/apps/web/src/components/RightPanelSheet.tsx deleted file mode 100644 index 56846307b83da..0000000000000 --- a/apps/web/src/components/RightPanelSheet.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { type ReactNode } from "react"; - -import { Sheet, SheetHeader, SheetPopup, SheetTitle } from "./ui/sheet"; - -export function RightPanelSheet(props: { - animationDurationMs: number; - children: ReactNode; - open: boolean; - onClose: () => void; -}) { - return ( - { - if (!open) { - props.onClose(); - } - }} - > - - - Workspace panel - - {props.children} - - - ); -} diff --git a/apps/web/src/components/RightPanelTabs.browser.test.tsx b/apps/web/src/components/RightPanelTabs.browser.test.tsx index f803cb41520bc..de8c95e17a6c8 100644 --- a/apps/web/src/components/RightPanelTabs.browser.test.tsx +++ b/apps/web/src/components/RightPanelTabs.browser.test.tsx @@ -64,79 +64,81 @@ const surfaces: RightPanelSurface[] = Array.from({ length: 16 }, (_, index) => ( })); describe("RightPanelTabs", () => { - it("a flick that scrolls the tab strip to its left edge keeps the thread, a swipe from the edge goes back", async () => { - navigateHistory.mockReset(); - const { bridge, scroll } = trackpad(); - using _bridge = stubGlobal("desktopBridge", bridge); - using _platform = vi.spyOn(navigator, "platform", "get").mockReturnValue("MacIntel"); - const panel = document.createElement("div"); - panel.style.cssText = "width: 320px; height: 200px; display: flex"; - const noop = () => {}; - await using screen = await render( - <> - - ({ - terminalFocus: false, - terminalOpen: false, - previewFocus: false, - previewOpen: false, - isWeb: true, - isDesktop: false, - })} - surfaces={surfaces} - environmentId={null} - activeSurfaceId={null} - pendingSurfaceIds={new Set()} - previewSessions={{}} - desktopByTabId={{}} - terminalLabelsById={new Map(surfaces.map((surface) => [surface.id, "Terminal"]))} - onActivate={noop} - onCloseSurface={noop} - onCloseOtherSurfaces={noop} - onCloseSurfacesToRight={noop} - onCloseAllSurfaces={noop} - onCopyFilePath={noop} - onAddBrowser={noop} - onAddBrowserInProfile={noop} - onAddTerminal={noop} - onAddDiff={noop} - onAddFiles={noop} - onAddPullRequest={noop} - onAddPullRequests={noop} - onAddDevice={noop} - browserAvailable={false} - terminalAvailable - diffAvailable={false} - filesAvailable={false} - pullRequestAvailable={false} - pullRequestsAvailable={false} - deviceAvailable={false} - > - {null} - - , - { container: panel }, - ); - const viewport = screen.container.querySelector( - '[data-right-panel-tab-list] [data-slot="scroll-area-viewport"]', - )!; - expect(viewport.scrollWidth - viewport.clientWidth).toBeGreaterThan(120); - viewport.scrollLeft = 120; + it.each(["embedded", "inline"] as const)( + "%s: a flick that scrolls the tab strip to its left edge keeps the thread, a swipe from the edge goes back", + async (mode) => { + navigateHistory.mockReset(); + const { bridge, scroll } = trackpad(); + using _bridge = stubGlobal("desktopBridge", bridge); + using _platform = vi.spyOn(navigator, "platform", "get").mockReturnValue("MacIntel"); + const panel = document.createElement("div"); + panel.style.cssText = "width: 320px; height: 200px; display: flex"; + const noop = () => {}; + await using screen = await render( + <> + + [surface.id, "Terminal"]))} + onActivate={noop} + onCloseSurface={noop} + onCloseOtherSurfaces={noop} + onCloseSurfacesToRight={noop} + onCloseAllSurfaces={noop} + onCopyFilePath={noop} + onAddBrowser={noop} + onAddBrowserInProfile={noop} + onAddTerminal={noop} + onAddDiff={noop} + onAddFiles={noop} + onAddPullRequest={noop} + onAddPullRequests={noop} + onAddDevice={noop} + browserAvailable={false} + terminalAvailable + diffAvailable={false} + filesAvailable={false} + pullRequestAvailable={false} + pullRequestsAvailable={false} + deviceAvailable={false} + > + {null} + + , + { container: panel }, + ); + const viewport = screen.container.querySelector( + '[data-layout-tab-strip], [data-right-panel-tab-list] [data-slot="scroll-area-viewport"]', + )!; + expect(viewport.scrollWidth - viewport.clientWidth).toBeGreaterThan(120); + if (mode === "inline") { + viewport.scrollLeft = 120; + await userEvent.wheel(screen.getByRole("button", { name: "Scroll tabs right" }), { + delta: { x: 40 }, + }); + await vi.waitFor(() => expect(viewport.scrollLeft).toBe(160)); + } + viewport.scrollLeft = 120; - const momentum = Array.from({ length: 24 }, (_, index) => -60 * 0.85 ** index); - scroll(viewport.querySelector("[data-active-tab]")!, [-10, -20, -30, ...momentum]); + const momentum = Array.from({ length: 24 }, (_, index) => -60 * 0.85 ** index); + scroll(viewport.querySelector("[data-active-tab]")!, [-10, -20, -30, ...momentum]); - expect(viewport.scrollLeft).toBe(0); - expect(navigateHistory).not.toHaveBeenCalled(); + expect(viewport.scrollLeft).toBe(0); + expect(navigateHistory).not.toHaveBeenCalled(); - viewport.scrollLeft = 0; - scroll(viewport.querySelector("[data-active-tab]")!, [-40, -40, -40, -40, -40]); + viewport.scrollLeft = 0; + scroll(viewport.querySelector("[data-active-tab]")!, [-40, -40, -40, -40, -40]); - expect(navigateHistory).toHaveBeenCalledExactlyOnceWith("back"); - }); + expect(navigateHistory).toHaveBeenCalledExactlyOnceWith("back"); + }, + ); }); const BROWSER_SURFACE: RightPanelSurface = { @@ -151,14 +153,6 @@ function panel(activeSurface: RightPanelSurface) { const props: RightPanelTabsProps = { mode: "inline", keybindings: [], - getShortcutContext: () => ({ - terminalFocus: false, - terminalOpen: false, - previewFocus: false, - previewOpen: true, - isWeb: true, - isDesktop: false, - }), surfaces: [BROWSER_SURFACE, DIFF_SURFACE], environmentId: EnvironmentId.make("environment-local"), activeSurfaceId: activeSurface.id, diff --git a/apps/web/src/components/RightPanelTabs.test.tsx b/apps/web/src/components/RightPanelTabs.test.tsx index 88793cce44850..65cb25c4c7372 100644 --- a/apps/web/src/components/RightPanelTabs.test.tsx +++ b/apps/web/src/components/RightPanelTabs.test.tsx @@ -1,11 +1,8 @@ import { EnvironmentId, type ThreadPullRequestLink } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; -import { - resolvePullRequestTabLink, - surfaceShortcutActionForKey, - surfaceShortcutTargetsTypingContext, -} from "./RightPanelTabs"; +import { surfaceShortcutActionForKey, surfaceShortcutTargetsTypingContext } from "./RightPanelTabs"; +import { resolvePullRequestTabLink } from "./SurfaceTabIcon"; function shortcutEvent( key: string, diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index eaa73aeec32d7..00c7a1e2d75dc 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -1,29 +1,25 @@ -import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; import { scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { useQuietThreadShell } from "~/state/entities"; +import { useLayoutPane, useLayoutPaneFocusedRef } from "../layout/layoutPaneContext"; +import { beginLayoutTabDrag } from "../layout/layoutDrag"; +import { useLayoutStore } from "../layout/layoutStore"; import { - threadRuntimeIsActive, - type EnvironmentThreadShell, -} from "@t3tools/client-runtime/state/shell"; -import { - useProjects, - useQuietThreadShell, - useServerConfigs, - useThreadShells, -} from "~/state/entities"; -import { - threadPullRequestKeysEqual, - visibleThreadPullRequests, -} from "@t3tools/client-runtime/thread-pull-requests"; + finishLayoutTabRename, + LayoutPaneTab, + LayoutPaneTabStrip, + showLayoutTabMenu, + tabMuteMenuItem, + useLayoutPaneSiblingTabs, + useLayoutTabLabel, + useLayoutTabRenameRequested, +} from "../layout/PaneTabs"; +import { findLayoutTab, type LayoutTab } from "../layout/layoutModel"; import type { ContextMenuItem, EnvironmentId, PreviewSessionSnapshot, - ProjectId, - PullRequestState, ResolvedKeybindingsConfig, - ThreadId, } from "@t3tools/contracts"; -import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; import { closestCenter, DndContext, @@ -36,26 +32,19 @@ import { restrictToFirstScrollableAncestor, restrictToHorizontalAxis } from "@dn import { horizontalListSortingStrategy, SortableContext, useSortable } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import { - Smartphone, - ChevronDown, ChevronLeft, ChevronRight, - FileDiff, - Files, - Globe2, - MessagesSquare, + EllipsisIcon, Plus, - TerminalSquare, Volume2Icon, VolumeOffIcon, } from "lucide-react"; + import { type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, - type ReactElement, type ReactNode, - useEffect, - useEffectEvent, + type Ref, useRef, useState, } from "react"; @@ -64,57 +53,68 @@ import { isElectron } from "~/env"; import type { DesktopPreviewOverlay } from "~/previewStateStore"; import type { RightPanelSurface } from "~/rightPanelStore"; import { cn } from "~/lib/utils"; -import { resolveShortcutCommand, type ShortcutMatchContext } from "~/keybindings"; import { readLocalApi } from "~/localApi"; import { useStableCallback } from "~/hooks/useStableCallback"; import { Button } from "~/components/ui/button"; -import { AndroidIcon, AppleIcon } from "~/components/Icons"; import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; -import { Kbd } from "~/components/ui/kbd"; -import { - Menu, - MenuItem, - MenuPopup, - MenuShortcut, - MenuSub, - MenuSubPopup, - MenuSubTrigger, - MenuTrigger, -} from "~/components/ui/menu"; +import { Menu, MenuPopup, MenuTrigger } from "~/components/ui/menu"; import { useBrowserDefaults } from "~/browser/browserDefaults"; import { ScrollArea } from "~/components/ui/scroll-area"; +import { scrollTabStripFromRow } from "../motion/gesture"; import { PanelTabCloseButton } from "~/components/ui/panel-tab-close-button"; -import { faviconUrlForOrigin } from "@t3tools/shared/favicon"; import { useTheme } from "~/hooks/useTheme"; +import { useMediaQuery } from "~/hooks/useMediaQuery"; +import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover"; import { useDeviceState } from "~/state/device"; import type { PreviewPanelInlineSize } from "~/hooks/usePreviewPanelInlineSize"; -import { - newestPullRequestSummary, - pullRequestEnvironment, - useSharedPullRequestSummary, -} from "~/state/pullRequests"; -import { useEnvironmentQuery } from "~/state/query"; +import { useRevealActiveTab } from "~/hooks/useRevealActiveTab"; import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar"; import { PreviewPanelShell, type PreviewPanelMode } from "./preview/PreviewPanelShell"; -import { FaviconImage } from "./preview/PreviewFaviconIcon"; +import { PanelTabSlotsContext, usePanelTabActionsHost } from "./PanelTabBreadcrumb"; +import { + PANE_BODY_UNDER_HEADER_CLASS, + PANE_INACTIVE_TAB_TITLE_CLASS, + PANE_TAB_TITLE_CLASS, + PaneNotch, +} from "./PaneNotch"; import { previewBridge } from "./preview/previewBridge"; import { dispatchPreviewAction } from "./preview/previewActionBus"; import { preventThumbButtonFocus, takeThumbButtonNavigation } from "./preview/thumbButtons"; -import { PierreEntryIcon } from "./chat/PierreEntryIcon"; -import { resolvePullRequestState } from "./pullRequest/pullRequestPresentation"; -import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons"; import { observeResize } from "~/lib/observeResize"; -import { surfaceShortcutKey } from "~/lib/surfaceShortcutKey"; +import { + previewFaviconOf, + SurfaceIcon, + surfaceTitle, + type PullRequestTabStatusSeed, +} from "./SurfaceTabIcon"; +import { + SurfaceLauncherRows, + SurfaceMenuItems, + surfaceMenuActions, + surfaceShortcutActionForKey, + useLauncherElement, + useSurfaceLauncherKeys, + type SurfaceLauncherAction, +} from "./SurfaceLauncher"; + +export { + surfaceShortcutActionForKey, + surfaceShortcutTargetsTypingContext, +} from "./SurfaceLauncher"; export interface RightPanelTabsProps { mode: PreviewPanelMode; maximized?: boolean; open?: boolean; keybindings: ResolvedKeybindingsConfig; - getShortcutContext: () => ShortcutMatchContext; widthStorageKey?: string; defaultWidth?: number; + /** + * An inline panel drawn as its own inset pane, notch and all, beside a page's pane (the + * pull requests page's review, pen bETcv). + */ + pane?: boolean; inlineSize?: PreviewPanelInlineSize; layoutControls?: ReactNode; surfaces: readonly RightPanelSurface[]; @@ -134,9 +134,13 @@ export interface RightPanelTabsProps { onCloseAllSurfaces: () => void; onMoveSurface?: (surfaceId: string, toIndex: number) => void; onCopyFilePath: (relativePath: string) => void; + /** Escape on the empty pane's chooser closes the pane; the sheet closes itself. */ + onClose?: () => void; onAddBrowser: () => void; /** Keep separate from the DOM click handler so a MouseEvent cannot become a profile id. */ onAddBrowserInProfile: (profileId: string) => void; + /** The "+" menu's New thread, in the thread's project; omitted, it has none. */ + onNewThread?: (() => void) | undefined; onAddTerminal: () => void; onAddDiff: () => void; onAddFiles: () => void; @@ -146,6 +150,8 @@ export interface RightPanelTabsProps { /** Absent where side chats are not offered. */ onAddSideChat?: (() => void) | undefined; browserAvailable: boolean; + /** Why the browser cannot open, when it cannot. */ + browserUnavailableReason?: string | null; terminalAvailable: boolean; diffAvailable: boolean; filesAvailable: boolean; @@ -156,23 +162,11 @@ export interface RightPanelTabsProps { /** Overrides the default reason while side chats are available but cannot start. */ sideChatDisabledReason?: string | null; pullRequestStatusSeeds?: Readonly>; + /** The linked thread stays in the tab's tooltip when its surface moves elsewhere. */ + threadTitle?: string | null; children: ReactNode; } -export interface PullRequestTabStatus { - projectId: string; - repository: string; - number: number; - state: PullRequestState; - isDraft: boolean; -} - -export type PullRequestTabStatusSeed = Pick; - -function shouldOpenDefaultBrowserProfileFromMenuClick(pointerType: string | undefined): boolean { - return pointerType !== "touch"; -} - const SURFACE_DISABLED_REASONS = { browser: "Browser previews are only available in the T3 Code desktop app.", terminal: "Terminal surfaces are only available from a project thread.", @@ -184,31 +178,6 @@ const SURFACE_DISABLED_REASONS = { sideChat: "Side chats start after the first message.", } as const; -// Closed keepMounted popups must stop blocking shortcuts once their exit transition ends. -const LAUNCHER_SHORTCUT_BLOCKING_LAYERS = [ - "dialog-popup", - "alert-dialog-popup", - "command-dialog-popup", - "menu-popup", - "select-popup", - "popover-popup", - "combobox-popup", - "autocomplete-popup", -] - .map((slot) => `[data-slot="${slot}"]:is([data-open],[data-ending-style])`) - .join(","); - -const SURFACE_UNAVAILABLE_HINTS = { - browser: "Only available in the desktop app.", - terminal: "Available when a project is open.", - files: "Available when a project is open.", - diff: "Available for Git repositories.", - pullRequest: "No pull request on this branch yet.", - pullRequests: "No linked pull requests available.", - device: "Available from a thread.", - sideChat: "Available after the first message.", -} as const; - type TabContextMenuAction = | "rename" | "copy-path" @@ -233,22 +202,6 @@ function previewTabIdOf( return sessions[surface.resourceId]?.tabId ?? null; } -/** - * Stays disabled until desktop overlay state arrives: a server session id can - * resolve while the preview manager's createTab is still in flight, and muting - * then fails with a PreviewTabNotFoundError nothing surfaces to the user. - */ -function tabMuteMenuItem(input: { - overlay: DesktopPreviewOverlay | null; - canResolveRuntimeTabId: boolean; -}): { label: string; disabled: boolean } { - const muted = input.overlay?.audioMuted ?? false; - return { - label: muted ? "Unmute tab" : "Mute tab", - disabled: input.overlay === null || !input.canResolveRuntimeTabId, - }; -} - type TabAudioState = "none" | "audible" | "muted"; function tabAudioState(overlay: DesktopPreviewOverlay | null): TabAudioState { @@ -256,65 +209,103 @@ function tabAudioState(overlay: DesktopPreviewOverlay | null): TabAudioState { return overlay.audioMuted ? "muted" : "audible"; } -type SurfaceShortcutEvent = Pick< - KeyboardEvent, - "altKey" | "ctrlKey" | "defaultPrevented" | "isComposing" | "key" | "metaKey" -> & - Partial>; - -export function surfaceShortcutActionForKey< - const Action extends { available: boolean; shortcut: string }, ->(actions: readonly Action[], event: SurfaceShortcutEvent): Action | null { - if (event.defaultPrevented || event.isComposing) return null; - if (event.metaKey || event.ctrlKey || event.altKey) return null; - const key = surfaceShortcutKey(event); +/** + * A layout pane's tabs sit in its notch with "+" as the notch's last child, + * like a chat's and the terminal's; the sheet and inline panel keep a plain row. + */ +function PanelTabStrip({ + notched, + noDrag, + ref, + children, +}: { + notched: boolean; + noDrag: boolean; + ref: Ref; + children: ReactNode; +}) { + if (notched) { + return ( +
+ {children} +
+ ); + } return ( - actions.find((action) => action.available && action.shortcut.toLowerCase() === key) ?? null + +
{children}
+
); } -/** Skip contenteditable=false islands to find their editable host, matching the composer typing guard. */ -export function surfaceShortcutTargetsTypingContext( - target: { closest(selectors: string): unknown } | null, -): boolean { +function PanelTabsNotch(props: { + notched: boolean; + /** A pane's second strip, beside its own (the pull request beside its list). */ + secondStrip: boolean; + addButton: ReactNode; + pinnedTab: ReactNode; + children: ReactNode; +}) { + if (!props.notched) return props.children; return ( - target?.closest('input, textarea, select, [contenteditable]:not([contenteditable="false"])') != - null + + {props.pinnedTab} + + {props.children} + + {props.addButton ? ( + + {props.addButton} + + ) : null} + ); } -function DisabledReasonTooltip(props: { reason: string; trigger: ReactElement }) { +function PanelActionsNotch(props: { notched: boolean; hasActions: boolean; children: ReactNode }) { + const isMobile = useMediaQuery("max-md"); + if (!props.notched) return props.children; + if (!props.hasActions) return null; + if (isMobile) { + return ( + + } + > + + + +
+ {props.children} +
+
+
+ ); + } return ( - - - {props.reason} - - ); -} - -function SurfaceMenuItem(props: { - available: boolean; - disabledReason?: string; - shortcut: string; - onClick: () => void; - children: ReactNode; -}) { - const item = ( - + {props.children} - {props.shortcut} - + ); - if (props.available || !props.disabledReason) return item; - return ; } +/** + * Pane-wide letter shortcuts must yield to typing contexts. + * The focused launcher also supports arrows and explains unavailable surfaces. + */ function RightPanelEmptyState(props: { + onClose: (() => void) | undefined; onAddBrowser: () => void; onAddBrowserInProfile: (profileId: string) => void; browserProfiles: ReadonlyArray<{ readonly id: string; readonly name: string }>; @@ -327,6 +318,7 @@ function RightPanelEmptyState(props: { /** Absent where side chats are not offered. */ onAddSideChat?: (() => void) | undefined; browserAvailable: boolean; + browserUnavailableReason: string | null | undefined; terminalAvailable: boolean; diffAvailable: boolean; filesAvailable: boolean; @@ -336,498 +328,85 @@ function RightPanelEmptyState(props: { sideChatAvailable: boolean; sideChatDisabledReason: string | null; }) { - const [highlight, setHighlight] = useState(-1); - - const actions = [ + const actions: SurfaceLauncherAction[] = [ { - label: "Browser", - icon: Globe2, + kind: "browser", shortcut: "B", available: props.browserAvailable, - disabledReason: SURFACE_UNAVAILABLE_HINTS.browser, + ...(props.browserUnavailableReason ? { disabledReason: props.browserUnavailableReason } : {}), onClick: props.onAddBrowser, }, { - label: "Terminal", - icon: TerminalSquare, + kind: "terminal", shortcut: "T", available: props.terminalAvailable, - disabledReason: SURFACE_UNAVAILABLE_HINTS.terminal, onClick: props.onAddTerminal, }, + { kind: "files", shortcut: "F", available: props.filesAvailable, onClick: props.onAddFiles }, + { kind: "diff", shortcut: "D", available: props.diffAvailable, onClick: props.onAddDiff }, { - label: "Files", - icon: Files, - shortcut: "F", - available: props.filesAvailable, - disabledReason: SURFACE_UNAVAILABLE_HINTS.files, - onClick: props.onAddFiles, - }, - { - label: "Diff", - icon: FileDiff, - shortcut: "D", - available: props.diffAvailable, - disabledReason: SURFACE_UNAVAILABLE_HINTS.diff, - onClick: props.onAddDiff, - }, - { - label: "Pull request", - icon: PullRequestGlyph.pullRequest, + kind: "pull-request", shortcut: "P", available: props.pullRequestAvailable, - disabledReason: SURFACE_UNAVAILABLE_HINTS.pullRequest, onClick: props.onAddPullRequest, }, { - label: "Linked pull requests", - icon: PullRequestGlyph.link, + kind: "pull-requests", shortcut: "L", available: props.pullRequestsAvailable, - disabledReason: SURFACE_UNAVAILABLE_HINTS.pullRequests, onClick: props.onAddPullRequests, }, - { - label: "Device", - description: "Watch an iOS Simulator or Android Emulator.", - icon: Smartphone, - shortcut: "M", - available: props.deviceAvailable, - disabledReason: SURFACE_UNAVAILABLE_HINTS.device, - onClick: props.onAddDevice, - }, + { kind: "device", shortcut: "M", available: props.deviceAvailable, onClick: props.onAddDevice }, ...(props.onAddSideChat ? [ { - label: "Side chat", - description: "Ask a side question while this thread keeps working.", - icon: MessagesSquare, + kind: "side-chat" as const, shortcut: "S", available: props.sideChatAvailable && !props.sideChatDisabledReason, - disabledReason: props.sideChatDisabledReason ?? SURFACE_UNAVAILABLE_HINTS.sideChat, onClick: props.onAddSideChat, }, ] : []), - ] as const; - - type SurfaceAction = (typeof actions)[number]; - - const availableActions = actions.filter((action) => action.available); - const highlightIndex = - availableActions.length === 0 ? -1 : Math.min(highlight, availableActions.length - 1); - - const handleShortcut = useEffectEvent((event: KeyboardEvent) => { - const action = surfaceShortcutActionForKey(availableActions, event); - if (!action) return; - if (document.querySelector(LAUNCHER_SHORTCUT_BLOCKING_LAYERS)) return; - const target = event.composedPath()[0] ?? event.target; - if (target instanceof Element && surfaceShortcutTargetsTypingContext(target)) return; - event.preventDefault(); - event.stopPropagation(); - action.onClick(); + ]; + const paneFocusedRef = useLayoutPaneFocusedRef(); + const [launcherRef, attachLauncher] = useLauncherElement(paneFocusedRef, false); + useSurfaceLauncherKeys({ + launcherRef, + activeRef: paneFocusedRef, + actions, + onClose: props.onClose, }); - // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- subscribes a capture-phase window keydown listener for surface shortcuts while the menu is mounted - useEffect(() => { - const handler = (event: KeyboardEvent) => handleShortcut(event); - window.addEventListener("keydown", handler, true); - return () => window.removeEventListener("keydown", handler, true); - }, []); - - const handleKeyDown = (event: ReactKeyboardEvent) => { - if (event.defaultPrevented || event.metaKey || event.ctrlKey || event.altKey) return; - if (availableActions.length === 0) return; - if (event.key === "ArrowDown" || event.key === "ArrowRight") { - event.preventDefault(); - setHighlight((highlightIndex + 1) % availableActions.length); - return; - } - if (event.key === "ArrowUp" || event.key === "ArrowLeft") { - event.preventDefault(); - setHighlight( - highlightIndex === -1 - ? availableActions.length - 1 - : (highlightIndex - 1 + availableActions.length) % availableActions.length, - ); - return; - } - if (event.key === "Enter") { - if (event.target !== event.currentTarget) return; - const action = availableActions[highlightIndex]; - if (!action) return; - event.preventDefault(); - action.onClick(); - } - }; - - const isHighlighted = (action: SurfaceAction) => - highlightIndex !== -1 && availableActions[highlightIndex] === action; - - const actionIcon = (action: SurfaceAction, iconClassName = "size-4") => { - const Icon = action.icon; - return ( - - - - ); - }; return (
action.shortcut).join("")} + data-surface-launcher-keys={actions + .filter((action) => action.available) + .map((action) => action.shortcut) + .join("")} className={cn( - "scrollbar-gutter-both flex min-h-0 flex-1 items-center justify-center overflow-y-auto px-6 outline-none", + "scrollbar-gutter-both flex min-h-0 flex-1 items-center-safe justify-center overflow-y-auto px-4 outline-none", "pb-(--workspace-topbar-height)", )} > -
-

Open a surface

-
- {actions.map((action) => - action.available ? ( -
setHighlight(availableActions.indexOf(action))} - onMouseLeave={() => - setHighlight((current) => - current === availableActions.indexOf(action) ? -1 : current, - ) - } - > - - {action.label === "Browser" && props.browserProfiles.length > 1 ? ( - - - } - > - - - - {props.browserProfiles.map((profile) => ( - props.onAddBrowserInProfile(profile.id)} - > - {profile.name} - - ))} - - - ) : null} -
- ) : ( - - {actionIcon(action, "size-4")} - {action.label} - {action.shortcut} -
- } - /> - ), - )} -
+
+
); } -function surfaceTitle( - surface: RightPanelSurface, - sessions: Readonly>, - terminalLabelsById: ReadonlyMap, -): string { - switch (surface.kind) { - case "diff": - return "Diff"; - case "files": - return "Files"; - case "file": - return surface.relativePath.slice( - Math.max(surface.relativePath.lastIndexOf("/"), surface.relativePath.lastIndexOf("\\")) + 1, - ); - case "terminal": - return ( - terminalLabelsById.get(surface.activeTerminalId) ?? - getTerminalLabel(surface.activeTerminalId) - ); - case "pull-request": - return `#${surface.number}`; - case "pull-requests": - return "Pull requests"; - case "device": - return surface.title ?? surface.target?.name ?? "Device"; - case "side-chat": - return "Side chat"; - case "preview": { - const snapshot = surface.resourceId ? sessions[surface.resourceId] : null; - if (!snapshot || snapshot.navStatus._tag === "Idle") return "Browser"; - if (snapshot.navStatus.title.trim().length > 0) return snapshot.navStatus.title; - try { - return new URL(snapshot.navStatus.url).host || "Browser"; - } catch { - return "Browser"; - } - } - } -} - -function PreviewFavicon({ capturedUrl, url }: { capturedUrl: string | null; url: string | null }) { - const publicProviderUrl = faviconUrlForOrigin(url, 32); - return ( - } - className="size-3 shrink-0 rounded-sm object-contain" - /> - ); -} - -function sameOrigin(left: string, right: string): boolean { - try { - return new URL(left).origin === new URL(right).origin; - } catch { - return false; - } -} - -function previewFaviconOf( - surface: RightPanelSurface, - sessions: Readonly>, - desktopByTabId: Readonly>, -): { url: string | null; capturedUrl: string | null } { - const snapshot = - surface.kind === "preview" && surface.resourceId ? sessions[surface.resourceId] : null; - const url = !snapshot || snapshot.navStatus._tag === "Idle" ? null : snapshot.navStatus.url; - const favicon = snapshot ? (desktopByTabId[snapshot.tabId]?.favicon ?? null) : null; - const capturedUrl = favicon && url && sameOrigin(favicon.pageUrl, url) ? favicon.dataUrl : null; - return { url, capturedUrl }; -} - -function SurfaceIcon({ - surface, - previewUrl, - previewCapturedUrl, - theme, - environmentId, - pullRequestStatusSeed, -}: { - surface: RightPanelSurface; - previewUrl: string | null; - previewCapturedUrl: string | null; - theme: "light" | "dark"; - environmentId: EnvironmentId | null; - pullRequestStatusSeed: PullRequestTabStatusSeed | undefined; -}) { - switch (surface.kind) { - case "preview": - return ; - case "diff": - return ( - - ); - case "files": - return ( - - ); - case "file": - return ( - - ); - case "terminal": - return ( - - ); - case "pull-request": - return ( - - ); - case "pull-requests": - return ( - - ); - case "side-chat": - return ; - case "device": - return surface.target?.platform === "ios" ? ( - - ) : surface.target?.platform === "android" ? ( - - ) : ( - - ); - } -} - -/** What a side chat tab's dot shows and its title's accessible name says. */ -function sideChatTabState(shell: EnvironmentThreadShell | null) { - if (shell === null) return null; - if (shell.hasPendingApprovals || shell.pendingSubagentRequest === "approval") { - return "needs approval"; - } - if (shell.hasPendingUserInput || shell.pendingSubagentRequest === "user_input") { - return "needs input"; - } - return threadRuntimeIsActive(shell.runtime) ? "running" : null; -} - -// Static dots: a side chat tab can stay open for a whole run. -function SideChatTabIcon(props: { environmentId: EnvironmentId | null; threadId: ThreadId }) { - const state = sideChatTabState( - useQuietThreadShell( - props.environmentId === null ? null : scopeThreadRef(props.environmentId, props.threadId), - ), - ); - return ( - <> - - {state === null ? null : ( - - )} - - ); -} - -export function resolvePullRequestTabLink( - threads: readonly Pick[], - environmentId: EnvironmentId | null, - host: string | null, - reference: { repository: string; number: number }, -) { - if (environmentId === null || host === null) return undefined; - // Keys are cached per object, so one target normalizes once instead of once per link. - const target = { host, repository: reference.repository, number: reference.number }; - let newest: EnvironmentThreadShell["pullRequests"][number] | undefined; - for (const thread of threads) { - if (thread.environmentId !== environmentId) continue; - for (const link of visibleThreadPullRequests(thread.pullRequests)) { - if (!threadPullRequestKeysEqual(link, target)) continue; - if ( - newest === undefined || - (link.snapshot?.syncedAt ?? "") > (newest.snapshot?.syncedAt ?? "") - ) - newest = link; - } - } - return newest; -} - -function PullRequestSurfaceIcon({ - surface, - environmentId, - seed, -}: { - surface: Extract; - environmentId: EnvironmentId | null; - seed: PullRequestTabStatusSeed | undefined; -}) { - const resolvedEnvironmentId = - (surface.environmentId as EnvironmentId | undefined) ?? environmentId; - const projects = useProjects(); - const threads = useThreadShells(); - const project = projects.find( - (entry) => entry.environmentId === resolvedEnvironmentId && entry.id === surface.projectId, - ); - const identity = project?.repositoryIdentity; - const host = - surface.host ?? - (identity?.provider - ? sourceControlClients.changeRequestHost(identity, identity.provider) - : null); - const configs = useServerConfigs(); - const capabilities = - resolvedEnvironmentId === null - ? undefined - : configs.get(resolvedEnvironmentId)?.environment.capabilities; - const linkedSnapshot = - capabilities !== undefined - ? (resolvePullRequestTabLink(threads, resolvedEnvironmentId, host, surface)?.snapshot ?? null) - : null; - const detail = useEnvironmentQuery( - resolvedEnvironmentId === null || capabilities === undefined || linkedSnapshot !== null - ? null - : pullRequestEnvironment.detail({ - environmentId: resolvedEnvironmentId, - input: { - projectId: surface.projectId as ProjectId, - ...(capabilities !== undefined && surface.host !== undefined - ? { host: surface.host } - : {}), - repository: surface.repository, - number: surface.number, - allowStale: false, - }, - }), - ).data; - const reference = { - projectId: surface.projectId as ProjectId, - repository: surface.repository, - number: surface.number, - }; - const sharedSummary = useSharedPullRequestSummary(resolvedEnvironmentId, reference); - const status = linkedSnapshot ?? newestPullRequestSummary(detail, sharedSummary) ?? seed ?? null; - if (status === null) { - return ; - } - const presentation = resolvePullRequestState({ - state: status.state, - isDraft: status.isDraft ?? detail?.isDraft ?? seed?.isDraft ?? false, - }); - return ; -} - function SortableTabList(props: { surfaces: readonly RightPanelSurface[]; onMove: ((surfaceId: string, toIndex: number) => void) | undefined; @@ -864,6 +443,11 @@ interface RightPanelTabHandlers { onFinishRename: (surfaceId: string, title: string) => void; } +const NO_LAYOUT_TAB: LayoutTab = { + id: "panel", + content: { kind: "launcher", id: "panel", context: null }, +}; + // Chromium on Linux only suppresses primary-selection paste when mouseup is cancelled. function preventTabMiddleButtonDefault(event: ReactMouseEvent) { if (event.button !== 1) return; @@ -888,12 +472,29 @@ function RightPanelTab( renaming: boolean; canRename: boolean; ownsDesktopTitleBar: boolean; + notched: boolean; + layoutTabId: string | null; + threadTitle: string | null; }, ) { const { + dragDisabled, + previewUrl, + previewCapturedUrl, + theme, + environmentId, + pullRequestStatusSeed, + renaming, + canRename, + threadTitle, surface, audio, audioRuntimeTabId, + active, + pending, + notched, + layoutTabId, + ownsDesktopTitleBar, onActivate, onClose, onAuxClick, @@ -903,143 +504,220 @@ function RightPanelTab( } = props; const { listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: surface.id, - disabled: props.dragDisabled, + disabled: dragDisabled, }); const sideChatShell = useQuietThreadShell( surface.kind === "side-chat" && props.environmentId !== null ? scopeThreadRef(props.environmentId, surface.threadId) : null, ); - const title = sideChatShell?.title ?? props.title; - const sideChatState = sideChatTabState(sideChatShell); - const content = ( - <> - onClose(surface)}> - - {props.pending ? ( - - ) : null} - - {audio === "none" || !audioRuntimeTabId ? null : ( - - { - event.stopPropagation(); - void previewBridge - ?.setAudioMuted(audioRuntimeTabId, audio !== "muted") - .catch(() => undefined); - }} - > - {audio === "muted" ? ( - - ) : ( - - )} - - } - /> - {audio === "muted" ? "Unmute tab" : "Mute tab"} - - )} - {props.renaming ? ( - { - element?.focus(); - element?.select(); - }} - onBlur={(event) => onFinishRename(surface.id, event.currentTarget.value)} - onKeyDown={(event) => { - event.stopPropagation(); - if (event.nativeEvent.isComposing || event.keyCode === 229) return; - if (event.key === "Enter") event.currentTarget.blur(); - if (event.key === "Escape") { - event.currentTarget.value = title; - event.currentTarget.blur(); - } - }} - /> - ) : ( - - { - if (surface.kind === "device" && props.canRename) onStartRename(surface.id); - }} - className="cursor-pointer flex min-w-0 items-center" - aria-label={sideChatState === null ? undefined : `${title}, ${sideChatState}`} - onClick={() => onActivate(surface)} - > - {title} - - } - /> - - {surface.kind === "device" ? ( - - ) : ( - title - )} - - - )} - + const layoutTab = useLayoutStore((state) => + layoutTabId === null + ? null + : (findLayoutTab(state.layout, (tab) => tab.id === layoutTabId)?.tab ?? null), ); + const layoutLabel = useLayoutTabLabel(layoutTab ?? NO_LAYOUT_TAB); + const title = layoutTab ? layoutLabel.title : (sideChatShell?.title ?? props.title); + const tooltip = layoutTab + ? layoutLabel.tooltip + : [title, threadTitle, previewUrl, surface.kind === "file" ? surface.relativePath : null] + .filter(Boolean) + .join("\n"); return (
beginLayoutTabDrag(event, layoutTabId) : undefined + } onMouseDown={preventTabMiddleButtonDefault} onMouseUp={preventTabMiddleButtonDefault} onAuxClick={(event) => onAuxClick(event, surface)} onContextMenu={(event) => onContextMenu(event, surface)} {...listeners} - ref={setNodeRef} - style={{ transform: CSS.Translate.toString(transform), transition }} className={cn( - "cursor-pointer group/tab flex h-6 max-w-36 shrink-0 items-center gap-0.5 rounded-item pr-2 pl-1.5 text-xs", - props.ownsDesktopTitleBar && "[-webkit-app-region:no-drag]", - props.active - ? "font-medium text-strong-foreground" - : "text-muted-foreground hover:surface-ghost-hover hover:text-foreground", + "cursor-pointer group/tab flex shrink-0 items-center", + notched && "max-md:min-w-0 max-md:shrink", isDragging && "relative z-10", + // In the notch a tab is the chat's 28px notch tab (pen JiaZk). + notched + ? "h-7 gap-1.75 rounded-control px-2.5 text-label" + : "h-6 gap-0.5 rounded-item pr-2 pl-1.5 text-xs", + // The active tab title caps below. + !active && !notched && "max-w-36", + ownsDesktopTitleBar && "[-webkit-app-region:no-drag]", + active + ? cn("text-strong-foreground", !notched && "font-medium") + : "text-muted-foreground hover:surface-ghost-hover hover:text-foreground", )} > - {content} + <> + onClose(surface)} + showOnPhone={notched} + > + {surface.kind === "terminal" && layoutTab ? ( + layoutLabel.icon + ) : ( + + )} + {pending ? ( + + ) : null} + + {audio === "none" || !audioRuntimeTabId ? null : ( + + { + event.stopPropagation(); + void previewBridge + ?.setAudioMuted(audioRuntimeTabId, audio !== "muted") + .catch(() => undefined); + }} + > + {audio === "muted" ? ( + + ) : ( + + )} + + } + /> + {audio === "muted" ? "Unmute tab" : "Mute tab"} + + )} + {renaming ? ( + { + element?.focus(); + element?.select(); + }} + onBlur={(event) => onFinishRename(surface.id, event.currentTarget.value)} + onKeyDown={(event) => { + event.stopPropagation(); + if (event.nativeEvent.isComposing || event.keyCode === 229) return; + if (event.key === "Enter") event.currentTarget.blur(); + if (event.key === "Escape") { + event.currentTarget.value = title; + event.currentTarget.blur(); + } + }} + /> + ) : ( + + { + if (surface.kind === "device" && canRename) onStartRename(surface.id); + }} + className={cn( + "cursor-pointer flex min-w-0 items-center outline-none focus-visible:inset-ring-2 focus-visible:inset-ring-ring", + active && surface.kind === "diff" && "shrink-0", + )} + aria-label={ + surface.kind === "terminal" && threadTitle + ? `${title}, ${threadTitle}` + : undefined + } + onClick={() => onActivate(surface)} + > + + {title} + + + } + /> + + {surface.kind === "device" ? ( + <> + {threadTitle ? ( + <> + {threadTitle} +
+ + ) : null} + + + ) : ( + tooltip + )} +
+
+ )} +
); } +const PANE_BODY_SURFACE_CLASS: Partial> = { + "pull-request": "bg-(--code-surface)", + "pull-requests": "bg-transparent", + file: "bg-(--code-surface)", + terminal: "bg-(--terminal-background)", +}; + export function RightPanelTabs(props: RightPanelTabsProps) { const focusedSurface = props.surfaces.find((surface) => surface.id === props.activeSurfaceId); const ownsDesktopTitleBar = isElectron && props.mode === "inline"; const browserProfiles = useBrowserDefaults().profiles; const { resolvedTheme } = useTheme(); const tabListRef = useRef(null); - const addSurfaceTriggerRef = useRef(null); + // Embedded, this is one surface's tab in the layout: it drags like any tab + // and its pane's other tabs sit around it in the notch. + const { tabId: layoutPaneTabId, leadingEdge } = useLayoutPane(); + const layoutTabId = props.mode === "embedded" ? layoutPaneTabId : null; + const siblingTabs = useLayoutPaneSiblingTabs(); + const isMobile = useMediaQuery("max-md"); + const pinnedChatTab = + isMobile && layoutTabId !== null + ? [...siblingTabs.before, ...siblingTabs.after].find((tab) => tab.content.kind === "chat") + : undefined; const [renamingDevice, setRenamingDevice] = useState(null); + const renameRequested = useLayoutTabRenameRequested(layoutTabId); + const renamingDeviceId = renamingDevice ?? (renameRequested ? props.activeSurfaceId : null); + const [mobileBreadcrumbSlot, setMobileBreadcrumbSlot] = useState(null); + const [actionsSlot, setActionsSlot] = useState(null); + const { hasActions, registerActions } = usePanelTabActionsHost(); + const mobileDiff = isMobile && props.mode === "embedded" && focusedSurface?.kind === "diff"; + // A layout pane's tabs sit in its notch (pen JiaZk), like a chat's. + const notched = props.mode === "embedded" || props.pane === true; + const tabSlots = { + breadcrumb: mobileDiff ? mobileBreadcrumbSlot : null, + actions: actionsSlot, + registerActions, + }; const [addSurfaceMenuOpen, setAddSurfaceMenuOpen] = useState(false); const [tabScrollState, setTabScrollState] = useState({ hasOverflow: false, @@ -1049,29 +727,6 @@ export function RightPanelTabs(props: RightPanelTabsProps) { if (props.open === false && addSurfaceMenuOpen) setAddSurfaceMenuOpen(false); - const onNewSurfaceKeyDown = useEffectEvent((event: KeyboardEvent) => { - if (event.defaultPrevented || event.isComposing) return; - if ( - resolveShortcutCommand(event, props.keybindings, { - context: { ...props.getShortcutContext(), rightPanelOpen: true }, - }) !== "rightPanel.new" - ) - return; - if (!addSurfaceMenuOpen && document.querySelector(LAUNCHER_SHORTCUT_BLOCKING_LAYERS)) return; - event.preventDefault(); - event.stopPropagation(); - if (!event.repeat) { - addSurfaceTriggerRef.current?.focus(); - setAddSurfaceMenuOpen(true); - } - }); - // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- subscribes a document keydown listener for the new surface shortcut while the panel is open - useEffect(() => { - if (props.open === false) return; - document.addEventListener("keydown", onNewSurfaceKeyDown, true); - return () => document.removeEventListener("keydown", onNewSurfaceKeyDown, true); - }, [props.open]); - const updateTabScrollState = (viewport: HTMLDivElement) => { const hasOverflow = viewport.scrollWidth - viewport.clientWidth > TAB_SCROLL_EDGE_TOLERANCE; const canScrollLeft = hasOverflow && viewport.scrollLeft > TAB_SCROLL_EDGE_TOLERANCE; @@ -1100,76 +755,55 @@ export function RightPanelTabs(props: RightPanelTabsProps) { }); }; - const addSurfaceActions = [ - { - label: "Browser", - icon: Globe2, - shortcut: "B", - available: props.browserAvailable, - disabledReason: SURFACE_DISABLED_REASONS.browser, - onClick: props.onAddBrowser, - }, - { - label: "Terminal", - icon: TerminalSquare, - shortcut: "T", - available: props.terminalAvailable, - disabledReason: SURFACE_DISABLED_REASONS.terminal, - onClick: props.onAddTerminal, - }, - { - label: "Files", - icon: Files, - shortcut: "F", - available: props.filesAvailable, - disabledReason: SURFACE_DISABLED_REASONS.files, - onClick: props.onAddFiles, - }, - { - label: "Diff", - icon: FileDiff, - shortcut: "D", - available: props.diffAvailable, - disabledReason: SURFACE_DISABLED_REASONS.diff, - onClick: props.onAddDiff, - }, - { - label: "Pull request", - icon: PullRequestGlyph.pullRequest, - shortcut: "P", - available: props.pullRequestAvailable, - disabledReason: SURFACE_DISABLED_REASONS.pullRequest, - onClick: props.onAddPullRequest, - }, - { - label: "Linked pull requests", - icon: PullRequestGlyph.link, - shortcut: "L", - available: props.pullRequestsAvailable, - disabledReason: SURFACE_DISABLED_REASONS.pullRequests, - onClick: props.onAddPullRequests, - }, - { - label: "Device", - icon: Smartphone, - shortcut: "M", - available: props.deviceAvailable, - disabledReason: SURFACE_DISABLED_REASONS.device, - onClick: props.onAddDevice, + const addSurfaceActions = surfaceMenuActions({ + onNewThread: props.onNewThread, + surfaces: { + browser: { + available: props.browserAvailable, + disabledReason: props.browserUnavailableReason ?? SURFACE_DISABLED_REASONS.browser, + onClick: props.onAddBrowser, + }, + terminal: { + available: props.terminalAvailable, + disabledReason: SURFACE_DISABLED_REASONS.terminal, + onClick: props.onAddTerminal, + }, + files: { + available: props.filesAvailable, + disabledReason: SURFACE_DISABLED_REASONS.files, + onClick: props.onAddFiles, + }, + diff: { + available: props.diffAvailable, + disabledReason: SURFACE_DISABLED_REASONS.diff, + onClick: props.onAddDiff, + }, + "pull-request": { + available: props.pullRequestAvailable, + disabledReason: SURFACE_DISABLED_REASONS.pullRequest, + onClick: props.onAddPullRequest, + }, + "pull-requests": { + available: props.pullRequestsAvailable, + disabledReason: SURFACE_DISABLED_REASONS.pullRequests, + onClick: props.onAddPullRequests, + }, + device: { + available: props.deviceAvailable, + disabledReason: SURFACE_DISABLED_REASONS.device, + onClick: props.onAddDevice, + }, + ...(props.onAddSideChat + ? { + "side-chat": { + available: props.sideChatAvailable === true && !props.sideChatDisabledReason, + disabledReason: props.sideChatDisabledReason ?? SURFACE_DISABLED_REASONS.sideChat, + onClick: props.onAddSideChat, + }, + } + : {}), }, - ...(props.onAddSideChat - ? [ - { - label: "Side chat", - icon: MessagesSquare, - shortcut: "S", - available: props.sideChatAvailable === true && !props.sideChatDisabledReason, - disabledReason: props.sideChatDisabledReason ?? SURFACE_DISABLED_REASONS.sideChat, - onClick: props.onAddSideChat, - }, - ] - : []), - ] as const; + }); const handleAddSurfaceMenuKeyDown = (event: ReactKeyboardEvent) => { const action = surfaceShortcutActionForKey(addSurfaceActions, event.nativeEvent); @@ -1184,6 +818,16 @@ export function RightPanelTabs(props: RightPanelTabsProps) { event.preventDefault(); event.stopPropagation(); + if (layoutTabId !== null) { + void showLayoutTabMenu( + layoutTabId, + { x: event.clientX, y: event.clientY }, + props.keybindings, + () => setRenamingDevice(surface.id), + ); + return; + } + const api = readLocalApi(); if (!api) return; @@ -1281,22 +925,25 @@ export function RightPanelTabs(props: RightPanelTabsProps) { const onFinishRenameTab = useStableCallback((surfaceId: string, title: string) => { props.onRenameDevice?.(surfaceId, title); setRenamingDevice(null); + finishLayoutTabRename(layoutTabId); }); - // A newly active tab, or tabs that start overflowing, can sit outside the scrolled strip. - // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- scrolls the active tab DOM node into view after it changes or the tab strip starts overflowing - useEffect(() => { - if (!props.activeSurfaceId || !tabScrollState.hasOverflow) return; - const activeTab = tabListRef.current?.querySelector("[data-active-tab='true']"); - activeTab?.scrollIntoView({ block: "nearest", inline: "nearest" }); - }, [props.activeSurfaceId, tabScrollState.hasOverflow]); + useRevealActiveTab(tabListRef, layoutTabId === null ? props.activeSurfaceId : null); const attachTabList = (root: HTMLDivElement | null) => { tabListRef.current = root; const viewport = tabScrollViewport(root); if (!viewport) return; - const update = () => updateTabScrollState(viewport); + const row = root?.closest("[data-right-panel-tabbar]"); + const update = () => { + updateTabScrollState(viewport); + const overflow = viewport.scrollWidth > viewport.clientWidth; + if (row && row.hasAttribute("data-tab-row-overflow") !== overflow) { + row.toggleAttribute("data-tab-row-overflow", overflow); + } + }; + const rowWheel = (event: WheelEvent) => scrollTabStripFromRow(event, viewport); // Only a vertical mouse wheel is translated. Horizontal input scrolls natively, since // consuming it keeps Chromium from starting the gesture scroll that swipe navigation tracks. const handleWheel = (event: WheelEvent) => { @@ -1316,6 +963,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) { const stopObserving = observeResize(content ? [viewport, content] : viewport, update); viewport.addEventListener("scroll", update, { passive: true }); viewport.addEventListener("wheel", handleWheel, { passive: false }); + row?.addEventListener("wheel", rowWheel, { passive: false }); update(); return () => { @@ -1323,9 +971,43 @@ export function RightPanelTabs(props: RightPanelTabsProps) { stopObserving(); viewport.removeEventListener("scroll", update); viewport.removeEventListener("wheel", handleWheel); + row?.removeEventListener("wheel", rowWheel); + row?.removeAttribute("data-tab-row-overflow"); }; }; + const addSurfaceMenu = + // A notch keeps it while empty too: an empty notch reads as a broken corner. + props.open !== false || props.surfaces.length > 0 || notched ? ( + + + } + > + + + + setAddSurfaceMenuOpen(false)} + /> + + + ) : null; + return ( @@ -1351,26 +1034,50 @@ export function RightPanelTabs(props: RightPanelTabsProps) { >
- + + + ) : null + } > -
+ {layoutTabId !== null + ? siblingTabs.before + .filter((tab) => tab.id !== pinnedChatTab?.id) + .map((tab) => ) + : null} + {props.surfaces.map((surface) => { const previewTabId = previewTabIdOf(surface, props.previewSessions); @@ -1383,7 +1090,12 @@ export function RightPanelTabs(props: RightPanelTabsProps) { - {props.open !== false ? ( - - - } - > - - - - {addSurfaceActions.map((action) => { - const Icon = action.icon; - if (action.label === "Browser" && action.available) { - return ( - - { - const pointerType = - "pointerType" in event.nativeEvent && - typeof event.nativeEvent.pointerType === "string" - ? event.nativeEvent.pointerType - : undefined; - if (!shouldOpenDefaultBrowserProfileFromMenuClick(pointerType)) - return; - setAddSurfaceMenuOpen(false); - action.onClick(); - }} - > - - {action.label} - {action.shortcut} - - - {browserProfiles.map((profile) => ( - props.onAddBrowserInProfile(profile.id)} - > - {profile.name} - - ))} - - - ); - } - return ( - - - {action.label} - - ); - })} - - - ) : null} -
-
- {tabScrollState.hasOverflow ? ( + {notched ? null : addSurfaceMenu} + + {layoutTabId !== null + ? siblingTabs.after + .filter((tab) => tab.id !== pinnedChatTab?.id) + .map((tab) => ) + : null} + + {/* In the notch the tabs scroll by wheel and ctrl+Tab; the actions need the room. */} + {!notched && tabScrollState.hasOverflow ? (
) : null} - {props.layoutControls} + + {mobileDiff ? ( + + ) : null} +
+ {props.layoutControls} + {ownsDesktopTitleBar && !props.layoutControls ? ( ) : null} @@ -1542,9 +1203,14 @@ export function RightPanelTabs(props: RightPanelTabsProps) { /> ) : null}
-
+
{props.activeSurfaceId === null ? ( ) : ( - props.children + {props.children} )}
diff --git a/apps/web/src/components/Sidebar.browser.test.tsx b/apps/web/src/components/Sidebar.browser.test.tsx index e8f6129224394..5b8687449a20c 100644 --- a/apps/web/src/components/Sidebar.browser.test.tsx +++ b/apps/web/src/components/Sidebar.browser.test.tsx @@ -52,6 +52,17 @@ vi.mock(import("../state/server"), async (original) => { ]); return { ...(await original()), primaryServerKeybindingsAtom: Atom.make(keybindings) }; }); +vi.mock(import("../connection/catalog"), async (original) => { + const actual = await original(); + const { Atom } = await import("effect/reactivity"); + return { + ...actual, + environmentCatalog: { + ...actual.environmentCatalog, + catalogValueAtom: Atom.make({ isReady: true, entries: new Map() }) as never, + }, + }; +}); vi.mock("../commandPaletteBus", () => ({ openCommandPalette: vi.fn(), isCommandPaletteOpen: () => false, @@ -76,6 +87,40 @@ function project(id: string): Project { } describe("sidebar header new thread", () => { + it("does not render the thread list when terminal focus changes without held modifiers", async () => { + vi.mocked(useProjects).mockReturnValue([]); + vi.mocked(useHandleNewThread).mockReturnValue({ + activeDraftThread: null, + activeThread: null, + defaultProjectRef: null, + defaultProjectFallbackReady: true, + handleNewThread: vi.fn().mockResolvedValue(null), + routeDraftId: null, + routeThreadRef: null, + }); + await using screen = await render( + + + +
+ +
+
, + ); + await act(() => + screen.container.querySelector('[aria-label="Outside"]')!.focus(), + ); + vi.mocked(useProjects).mockClear(); + + await act(() => + screen.container.querySelector('[aria-label="Terminal"]')!.focus(), + ); + await act(() => + screen.container.querySelector('[aria-label="Outside"]')!.focus(), + ); + + expect(useProjects).not.toHaveBeenCalled(); + }); it.each([ { mode: "normal", count: 2, shift: false, picker: false }, { mode: "boring", count: 2, shift: false, picker: false }, diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 92f7595b4ba2a..078bd0752b014 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -7,6 +7,7 @@ import { describe, expect, it } from "vite-plus/test"; import * as Cause from "effect/Cause"; import { AsyncResult } from "effect/reactivity"; import { + shouldCreateNewThreadInCurrentProject, deleteSelectedThreadEntries, getFallbackThreadIdAfterDelete, hasUnseenCompletion, @@ -56,6 +57,7 @@ import { makeThreadFixture, type ThreadFixtureOverrides } from "../test-fixtures import { completeSidebarRouteReveal, pendingSidebarRouteReveal, + observeSidebarDraftPromotion, resolveSidebarRouteReveal, } from "./sidebar/routeReveal"; @@ -1560,6 +1562,21 @@ describe("resolveSidebarRouteReveal", () => { ).toEqual({ pendingKey: null, widensScope: false }); }); + it("never reveals the thread an open draft became", () => { + const reveal = { observedKey: "environment-test:other", pendingKey: null }; + observeSidebarDraftPromotion(reveal, hiddenKey); + expect( + resolveSidebarRouteReveal({ + reveal, + routeKey: hiddenKey, + isSearching: false, + scopedProjectKeys, + delegatedUnderParent: new Set(), + findThread, + }), + ).toEqual({ pendingKey: null, widensScope: false }); + }); + it("widens again for a thread reopened before the thread between them listed its row", () => { // The user scoped away from the open thread, then went to a thread whose row // never mounted, then came back. @@ -1697,3 +1714,13 @@ describe("resolveSidebarFolderDot", () => { expect(resolveSidebarFolderDot([unread, limited])).toBe("attention"); }); }); + +describe("shouldCreateNewThreadInCurrentProject", () => { + it("opens the picker when several projects are open, or only archived ones exist", () => { + expect(shouldCreateNewThreadInCurrentProject(false, 2, 3)).toBe(false); + expect(shouldCreateNewThreadInCurrentProject(false, 0, 2)).toBe(false); + expect(shouldCreateNewThreadInCurrentProject(false, 1, 3)).toBe(true); + expect(shouldCreateNewThreadInCurrentProject(false, 0, 0)).toBe(true); + expect(shouldCreateNewThreadInCurrentProject(true, 2, 2)).toBe(true); + }); +}); diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 07a2b7075c8f8..bc96678af90ee 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -831,6 +831,15 @@ export function isSidebarNestedLinkClick(target: EventTarget | null): boolean { return nodeClosest(parent, "a[href]") !== null; } +/** The "New thread in" picker opens unless there is one open project or none; then the current context decides. */ +export function shouldCreateNewThreadInCurrentProject( + shiftKey: boolean, + openProjectGroupCount: number, + projectGroupCount: number, +): boolean { + return shiftKey || openProjectGroupCount === 1 || projectGroupCount === 0; +} + export function orderItemsByPreferredIds(input: { items: readonly TItem[]; preferredIds: readonly TId[]; diff --git a/apps/web/src/components/Sidebar.motion.ts b/apps/web/src/components/Sidebar.motion.ts index 0e86c3c9a0e94..5a5fd8df3dbe8 100644 --- a/apps/web/src/components/Sidebar.motion.ts +++ b/apps/web/src/components/Sidebar.motion.ts @@ -1,5 +1,8 @@ const motionTiming = { duration: 150, easing: "ease-out" }; const rowTravel = (height: number) => Math.min(height, 40); +const EXIT_FADE_END = 0.4; +const ENTRY_FADE_START = 0.6; +const MAX_SLIDE_ROWS = 1.5; // Fades clone removed rows and animate clones and entries; intervening layout reads force reflows. const MAX_FADED_ROWS_PER_UPDATE = 40; @@ -11,6 +14,33 @@ function progress(animation: Animation) { : (animation.effect?.getComputedTiming().progress ?? 0); } +function entryOpacity(animation: Animation) { + return Math.max(0, (progress(animation) - ENTRY_FADE_START) / (1 - ENTRY_FADE_START)); +} + +function crossingRows(order: readonly HTMLElement[], previousIndex: Map) { + const retained = order.filter((node) => previousIndex.has(node)); + const tails: number[] = []; + const previous: number[] = []; + for (const [index, node] of retained.entries()) { + const value = previousIndex.get(node)!; + let low = 0; + let high = tails.length; + while (low < high) { + const middle = (low + high) >> 1; + if (previousIndex.get(retained[tails[middle]!]!)! < value) low = middle + 1; + else high = middle; + } + previous[index] = low > 0 ? tails[low - 1]! : -1; + tails[low] = index; + } + const ordered = new Set(); + for (let index = tails.at(-1) ?? -1; index >= 0; index = previous[index]!) { + ordered.add(retained[index]!); + } + return new Set(retained.filter((node) => !ordered.has(node))); +} + /** Position the list so all direct children's offsetTop share an origin. */ export function createSidebarListMotion(parent: HTMLUListElement) { let positions: Map | null = null; @@ -69,10 +99,10 @@ export function createSidebarListMotion(parent: HTMLUListElement) { }); parent.append(clone); const entry = entering.get(node); - const entryProgress = entry ? progress(entry.animation) : 1; const animation = clone.animate( [ - { opacity: entryProgress, transform: "translateY(0px)" }, + { opacity: entry ? entryOpacity(entry.animation) : 1, transform: "translateY(0px)" }, + { opacity: 0, offset: EXIT_FADE_END }, { opacity: 0, transform: `translateY(${travel}px)` }, ], motionTiming, @@ -117,7 +147,12 @@ export function createSidebarListMotion(parent: HTMLUListElement) { ); }; - return { + let frame = 0; + let frameAnimates = false; + const view = parent.ownerDocument.defaultView; + + const api = { + /** Read every row's position and animate rows that moved. Reads layout. */ update(animate: boolean) { if (disposed) return; // A hidden list (the closed narrow sheet stays mounted) reports zero @@ -148,14 +183,28 @@ export function createSidebarListMotion(parent: HTMLUListElement) { if (!positions.has(node) && position.height > 0) fadeCount++; } } + const nextOrder = [...next.keys()]; + const oldOrder = positions === null ? [] : [...positions.keys()]; + const crossing = crossingRows(nextOrder, new Map(oldOrder.map((n, i) => [n, i] as const))); + for (const [node, position] of next) { + const previousTop = positions?.get(node)?.top; + if ( + previousTop !== undefined && + position.height > 0 && + Math.abs(previousTop + remainingOffset(node) - position.top) > + position.height * MAX_SLIDE_ROWS + ) { + crossing.add(node); + } + } + fadeCount += crossing.size * 2; const shouldAnimate = animate && positions !== null && !reducedMotion?.matches && fadeCount <= MAX_FADED_ROWS_PER_UPDATE; const movedDelta = new Map(); - const nextOrder = [...next.keys()]; - const oldOrder = positions === null ? [] : [...positions.keys()]; + const stays = (node: HTMLElement) => next.has(node) && !crossing.has(node); const ridingDelta = ( order: readonly HTMLElement[], index: number, @@ -173,12 +222,14 @@ export function createSidebarListMotion(parent: HTMLUListElement) { else { for (const [node, position] of next) { const previousTop = positions!.get(node)?.top; - if (previousTop === undefined || previousTop === position.top) continue; + if (previousTop === undefined || previousTop === position.top || crossing.has(node)) { + continue; + } movedDelta.set(node, previousTop + remainingOffset(node) - position.top); } for (const [node, position] of positions!) { - if (next.has(node)) continue; - const delta = ridingDelta(oldOrder, oldOrder.indexOf(node), (n) => next.has(n)); + if (stays(node)) continue; + const delta = ridingDelta(oldOrder, oldOrder.indexOf(node), stays); fadeOut(node, position, delta === undefined ? rowTravel(position.height) : -delta); } } @@ -195,13 +246,21 @@ export function createSidebarListMotion(parent: HTMLUListElement) { for (const [index, node] of nextOrder.entries()) { const position = next.get(node)!; const previousTop = positions!.get(node)?.top; - if (previousTop === undefined) { + if (previousTop === undefined || crossing.has(node)) { + cancel(node); + entering.get(node)?.animation.cancel(); + entering.delete(node); if (position.height > 0) { - const delta = ridingDelta(nextOrder, index, (n) => positions!.has(n)); + const delta = ridingDelta( + nextOrder, + index, + (n) => positions!.has(n) && !crossing.has(n), + ); const travel = delta === undefined ? -rowTravel(position.height) : delta; const animation = node.animate( [ { opacity: 0, transform: `translateY(${travel}px)` }, + { opacity: 0, offset: ENTRY_FADE_START }, { opacity: 1, transform: "translateY(0px)" }, ], motionTiming, @@ -234,6 +293,19 @@ export function createSidebarListMotion(parent: HTMLUListElement) { } positions = next; }, + /** `update` in the next frame, before it paints, so a commit's own layout + * pass is the one read instead of a forced one mid-commit. A chat + * mounting in the same commit made that forced layout expensive. Calls + * before the frame coalesce; the last one decides whether rows animate. */ + scheduleUpdate(animate: boolean) { + if (disposed || !view) return; + frameAnimates = animate; + if (frame !== 0) return; + frame = view.requestAnimationFrame(() => { + frame = 0; + api.update(frameAnimates); + }); + }, release() { suspend(); const origin = parent.getBoundingClientRect().top; @@ -246,7 +318,9 @@ export function createSidebarListMotion(parent: HTMLUListElement) { suspend, dispose() { suspend(); + if (frame !== 0) view?.cancelAnimationFrame(frame); disposed = true; }, }; + return api; } diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 8358f6de33274..ef2067ab7629f 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1,3 +1,4 @@ +import { ThreadContextDragGhost } from "./chat/ThreadContextDragGhost"; import { attachSidebarTree } from "../lib/sidebarTree"; import { type EnvironmentId, type ScopedProjectRef } from "@t3tools/contracts"; import type { EnvironmentServerConfig } from "@t3tools/client-runtime/connection"; @@ -6,7 +7,20 @@ import { endThreadContextDrag, moveThreadContextDrag as moveThreadContextDragGhost, } from "./chat/threadContextDrag"; -import { appearanceFontStack } from "../appearanceFonts"; +import { selectFocusedTab, selectLayoutThreadKeys } from "../layout/layoutModel"; +import { followLayoutCompanions } from "../layout/openRules"; +import { isThreadlessSurfacesRef } from "../threadlessSurfaces"; +import { useLayoutStore } from "../layout/layoutStore"; +import { + cancelLayoutDrag, + dropLayoutDrag, + moveLayoutDrag, + OPEN_THREAD_TO_THE_SIDE_MENU_ID, + openThreadToTheSide, + withOpenThreadToTheSideItem, +} from "../layout/layoutDrag"; +import { appearanceFontStack, DEFAULT_SANS_FONT_STACK } from "../appearanceFonts"; +import { wasThreadSettledByUser } from "../layout/settledByUser"; import { discardComposerDraft } from "../lib/discardComposerDraft"; import { requestCustomSnooze } from "./CustomSnoozeDialog"; import { useAtomValue } from "@effect/atom-react"; @@ -75,6 +89,7 @@ import { type MouseEvent as ReactMouseEvent, type ReactNode, } from "react"; +import { flushSync } from "react-dom"; import { type AnyRouter, useRouter } from "@tanstack/react-router"; import { @@ -221,6 +236,7 @@ import { useStableCallback } from "../hooks/useStableCallback"; import { resolveSidebarRouteReveal, type SidebarRouteReveal, + observeSidebarDraftPromotion, useActiveRowReveal, } from "./sidebar/routeReveal"; import { stackedThreadToast, toastManager } from "./ui/toast"; @@ -252,6 +268,7 @@ import { import { useShownThreadRouteTarget } from "../hooks/useShownThreadRouteTarget"; import { SidebarSearchResultRow, + SidebarThreadRow, SortableThreadRow, type SidebarProviderEntry, type SidebarSweepAction, @@ -346,6 +363,7 @@ interface SidebarThreadPartitionInput { readonly serverConfigs: ReadonlyMap; readonly connectedEnvironmentIds: readonly EnvironmentId[]; readonly optimisticDrop: SidebarOptimisticDrop | null; + readonly layoutThreadKeys: ReadonlySet; readonly workingShelfEnabled: boolean; /** Boring mode: folders list active threads by the user's latest message. */ readonly sortActiveBySend: boolean; @@ -418,6 +436,7 @@ function useSidebarThreads(input: { readonly folderGroups: readonly SidebarProjectSnapshot[] | null; // Keeps folders with no active threads, so a project the user filtered to or pinned can start one. readonly keepEmptyFolders: (group: SidebarProjectSnapshot) => boolean; + readonly layoutThreadKeys: ReadonlySet; }) { const { projectScopeKey, @@ -430,6 +449,7 @@ function useSidebarThreads(input: { routeRowThreadKey, folderGroups, keepEmptyFolders: keepEmpty, + layoutThreadKeys, } = input; // Folders already separate the work, so boring mode has no Working shelf. const workingShelfEnabled = @@ -476,6 +496,7 @@ function useSidebarThreads(input: { serverConfigs, connectedEnvironmentIds, optimisticDrop, + layoutThreadKeys, workingShelfEnabled, sortActiveBySend: folderGroups !== null, clockKey: `${nowMinute}:${snoozeWakeClock}:${inboxReturnsVersion}`, @@ -632,9 +653,18 @@ function partitionSidebarThreads(input: SidebarThreadPartitionInput) { serverConfigs, connectedEnvironmentIds: input.connectedEnvironmentIds, }); + const { layoutThreadKeys } = input; + for (const key of settledWhenOpened.keys()) { + if (!layoutThreadKeys.has(key)) settledWhenOpened.delete(key); + } for (const thread of visible) { const configLoaded = serverConfigs.has(thread.environmentId); const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); + // A thread seen active while open stays active after automatic settlement. + if (layoutThreadKeys.has(threadKey)) { + if (thread.settledOverride !== "settled") settledWhenOpened.set(threadKey, false); + else if (!settledWhenOpened.has(threadKey)) settledWhenOpened.set(threadKey, true); + } if (optimisticDrop?.key === threadKey) { const projected = applySidebarThreadDrop( thread, @@ -655,7 +685,12 @@ function partitionSidebarThreads(input: SidebarThreadPartitionInput) { } else { const section = resolveSidebarThreadSection({ snoozed: configLoaded && effectiveSnoozed(thread, { now: preciseNow }), - settled: configLoaded && thread.settledOverride === "settled", + settled: + configLoaded && + thread.settledOverride === "settled" && + (!layoutThreadKeys.has(threadKey) || + settledWhenOpened.get(threadKey) !== false || + wasThreadSettledByUser(threadKey)), pinned: thread.pinnedAt != null, }); (section === "snoozed" @@ -933,6 +968,28 @@ function revealSidebarRow(viewport: HTMLElement, row: HTMLElement) { if (target !== scrollTop) viewport.scrollTop = Math.min(maxScroll, Math.max(0, target)); } +/** + * Whether each open thread was settled every time the sidebar saw it while its tab was open. Only + * one settled while open (a merge) stays in the active list; opening a settled thread to read it + * leaves it on the shelf. Module scope: the sidebar unmounts on Settings routes, and the answer + * must outlive that. + */ +const settledWhenOpened = new Map(); + +function revealSidebarShelf(header: HTMLElement) { + const viewport = header.closest('[data-slot="scroll-area-viewport"]'); + const lastRow = Array.from( + header + .closest("ul") + ?.querySelectorAll( + `[data-sidebar-parent="${CSS.escape(header.dataset.sidebarRow ?? "")}"]`, + ) ?? [], + ).at(-1); + if (!viewport || !lastRow) return; + revealSidebarRow(viewport, lastRow); + revealSidebarRow(viewport, header); +} + export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReveal }) { const { projects, @@ -1096,6 +1153,14 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve routeDraftRedirect, ); const routeThreadKey = routeThreadRef ? scopedThreadKey(routeThreadRef) : null; + // The URL stays on the last chat while a surface has focus; the current row is the surface's. + const focusedSurfaceThreadRef = useLayoutStore((state) => { + const content = selectFocusedTab(state.layout)?.content; + return content?.kind === "surface" && !isThreadlessSurfacesRef(content.threadRef) + ? content.threadRef + : null; + }); + const currentThreadRef = focusedSurfaceThreadRef ?? routeThreadRef; // A selection made under one scope can hold threads the next scope hides. // The viewport outlives the scope and does not anchor, so a new list starts at the top. @@ -1141,20 +1206,23 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve : thread, ); const routeRowThreadKey = - routeThreadRef === null + currentThreadRef === null ? null : scopedThreadKey( scopeThreadRef( - routeThreadRef.environmentId, + currentThreadRef.environmentId, resolveSidebarRowThreadId( - routeThreadRef.threadId, - routeThreadRef.environmentId, + currentThreadRef.threadId, + currentThreadRef.environmentId, threads, ), ), ); const isSearchingThreads = threadSearchQuery.trim().length > 0; + if (routeTarget?.kind === "draft") { + observeSidebarDraftPromotion(routeReveal, routeRowThreadKey); + } // History, the palette and links can open a thread the scope hides or the list // scrolled away; a remount starts at the top, so each mount scrolls to the open // thread once. Render decides the widening, so the first frame of the new route @@ -1316,8 +1384,8 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve const clicked = await settlePromise(() => api.contextMenu.show( [ - { id: "new-thread", label: "New thread" }, - { id: "copy-path", label: "Copy path" }, + { id: "new-thread", label: "New thread", icon: "square-pen" }, + { id: "copy-path", label: "Copy path", icon: "copy" }, ...(canPinProject(group) ? [ group.pinned @@ -1350,6 +1418,14 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve }, ); + // A signature avoids sidebar updates for pane focus and resize alone. + const onScreenThreadKeys = useLayoutStore((state) => state.onScreenThreadKeys); + const layoutThreadKeysSignature = useLayoutStore((state) => + selectLayoutThreadKeys(state.layout).join("\n"), + ); + const layoutThreadKeys = new Set( + layoutThreadKeysSignature === "" ? [] : layoutThreadKeysSignature.split("\n"), + ); const { workingShelfEnabled, pinnedThreads, @@ -1403,6 +1479,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve : null, keepEmptyFolders: (group) => scopedProjectGroups.length > 0 || (group.pinned && !group.archived), + layoutThreadKeys, }); // Ranked across environments as the palette ranks them, so one @@ -1503,6 +1580,10 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve if (isMobile) { setOpenMobile(false); } + // A tab already open in the layout takes focus even if its URL is current. + followLayoutCompanions(() => + useLayoutStore.getState().focusChat({ kind: "server", threadRef }), + ); return router.navigate({ to: "/$environmentId/$threadId", params: buildThreadRouteParams(threadRef), @@ -1635,6 +1716,21 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve if (isTrailingDoubleClick(event.detail)) { return; } + if (event.button === 1) { + // Middle click keeps the thread open as a tab; the URL follows the focused tab. + event.preventDefault(); + followLayoutCompanions(() => + useLayoutStore + .getState() + .openKeptTab({ kind: "chat", target: { kind: "server", threadRef } }), + ); + return; + } + if (event.altKey) { + event.preventDefault(); + openThreadToTheSide(threadRef); + return; + } navigateToThread(threadRef); }; @@ -1644,6 +1740,8 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve coParkingKeys?: ReadonlySet, ): (() => void) | null => { if (readRouteThreadKey(router) !== threadKey) return null; + // Closing a parked thread's layout tabs chooses the neighboring pane or launcher. + if (selectLayoutThreadKeys(useLayoutStore.getState().layout).includes(threadKey)) return null; const shell = threadByKeyRef.current.get(threadKey); const orderedKeys = orderedThreadKeysRef.current; const settledKeys = settledThreadKeysRef.current; @@ -1787,6 +1885,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve contextDragKeyRef.current = null; dragScrollRef.current = null; endThreadContextDrag(); + cancelLayoutDrag(); if (started) { listMotionRef.current?.release(); setDragState(null); @@ -1828,19 +1927,32 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve ); if (!contextDrag) { endThreadContextDrag(); + cancelLayoutDrag(); return false; } restoreDragScroll(); const threads = contextDragThreads(); const threadRef = parseScopedThreadKey(contextDragKeyRef.current ?? ""); const title = (threadRef && readThreadShell(threadRef)?.title.trim()) || "Thread"; - moveThreadContextDragGhost(point, { title, count: threads.length }); + const composer = moveThreadContextDragGhost(point, { + title, + count: threads.length, + width: threadListRef.current?.getBoundingClientRect().width ?? 0, + }); + // One thread can also land in the layout (pen jVRc4). A composer under + // the pointer keeps the drop as context. + const [thread] = threads; + if (threads.length === 1 && thread) { + moveLayoutDrag({ kind: "thread", threadRef: thread }, point, { composer }); + } else { + cancelLayoutDrag(); + } return true; }; const dropThreadContextDrag = (point: { x: number; y: number }) => { if (!pointerOutsideThreadList(point)) return false; restoreDragScroll(); - dropThreadContext(point, contextDragThreads()); + if (!dropThreadContext(point, contextDragThreads())) dropLayoutDrag(); return true; }; // useSensor memoizes options identity; fresh options rebuild DndContext and every sortable row. @@ -2075,7 +2187,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve // Shelf rows would travel over stationary headers below the bottom-anchored shelf. const shelvesUnchanged = previousShelfExpansionKeyRef.current === shelfExpansionKey; previousShelfExpansionKeyRef.current = shelfExpansionKey; - listMotionRef.current?.update(listMotionPass.animate && shelvesUnchanged); + listMotionRef.current?.scheduleUpdate(listMotionPass.animate && shelvesUnchanged); }, [listMotionPass, shelfExpansionKey]); const handleThreadDragOver = (event: DragOverEvent) => { const target = event.over @@ -2120,6 +2232,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Keep the active row visible as layout changes until the user scrolls. useEffect(() => { if (routeRowThreadKey === null || !routeRowRendered || isSearchingThreads) return; + if (routeReveal.quietKey === routeRowThreadKey) return; const list = threadListRef.current; const viewport = list?.closest('[data-slot="scroll-area-viewport"]'); if (!list || !viewport) return; @@ -2144,7 +2257,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve release(); for (const type of userInput) viewport.removeEventListener(type, release); }; - }, [isSearchingThreads, routeRowRendered, routeRowThreadKey]); + }, [isSearchingThreads, routeReveal, routeRowRendered, routeRowThreadKey]); const draggedThreadKey = dragState?.activeKey; const draggedFromSection = dragState?.activeSection; const dragActivationY = dragState?.activationY; @@ -2719,44 +2832,50 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve ) ?? null; const clicked = await settlePromise(() => api.contextMenu.show( - buildThreadActionMenuItems({ - canOperate: readEnvironmentScope( - threadRef.environmentId, - AuthOrchestrationOperateScope, - ), - branch: thread.branch ?? null, - // An archived project is not a filter choice. - projectFilter: - threadProjectGroup && !threadProjectGroup.archived - ? { - label: threadProjectGroup.displayName, - isActive: projectScopeKeys.includes(threadProjectGroup.projectKey), - } - : null, - isPinned, - isSettled, - autoSettleEnabled: thread.autoSettleDisabledAt == null, - isSnoozed, - canSnoozeNow: canSnooze(thread, { now: new Date().toISOString() }), - isRegeneratingTitle, - isRunning: !threadRuntimeCanArchive(thread.runtime), - canShareWithGuest: appAtomRegistry.get( - canShareWithGuestAtom(threadRef.environmentId), - ), - canTurnIntoProject: canTurnThreadIntoProject(threadRef), - supports: { - settlement: configLoaded, - autoSettleOptOut: supportsAutoSettleOptOut, - snooze: configLoaded, - pinning: configLoaded, - titleRegeneration: configLoaded, - }, - snoozePresets, - }), + withOpenThreadToTheSideItem( + threadRef, + buildThreadActionMenuItems({ + canOperate: readEnvironmentScope( + threadRef.environmentId, + AuthOrchestrationOperateScope, + ), + branch: thread.branch ?? null, + projectFilter: + threadProjectGroup && !threadProjectGroup.archived + ? { + label: threadProjectGroup.displayName, + isActive: projectScopeKeys.includes(threadProjectGroup.projectKey), + } + : null, + isPinned, + isSettled, + autoSettleEnabled: thread.autoSettleDisabledAt == null, + isSnoozed, + canSnoozeNow: canSnooze(thread, { now: new Date().toISOString() }), + isRegeneratingTitle, + isRunning: !threadRuntimeCanArchive(thread.runtime), + canShareWithGuest: appAtomRegistry.get( + canShareWithGuestAtom(threadRef.environmentId), + ), + canTurnIntoProject: canTurnThreadIntoProject(threadRef), + supports: { + settlement: configLoaded, + autoSettleOptOut: supportsAutoSettleOptOut, + snooze: configLoaded, + pinning: configLoaded, + titleRegeneration: configLoaded, + }, + snoozePresets, + }), + ), position, ), ); if (clicked._tag === "Failure" || clicked.value === null) return; + if (clicked.value === OPEN_THREAD_TO_THE_SIDE_MENU_ID) { + openThreadToTheSide(threadRef); + return; + } if (threadActionRequiresOperate(clicked.value) && !checkThreadOperations([thread])) return; if (clicked.value?.startsWith("snooze:")) { const preset = @@ -3354,7 +3473,12 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve snoozedThreads.length === 0 && (settledCount === 0 || scopedProjectGroups.length > 0) ? ( -
+
{projects.length === 0 ? "No projects yet" @@ -3394,10 +3518,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve
    { const threadKey = scopedThreadKey( scopeThreadRef(thread.environmentId, thread.id), ); const isCard = section === "active" || section === "pinned" || section === "working"; + const folderRow = + typeof folderOrGhost === "string" ? folderOrGhost : undefined; const rowVariant = folderRow ? folderRowsMultiline ? "folder-multiline" @@ -3432,8 +3554,9 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve : isCard ? "card" : "slim"; + const Row = folderOrGhost === true ? SidebarThreadRow : SortableThreadRow; return ( - ); }; + const shelfToggle = ( + shelfThreads: readonly EnvironmentThreadShell[], + count: number, + expanded: boolean, + toggle: () => void, + ) => + count > shelfThreads.length || + shelfThreads.some((thread) => threadShellKey(thread) !== routeRowThreadKey) + ? { + expanded, + onToggle: (header: HTMLElement) => { + if (expanded) return toggle(); + flushSync(toggle); + revealSidebarShelf(header); + }, + } + : null; const from = isContextDrag ? null : (dragState?.activeSection ?? null); const items: ReactNode[] = [ , ); break; @@ -3643,10 +3785,12 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve className={cn(workingThreads.length === 0 && "mt-auto")} label="Snoozed" count={snoozedThreads.length} - toggle={{ - expanded: snoozedShelfExpanded, - onToggle: toggleSnoozedShelf, - }} + toggle={shelfToggle( + snoozedThreads, + snoozedThreads.length, + snoozedShelfExpanded, + toggleSnoozedShelf, + )} />, ); break; @@ -3668,10 +3812,12 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve concealed={settledCount === 0 && from === null} dragging={from !== null} isDropTarget={dragTargetSection === "settled"} - toggle={{ - expanded: settledShelfExpanded, - onToggle: toggleSettledShelf, - }} + toggle={shelfToggle( + settledThreads, + settledCount, + settledShelfExpanded, + toggleSettledShelf, + )} />, ); break; @@ -3695,6 +3841,17 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve break; } } + // Past the list edge the picked-up row lifts under the + // pointer (pen jVRc4) while its sidebar copy goes home. + const contextDragThread = + isContextDrag && dragState ? threadByKey.get(dragState.activeKey) : null; + if (contextDragThread && dragState) { + items.push( + + {renderThreadRow(contextDragThread, dragState.activeSection, true)} + , + ); + } return items; })()} {settledShelfExpanded && hiddenSettledCount > 0 ? ( @@ -3709,7 +3866,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve olderSettledShelf.retry(); showMoreSettled(); }} - className="mx-0.5 flex h-5.5 w-[calc(100%-0.25rem)] cursor-pointer items-center rounded-row px-2 text-left text-sm font-medium text-dim-foreground outline-none hover:text-sidebar-foreground focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring aria-disabled:cursor-default" + className="mx-0.5 flex h-5.5 w-[calc(100%-0.25rem)] cursor-pointer items-center rounded-row px-2 text-left text-sm font-medium text-dim-foreground outline-none not-aria-disabled:hover:surface-sidebar-row-hover hover:text-sidebar-foreground focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring aria-disabled:cursor-default" > {olderSettledShelf.loading ? "Loading" diff --git a/apps/web/src/components/SidebarStageBackdrop.tsx b/apps/web/src/components/SidebarStageBackdrop.tsx index e813928743dff..730f4a66891d2 100644 --- a/apps/web/src/components/SidebarStageBackdrop.tsx +++ b/apps/web/src/components/SidebarStageBackdrop.tsx @@ -4,7 +4,7 @@ import { useId } from "react"; import { APP_STAGE_LABEL } from "../branding"; import { resolveServerBackedAppStageLabel } from "../branding.logic"; -import { useDevArtworkPreview } from "../devArtworkPreview"; +import { useMediaQuery } from "../hooks/useMediaQuery"; import { primaryServerConfigAtom } from "../state/server"; export type SidebarStageBackdropVariant = "nightly" | "dev"; @@ -37,21 +37,19 @@ export function resolveEnvironmentIdentificationPillLabel( export function useEnvironmentStageLabel(): string { const primaryServerVersion = useAtomValue(primaryServerConfigAtom)?.environment.serverVersion ?? null; - const devArtworkPreview = useDevArtworkPreview(); - - return ( - devArtworkPreview ?? - resolveServerBackedAppStageLabel({ - primaryServerVersion, - fallbackStageLabel: APP_STAGE_LABEL, - }) - ); + return resolveServerBackedAppStageLabel({ + primaryServerVersion, + fallbackStageLabel: APP_STAGE_LABEL, + }); } export function useSidebarStageBackdropVariant( mode: EnvironmentIdentificationMode, ): SidebarStageBackdropVariant | null { - return resolveSidebarStageBackdropVariant(useEnvironmentStageLabel(), mode); + const stageLabel = useEnvironmentStageLabel(); + const isMobile = useMediaQuery("(width < 48rem)"); + // Phone chrome uses the theme's --app-chrome-background without channel artwork. + return isMobile ? null : resolveSidebarStageBackdropVariant(stageLabel, mode); } /** diff --git a/apps/web/src/components/SurfaceLauncher.tsx b/apps/web/src/components/SurfaceLauncher.tsx new file mode 100644 index 0000000000000..f4f155e91aea3 --- /dev/null +++ b/apps/web/src/components/SurfaceLauncher.tsx @@ -0,0 +1,478 @@ +import { + ChevronDown, + FileDiff, + Files, + Globe2, + MessagesSquare, + Smartphone, + SquarePen, + TerminalSquare, +} from "lucide-react"; +import { + type ComponentType, + type RefObject, + useEffect, + useEffectEvent, + useRef, + useState, +} from "react"; + +import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons"; +import { Button } from "~/components/ui/button"; +import { Kbd } from "~/components/ui/kbd"; +import { + Menu, + MenuItem, + MenuPopup, + MenuSub, + MenuSubPopup, + MenuSubTrigger, + MenuTrigger, +} from "~/components/ui/menu"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; +import { openLayerMatches } from "~/lib/openLayers"; +import { surfaceShortcutKey } from "~/lib/surfaceShortcutKey"; +import { registerCommands } from "~/shortcuts/dispatcher"; + +import { DraftProjectGroupLabel, DraftProjectRow } from "./chat/DraftHeroHeadline"; + +/** What a launcher can open for a thread. */ +export type LauncherSurfaceKind = + | "browser" + | "terminal" + | "files" + | "diff" + | "pull-request" + | "pull-requests" + | "device" + | "side-chat"; + +const LAUNCHER_SHORTCUT_BLOCKING_LAYERS = [ + '[data-slot="dialog-popup"]', + '[data-slot="alert-dialog-popup"]', + '[data-slot="command-dialog-popup"]', + '[data-slot="menu-popup"]', + '[data-slot="select-popup"]', + '[data-slot="popover-popup"]', + '[data-slot="combobox-popup"]', + '[data-slot="autocomplete-popup"]', +] + .map((selector) => `${selector}:is([data-open],[data-ending-style])`) + .join(","); + +/** The surfaces a launcher offers, in its order, with their letter and one-line hint. */ +export const SURFACE_LAUNCHER_ENTRIES: ReadonlyArray<{ + readonly kind: LauncherSurfaceKind; + readonly label: string; + readonly icon: ComponentType<{ className?: string }>; + readonly shortcut: string; + /** Why it is unavailable, on the row in place of its detail. */ + readonly hint: string; +}> = [ + { + kind: "browser", + label: "Browser", + icon: Globe2, + shortcut: "B", + hint: "Only available in the desktop app.", + }, + { + kind: "terminal", + label: "Terminal", + icon: TerminalSquare, + shortcut: "T", + hint: "Available when a project is open.", + }, + { + kind: "files", + label: "Files", + icon: Files, + shortcut: "F", + hint: "Available when a project is open.", + }, + { + kind: "diff", + label: "Diff", + icon: FileDiff, + shortcut: "D", + hint: "Available for Git repositories.", + }, + { + kind: "pull-request", + label: "Pull request", + icon: PullRequestGlyph.pullRequest, + shortcut: "P", + hint: "No pull request on this branch yet.", + }, + { + kind: "pull-requests", + label: "Linked pull requests", + icon: PullRequestGlyph.link, + shortcut: "L", + hint: "No linked pull requests available.", + }, + { + kind: "side-chat", + label: "Side chat", + icon: MessagesSquare, + shortcut: "S", + hint: "Available after the first message.", + }, + { + kind: "device", + label: "Device", + icon: Smartphone, + shortcut: "M", + hint: "Available from a thread.", + }, +]; + +export interface SurfaceLauncherAction { + readonly kind: LauncherSurfaceKind; + readonly shortcut: string; + readonly available: boolean; + /** Shown on the row while unavailable; defaults to the surface's hint. */ + readonly disabledReason?: string; + readonly onClick: () => void; +} + +export interface SurfaceMenuAction { + readonly kind: LauncherSurfaceKind | "new-thread"; + readonly label: string; + readonly icon: ComponentType<{ className?: string }>; + readonly shortcut: string; + readonly available: boolean; + readonly disabledReason?: string | undefined; + readonly onClick: () => void; +} + +/** The "+" menus' rows: a new thread when offered, then the launcher's surfaces in its order. */ +export function surfaceMenuActions(input: { + readonly onNewThread?: (() => void) | undefined; + readonly surfaces: Partial< + Record< + LauncherSurfaceKind, + { + readonly available: boolean; + readonly disabledReason?: string | undefined; + readonly onClick: () => void; + } + > + >; +}): SurfaceMenuAction[] { + const actions: SurfaceMenuAction[] = input.onNewThread + ? [ + { + kind: "new-thread", + label: "New thread", + icon: SquarePen, + shortcut: "N", + available: true, + onClick: input.onNewThread, + }, + ] + : []; + for (const entry of SURFACE_LAUNCHER_ENTRIES) { + const surface = input.surfaces[entry.kind]; + if (!surface) continue; + actions.push({ + kind: entry.kind, + label: entry.label, + icon: entry.icon, + shortcut: entry.shortcut, + available: surface.available, + disabledReason: surface.disabledReason, + onClick: surface.onClick, + }); + } + return actions; +} + +function MenuKbd(props: { readonly shortcut: string }) { + return ( + + {props.shortcut} + + ); +} + +/** + * The rows of a "+" menu. Browser offers its profiles as a submenu once + * there is more than one, as the launcher's chevron does. + */ +export function SurfaceMenuItems(props: { + readonly actions: readonly SurfaceMenuAction[]; + readonly browserProfiles: ReadonlyArray<{ readonly id: string; readonly name: string }>; + readonly onAddBrowserInProfile: (profileId: string) => void; + readonly onClose: () => void; +}) { + return props.actions.map((action) => { + const Icon = action.icon; + if (action.kind === "browser" && action.available && props.browserProfiles.length > 1) { + return ( + + { + const pointerType = + "pointerType" in event.nativeEvent && + typeof event.nativeEvent.pointerType === "string" + ? event.nativeEvent.pointerType + : undefined; + if (pointerType === "touch") return; + props.onClose(); + action.onClick(); + }} + > + + {action.label} + + + + {props.browserProfiles.map((profile) => ( + props.onAddBrowserInProfile(profile.id)}> + {profile.name} + + ))} + + + ); + } + const item = ( + + + {action.label} + + + ); + if (action.available || !action.disabledReason) return item; + return ( + + + {action.disabledReason} + + ); + }); +} + +type SurfaceShortcutEvent = Pick< + KeyboardEvent, + "altKey" | "ctrlKey" | "defaultPrevented" | "isComposing" | "key" | "metaKey" +> & + Partial>; + +export function surfaceShortcutActionForKey< + const Action extends { available: boolean; shortcut: string }, +>(actions: readonly Action[], event: SurfaceShortcutEvent): Action | null { + if (event.defaultPrevented || event.isComposing) return null; + if (event.metaKey || event.ctrlKey || event.altKey) return null; + const key = surfaceShortcutKey(event); + return ( + actions.find((action) => action.available && action.shortcut.toLowerCase() === key) ?? null + ); +} + +/** + * The `:not` clause lets `closest` see past non-editable islands + * (`contenteditable="false"`) to an editable host around them, matching + * ComposerPendingUserInputPanel's typing guard. + */ +export function surfaceShortcutTargetsTypingContext( + target: { closest(selectors: string): unknown } | null, +): boolean { + return ( + target?.closest('input, textarea, select, [contenteditable]:not([contenteditable="false"])') != + null + ); +} + +/** + * Arrows move focus between the launcher's rows; Enter is the focused row's + * own. Focus may sit on the pane around the launcher (the layout focuses + * the pane), so this runs from the window rather than the launcher. + */ +function launcherHasFocus(launcher: HTMLElement | null): boolean { + const active = document.activeElement; + return !!launcher && !!active && (launcher.contains(active) || active.contains(launcher)); +} + +function launcherRows(launcher: HTMLElement): HTMLButtonElement[] { + return Array.from( + launcher.querySelectorAll('[role="listitem"] > button:not(:disabled)'), + ); +} + +function moveLauncherFocus(launcher: HTMLElement | null, event: KeyboardEvent): boolean { + if (!launcher || event.defaultPrevented || event.metaKey || event.ctrlKey || event.altKey) { + return false; + } + const forward = event.key === "ArrowDown" || event.key === "ArrowRight"; + if (!forward && event.key !== "ArrowUp" && event.key !== "ArrowLeft") return false; + if (!launcherHasFocus(launcher)) return false; + const active = document.activeElement; + const rows = launcherRows(launcher); + if (rows.length === 0) return false; + const index = rows.findIndex((row) => row === active); + const next = + index === -1 + ? forward + ? 0 + : rows.length - 1 + : (index + (forward ? 1 : -1) + rows.length) % rows.length; + rows[next]?.focus(); + return true; +} + +/** Capture shortcuts for the focused launcher pane, preserving typing contexts and handled events; + * row focus alone is too fragile. */ +export function useSurfaceLauncherKeys(input: { + readonly launcherRef: RefObject; + readonly activeRef: RefObject; + readonly actions: readonly SurfaceLauncherAction[]; + readonly onClose: (() => void) | undefined; +}): void { + const { launcherRef, activeRef } = input; + const handler = useEffectEvent((event: KeyboardEvent) => { + if (!activeRef.current) return; + if (openLayerMatches(LAUNCHER_SHORTCUT_BLOCKING_LAYERS)) return; + if (moveLauncherFocus(launcherRef.current, event)) { + event.preventDefault(); + event.stopPropagation(); + return; + } + if (event.key === "Escape" && input.onClose && launcherHasFocus(launcherRef.current)) { + event.preventDefault(); + event.stopPropagation(); + input.onClose(); + return; + } + const action = surfaceShortcutActionForKey(input.actions, event); + if (!action) return; + const target = event.composedPath()[0] ?? event.target; + if (target instanceof Element && surfaceShortcutTargetsTypingContext(target)) return; + event.preventDefault(); + event.stopPropagation(); + action.onClick(); + }); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Register the launcher capture scope for its mounted lifetime. + useEffect( + () => + registerCommands( + {}, + { + capture: true, + onKeyDown: (event) => { + handler(event); + return event.defaultPrevented; + }, + }, + ), + [], + ); +} + +/** + * The "Open a surface" rows, in the new thread hero's style. Unavailable + * surfaces stay visible with a one-line reason. The browser row offers its + * profiles once there is more than one to choose between. + */ +export function SurfaceLauncherRows(props: { + readonly actions: readonly SurfaceLauncherAction[]; + readonly browserProfiles: ReadonlyArray<{ readonly id: string; readonly name: string }>; + readonly onAddBrowserInProfile: (profileId: string) => void; +}) { + return ( + <> + Open a surface + {SURFACE_LAUNCHER_ENTRIES.map((entry) => { + const action = props.actions.find((candidate) => candidate.kind === entry.kind); + if (!action) return null; + const Icon = entry.icon; + // Same choice the tab bar's "+" menu offers: the row opens the + // default profile, the chevron picks another. + const profileChooser = + entry.kind === "browser" && action.available && props.browserProfiles.length > 1 ? ( + + + } + > + + + + {props.browserProfiles.map((profile) => ( + props.onAddBrowserInProfile(profile.id)} + > + {profile.name} + + ))} + + + ) : null; + return ( + } + label={entry.label} + detail={action.available ? "" : (action.disabledReason ?? entry.hint)} + shortcut={entry.shortcut} + disabled={!action.available} + action={profileChooser} + onSelect={action.onClick} + /> + ); + })} + + ); +} + +/** Where the keyboard lands in a launcher: its selected row, else its first, else itself. */ +export function launcherFocusTarget(launcher: HTMLElement): HTMLElement { + return ( + launcher.querySelector( + '[role="listitem"][data-selected] > button:not(:disabled)', + ) ?? + launcherRows(launcher)[0] ?? + launcher + ); +} + +/** + * The launcher's element, focused on mount when its pane has focus. In the + * layout strip a plain focus() scrolls the strip, so it never scrolls. + * `focusFirstRow` puts the keyboard on the selected row, or the first, so + * Enter picks it. + */ +export function useLauncherElement( + activeRef: RefObject, + focusFirstRow: boolean, +): [RefObject, (node: HTMLDivElement | null) => void] { + const launcherRef = useRef(null); + const focusFirstRowRef = useRef(focusFirstRow); + // Stable identity so React only runs this callback ref on mount/unmount; + // an inline arrow would re-attach and re-focus on every render. + const [attach] = useState(() => (node: HTMLDivElement | null) => { + launcherRef.current = node; + if (!node || !activeRef.current) return; + const active = document.activeElement; + // Focus held outside the strip (the palette, the sidebar's search) stays. + if (active && active !== document.body && !active.closest("[data-layout-strip]")) return; + const target = focusFirstRowRef.current ? launcherFocusTarget(node) : node; + target.focus({ preventScroll: true }); + }); + return [launcherRef, attach]; +} diff --git a/apps/web/src/components/SurfaceTabIcon.tsx b/apps/web/src/components/SurfaceTabIcon.tsx new file mode 100644 index 0000000000000..d2af522c4541f --- /dev/null +++ b/apps/web/src/components/SurfaceTabIcon.tsx @@ -0,0 +1,307 @@ +import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; +import { scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { + threadRuntimeIsActive, + type EnvironmentThreadShell, +} from "@t3tools/client-runtime/state/shell"; +import { + threadPullRequestKeysEqual, + visibleThreadPullRequests, +} from "@t3tools/client-runtime/thread-pull-requests"; +import type { + EnvironmentId, + PreviewSessionSnapshot, + ProjectId, + PullRequestState, + ScopedThreadRef, + ThreadId, +} from "@t3tools/contracts"; +import { faviconUrlForOrigin } from "@t3tools/shared/favicon"; +import { surfaceTabTitle } from "../layout/surfaceTabLabels"; +import { FileDiff, Files, Globe2, MessagesSquare, Smartphone, TerminalSquare } from "lucide-react"; + +import { AndroidIcon, AppleIcon } from "~/components/Icons"; +import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons"; +import { useTheme } from "~/hooks/useTheme"; +import { cn } from "~/lib/utils"; +import { useThreadPreviewState, type DesktopPreviewOverlay } from "~/previewStateStore"; +import type { RightPanelSurface } from "~/rightPanelStore"; +import { + useProjects, + useQuietThreadShell, + useServerConfigs, + useThreadShells, +} from "~/state/entities"; +import { + newestPullRequestSummary, + pullRequestEnvironment, + useSharedPullRequestSummary, +} from "~/state/pullRequests"; +import { useEnvironmentQuery } from "~/state/query"; + +import { PierreEntryIcon } from "./chat/PierreEntryIcon"; +import { FaviconImage } from "./preview/PreviewFaviconIcon"; +import { resolvePullRequestState } from "./pullRequest/pullRequestPresentation"; + +interface PullRequestTabStatus { + projectId: string; + repository: string; + number: number; + state: PullRequestState; + isDraft: boolean; +} + +export type PullRequestTabStatusSeed = Pick; + +export function surfaceTitle( + surface: RightPanelSurface, + sessions: Readonly>, + terminalLabelsById: ReadonlyMap, +): string { + return surfaceTabTitle( + surface, + surface.kind === "preview" && surface.resourceId ? sessions[surface.resourceId] : null, + surface.kind === "terminal" ? terminalLabelsById.get(surface.activeTerminalId) : undefined, + ); +} + +function PreviewFavicon({ capturedUrl, url }: { capturedUrl: string | null; url: string | null }) { + const publicProviderUrl = faviconUrlForOrigin(url, 32); + return ( + } + className="size-3.5 shrink-0 rounded-sm object-contain" + /> + ); +} + +function sameOrigin(left: string, right: string): boolean { + try { + return new URL(left).origin === new URL(right).origin; + } catch { + return false; + } +} + +export function previewFaviconOf( + surface: RightPanelSurface, + sessions: Readonly>, + desktopByTabId: Readonly>, +): { url: string | null; capturedUrl: string | null } { + const snapshot = + surface.kind === "preview" && surface.resourceId ? sessions[surface.resourceId] : null; + const url = !snapshot || snapshot.navStatus._tag === "Idle" ? null : snapshot.navStatus.url; + const favicon = snapshot ? (desktopByTabId[snapshot.tabId]?.favicon ?? null) : null; + const capturedUrl = favicon && url && sameOrigin(favicon.pageUrl, url) ? favicon.dataUrl : null; + return { url, capturedUrl }; +} + +export function SurfaceIcon({ + surface, + previewUrl, + previewCapturedUrl, + theme, + environmentId, + pullRequestStatusSeed, +}: { + surface: RightPanelSurface; + previewUrl: string | null; + previewCapturedUrl: string | null; + theme: "light" | "dark"; + environmentId: EnvironmentId | null; + pullRequestStatusSeed: PullRequestTabStatusSeed | undefined; +}) { + switch (surface.kind) { + case "preview": + return ; + case "diff": + return ( + + ); + case "files": + return ( + + ); + case "file": + return ( + + ); + case "terminal": + return ( + + ); + case "pull-request": + return ( + + ); + case "pull-requests": + return ( + + ); + case "side-chat": + return ; + case "device": + return surface.target?.platform === "ios" ? ( + + ) : surface.target?.platform === "android" ? ( + + ) : ( + + ); + } +} + +/** What a side chat tab's dot shows and its title's accessible name says. */ +function sideChatTabState(shell: EnvironmentThreadShell | null) { + if (shell === null) return null; + if (shell.hasPendingApprovals || shell.pendingSubagentRequest === "approval") { + return "needs approval"; + } + if (shell.hasPendingUserInput || shell.pendingSubagentRequest === "user_input") { + return "needs input"; + } + return threadRuntimeIsActive(shell.runtime) ? "running" : null; +} + +// Static dots: a side chat tab can stay open for a whole run. +function SideChatTabIcon(props: { environmentId: EnvironmentId | null; threadId: ThreadId }) { + const state = sideChatTabState( + useQuietThreadShell( + props.environmentId === null ? null : scopeThreadRef(props.environmentId, props.threadId), + ), + ); + return ( + <> + + {state === null ? null : ( + + )} + + ); +} + +export function resolvePullRequestTabLink( + threads: readonly Pick[], + environmentId: EnvironmentId | null, + host: string | null, + reference: { repository: string; number: number }, +) { + if (environmentId === null || host === null) return undefined; + // Keys are cached per object, so one target normalizes once instead of once per link. + const target = { host, repository: reference.repository, number: reference.number }; + let newest: EnvironmentThreadShell["pullRequests"][number] | undefined; + for (const thread of threads) { + if (thread.environmentId !== environmentId) continue; + for (const link of visibleThreadPullRequests(thread.pullRequests)) { + if (!threadPullRequestKeysEqual(link, target)) continue; + if ( + newest === undefined || + (link.snapshot?.syncedAt ?? "") > (newest.snapshot?.syncedAt ?? "") + ) + newest = link; + } + } + return newest; +} + +function PullRequestSurfaceIcon({ + surface, + environmentId, + seed, +}: { + surface: Extract; + environmentId: EnvironmentId | null; + seed: PullRequestTabStatusSeed | undefined; +}) { + const resolvedEnvironmentId = + (surface.environmentId as EnvironmentId | undefined) ?? environmentId; + const projects = useProjects(); + const threads = useThreadShells(); + const project = projects.find( + (entry) => entry.environmentId === resolvedEnvironmentId && entry.id === surface.projectId, + ); + const identity = project?.repositoryIdentity; + const host = + surface.host ?? + (identity?.provider + ? sourceControlClients.changeRequestHost(identity, identity.provider) + : null); + const configs = useServerConfigs(); + const capabilities = + resolvedEnvironmentId === null + ? undefined + : configs.get(resolvedEnvironmentId)?.environment.capabilities; + const linkedSnapshot = + capabilities !== undefined + ? (resolvePullRequestTabLink(threads, resolvedEnvironmentId, host, surface)?.snapshot ?? null) + : null; + const detail = useEnvironmentQuery( + resolvedEnvironmentId === null || capabilities === undefined || linkedSnapshot !== null + ? null + : pullRequestEnvironment.detail({ + environmentId: resolvedEnvironmentId, + input: { + projectId: surface.projectId as ProjectId, + ...(capabilities !== undefined && surface.host !== undefined + ? { host: surface.host } + : {}), + repository: surface.repository, + number: surface.number, + allowStale: false, + }, + }), + ).data; + const reference = { + projectId: surface.projectId as ProjectId, + repository: surface.repository, + number: surface.number, + }; + const sharedSummary = useSharedPullRequestSummary(resolvedEnvironmentId, reference); + const status = linkedSnapshot ?? newestPullRequestSummary(detail, sharedSummary) ?? seed ?? null; + if (status === null) { + return ; + } + const presentation = resolvePullRequestState({ + state: status.state, + isDraft: status.isDraft ?? detail?.isDraft ?? seed?.isDraft ?? false, + }); + return ; +} + +export function ThreadSurfaceTabIcon({ + threadRef, + surface, +}: { + threadRef: ScopedThreadRef; + surface: RightPanelSurface; +}) { + const { sessions, desktopByTabId } = useThreadPreviewState(threadRef); + const { resolvedTheme } = useTheme(); + const favicon = previewFaviconOf(surface, sessions, desktopByTabId); + return ( + + ); +} diff --git a/apps/web/src/components/ThreadNotificationCoordinator.tsx b/apps/web/src/components/ThreadNotificationCoordinator.tsx index 0268da23dd2e1..fddc5e07916d9 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.tsx +++ b/apps/web/src/components/ThreadNotificationCoordinator.tsx @@ -20,6 +20,8 @@ import { import { getClientSettings, subscribeClientSettings } from "../hooks/useSettings"; import { isWindowsPlatform } from "../lib/utils"; +import { selectVisibleLayoutTabs } from "../layout/layoutModel"; +import { useLayoutStore } from "../layout/layoutStore"; import { environmentSummaries } from "../state/presentation"; import { environmentShell } from "../state/shell"; import { environmentThreadShells } from "../state/threads"; @@ -98,6 +100,7 @@ export function startThreadNotifications( (router.state.matches.at(-1)?.params ?? {}) as { environmentId?: EnvironmentId; threadId?: ThreadId; + draftId?: string; }; const closeOpenedThreadToasts = () => { const { environmentId, threadId } = activeThread(); @@ -142,6 +145,15 @@ export function startThreadNotifications( return; const { notificationMode: mode, inAppNotificationsEnabled } = getClientSettings(); const active = activeThread(); + const threadVisible = + (active.threadId !== undefined || active.draftId !== undefined) && + selectVisibleLayoutTabs(useLayoutStore.getState().layout).some( + (tab) => + tab.content.kind === "chat" && + tab.content.target.kind === "server" && + tab.content.target.threadRef.environmentId === environmentId && + tab.content.target.threadRef.threadId === thread.id, + ); const title = kind === "completion" ? "Thread completed" @@ -163,7 +175,8 @@ export function startThreadNotifications( inAppNotificationsEnabled && document.visibilityState === "visible" && document.hasFocus() && - (active.environmentId !== environmentId || active.threadId !== thread.id) + (active.environmentId !== environmentId || active.threadId !== thread.id) && + !threadVisible ) { const toastKey = threadKey({ environmentId, threadId: thread.id }); const toastId: ToastId = toastManager.add({ diff --git a/apps/web/src/components/ThreadRouteView.tsx b/apps/web/src/components/ThreadRouteView.tsx index a7b7da0a6b53b..3fe29ac3e673e 100644 --- a/apps/web/src/components/ThreadRouteView.tsx +++ b/apps/web/src/components/ThreadRouteView.tsx @@ -1,5 +1,9 @@ import { useAtomValue } from "@effect/atom-react"; -import { parseScopedThreadKey, scopedThreadKey } from "@t3tools/client-runtime/environment"; +import { + parseScopedThreadKey, + scopedThreadKey, + scopeThreadRef, +} from "@t3tools/client-runtime/environment"; import { parseThreadKey, threadKey } from "@t3tools/client-runtime/state/entities"; import { visibilityParentKey } from "@t3tools/client-runtime/state/threads"; import type { ScopedThreadRef } from "@t3tools/contracts"; @@ -9,8 +13,8 @@ import { useEffect, useState } from "react"; import ChatView from "./ChatView"; import { BackgroundThreadContext } from "./chat/backgroundThread"; -import { SidebarInset } from "./ui/sidebar"; import { useComposerDraftStore } from "../composerDraftStore"; +import { closeLayoutThread } from "../layout/layoutActions"; import { useSidebarPendingFileDropStore } from "../sidebarPendingFileDropStore"; import { useEnvironmentThreadRefs, @@ -45,17 +49,16 @@ function useOpenThreadFamily(threadRef: ScopedThreadRef | null, missing: boolean }, [mountedKey]); } -/** - * Unkeyed so navigating between server threads reuses one ChatView. Rendered by the `_chat` - * layout, since an element only survives a route swap when the same parent renders it. Draft - * promotion runs in the draft store; this only follows the redirect it leaves behind. - * `background` keeps the thread mounted, inert and in place under Settings or Usage. - */ +/** Each layout tab retains ChatView across route swaps; only the focused tab owns the URL. */ export function ThreadRouteView({ target, + tabId = null, + focused = true, background = false, }: { target: ThreadRouteTarget; + tabId?: string | null; + focused?: boolean; background?: boolean; }) { const navigate = useNavigate(); @@ -71,6 +74,9 @@ export function ThreadRouteView({ const shellStatus = useEnvironmentShellStatus(serverThreadRef?.environmentId ?? null); const environmentThreadRefs = useEnvironmentThreadRefs(serverThreadRef?.environmentId ?? null); const bootstrapComplete = isThreadRouteSnapshotAuthoritative(shellStatus); + // The cached snapshot can predate a thread made just before a reload; only + // the server's own snapshot can say a thread is gone. + const shellLive = shellStatus === "live"; const draftThread = useComposerDraftStore((store) => serverThreadRef ? store.getDraftThreadByRef(serverThreadRef) : null, ); @@ -81,11 +87,14 @@ export function ThreadRouteView({ // The ChatView that carried a draft keeps its key through the redirect to its thread, and only // until the route leaves that thread, so later visits reuse the shared unkeyed ChatView. const [handoffKey, setHandoffKey] = useState<{ threadKey: string; key: string } | null>(null); + const draftThreadRef = draftSession + ? scopeThreadRef(draftSession.environmentId, draftSession.threadId) + : draftRedirect; const nextHandoffKey = - draftId !== null && draftSession === null && draftRedirect - ? handoffKey?.key === draftId + draftId !== null && draftThreadRef + ? handoffKey?.key === draftId && handoffKey.threadKey === scopedThreadKey(draftThreadRef) ? handoffKey - : { threadKey: scopedThreadKey(draftRedirect), key: draftId } + : { threadKey: scopedThreadKey(draftThreadRef), key: draftId } : serverThreadRef && handoffKey?.threadKey === scopedThreadKey(serverThreadRef) ? handoffKey : serverThreadRef && serverThreadDraftId @@ -142,17 +151,21 @@ export function ThreadRouteView({ // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- clears pending file drops and navigates the router home once a server thread is confirmed missing useEffect(() => { - if (target.kind !== "server" || !bootstrapComplete) { + if (target.kind !== "server" || !shellLive) { return; } if (renderState === "missing") { const { clearPendingFileDropsForThread } = useSidebarPendingFileDropStore.getState(); clearPendingFileDropsForThread(target.threadRef); - if (environmentHasAnyThreads && !background) { + if (tabId !== null) { + closeLayoutThread(target.threadRef); + return; + } + if (!background && environmentHasAnyThreads) { void navigate({ to: "/", replace: true }); } } - }, [background, bootstrapComplete, environmentHasAnyThreads, navigate, renderState, target]); + }, [background, environmentHasAnyThreads, navigate, renderState, shellLive, tabId, target]); let view: React.ReactNode = null; let redirect: React.ReactNode = null; @@ -225,12 +238,9 @@ export function ThreadRouteView({ } return ( - - {view} - {background ? null : redirect} - + + {view} + {background || !focused ? null : redirect} + ); } diff --git a/apps/web/src/components/ThreadStatusIndicators.tsx b/apps/web/src/components/ThreadStatusIndicators.tsx index 06a8a79a3965f..8ea503be51dca 100644 --- a/apps/web/src/components/ThreadStatusIndicators.tsx +++ b/apps/web/src/components/ThreadStatusIndicators.tsx @@ -412,7 +412,11 @@ export function ThreadPullRequestsMiniList({ ); })} - {hiddenCount > 0 ?
  • {hiddenCount} more
  • : null} + {hiddenCount > 0 ? ( +
  • + {hiddenCount} more +
  • + ) : null}
); } diff --git a/apps/web/src/components/ThreadSurfaceTab.tsx b/apps/web/src/components/ThreadSurfaceTab.tsx new file mode 100644 index 0000000000000..2a37a6d8a105b --- /dev/null +++ b/apps/web/src/components/ThreadSurfaceTab.tsx @@ -0,0 +1,1189 @@ +import type { SideChatParentPanel } from "~/lib/sideChatPane"; +import { useSideChat } from "./chat/useSideChat"; +import { threadEnvironment } from "../state/threads"; +import { DEFAULT_RUNTIME_MODE } from "../types"; +/** Resolve surface context independently of ChatView so tabs remain usable after the chat closes, + * and report when their thread is gone. */ +import { scopedThreadKey, scopeProjectRef } from "@t3tools/client-runtime/environment"; +import { + AuthOrchestrationOperateScope, + AuthPreviewOperateScope, + AuthTerminalOperateScope, + AuthTerminalReadScope, + type PreviewAnnotationPayload, + type ProjectId, + type ScopedThreadRef, +} from "@t3tools/contracts"; +import { useAtomValue } from "@effect/atom-react"; +import { orchestrationV2WorktreePending } from "@t3tools/shared/orchestrationV2ThreadError"; +import { + projectScriptCwd, + projectScriptRuntimeEnv, + resolveProjectScripts, +} from "@t3tools/shared/projectScripts"; +import { nextTerminalId, resolveTerminalSessionLabel } from "@t3tools/shared/terminalLabels"; +import { visibleThreadPullRequests } from "@t3tools/shared/threadPullRequests"; +import { FolderX, Unlink2Icon } from "lucide-react"; +import { lazy, Suspense, useEffect, useState } from "react"; + +import { isBrowserSettingsReadError } from "../browser/openFileInPreview"; +import { previewRuntimeTabId } from "../browser/previewRuntimeTabId"; +import { type ComposerImageAttachment, useComposerDraftStore } from "../composerDraftStore"; +import { useDiffPanelStore } from "../diffPanelStore"; +import { isElectron } from "../env"; +import { useNewThreadHandler } from "../hooks/useHandleNewThread"; +import { useEnvironmentSettings } from "../hooks/useSettings"; +import { useLayoutPane } from "../layout/layoutPaneContext"; +import { copyLayoutFilePath } from "../layout/PaneTabs"; +import type { LauncherSurfaceKind } from "./SurfaceLauncher"; +import { THREAD_TERMINAL_SURFACE_ID } from "../layout/layoutModel"; +import { terminalTabTitle } from "../layout/surfaceTabLabels"; +import { runInNewLayoutTab } from "../layout/layoutStore"; +import { + useThreadChatBridge, + useThreadChatBridgeStore, + waitForThreadChatSendReady, +} from "../layout/threadSurfaceBridge"; +import { showThreadChatBeside } from "../layout/openRules"; +import { requestLayoutSurfacePlacement } from "../layout/surfaceSync"; +import { shortcutLabelForCommand } from "../keybindings"; +import { readLocalApi } from "../localApi"; +import { randomUUID } from "../lib/utils"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import { browserMiniPlayerSource, usePreviewMiniPlayerStore } from "../previewMiniPlayerStore"; +import { + isPreviewSupportedInRuntime, + readThreadPreviewState, + setActivePreviewTab, + useThreadPreviewState, +} from "../previewStateStore"; +import { + selectThreadRightPanelState, + useRightPanelStore, + type RightPanelSurface, +} from "../rightPanelStore"; +import { + useAllEnvironmentShellsBootstrapped, + useEnvironmentSupportsServerBrowser, + useProject, + useServerConfigs, + useQuietThreadShell, + useQuietThreadProjection, +} from "../state/entities"; +import { previewEnvironment } from "../state/preview"; +import { primaryServerKeybindingsAtom, serverEnvironment } from "../state/server"; +import { + readEnvironmentScope, + useEnvironmentScope, + useEnvironmentSessionStatus, +} from "../state/session"; +import { terminalEnvironment } from "../state/terminal"; +import { knownTerminalSessionsAtom, useKnownTerminalSessions } from "../state/terminalSessions"; +import { appAtomRegistry } from "../rpc/atomRegistry"; +import { useEnvironmentQuery } from "../state/query"; +import { focusActivePanelTerminal, focusTerminal } from "../lib/terminalFocus"; +import { requestSideChatComposerFocus } from "../lib/workspaceFocus"; +import type { TerminalContextSelection } from "../lib/terminalContext"; +import { useAtomCommand } from "../state/use-atom-command"; +import { vcsEnvironment } from "../state/vcs"; +import { + isThreadlessSurfacesRef, + threadlessSurfacesProjectRef, + useThreadlessHome, +} from "../threadlessSurfaces"; +import { findProjectForChangeRequest } from "@t3tools/client-runtime/change-request-links"; +import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; +import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; +import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; +import { MAX_TERMINALS_PER_GROUP } from "../types"; +import { agentControlledBrowserCloseConfirmation } from "./ChatView.logic"; +import { FileSurfaceState } from "./files/fileSurfaceChrome"; +import { addBrowserSurface } from "./preview/addBrowserSurface"; +import { closePreviewSession } from "./preview/closePreviewSession"; +import { getConfiguredPreviewUrls } from "./preview/previewEmptyStateLogic"; +import { PullRequestDetailGhost } from "./pullRequest/PullRequestGhosts"; +import { + pullRequestPanelContext, + threadPullRequestPanelTarget, +} from "./pullRequest/pullRequestDetail.logic"; +import { ThreadPullRequestsPanel } from "./pullRequest/ThreadPullRequestsPanel"; +import { RightPanelTabs } from "./RightPanelTabs"; +import { + PersistentThreadTerminalDrawer, + PersistentThreadTerminalPanel, +} from "./ThreadTerminalSurfaces"; +import { Button } from "./ui/button"; +import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "./ui/empty"; +import { stackedThreadToast, toastManager } from "./ui/toast"; +import { confirmTerminalClose } from "../lib/terminalCloseConfirm"; + +const SideChatPane = lazy(() => import("./chat/SideChatPane")); +const PreviewPanel = lazy(() => + import("./preview/PreviewPanel").then((module) => ({ default: module.PreviewPanel })), +); +const DiffPanel = lazy(() => import("./DiffPanel")); +const DevicePanel = lazy(() => + import("./device/DevicePanel").then((module) => ({ default: module.DevicePanel })), +); +const FilePreviewPanel = lazy(() => import("./files/FilePreviewPanel")); +const PullRequestDetailPanel = lazy(() => + import("./pullRequest/PullRequestDetailPanel").then((module) => ({ + default: module.PullRequestDetailPanel, + })), +); + +const EMPTY_PENDING: ReadonlySet = new Set(); +const noop = () => undefined; + +/** What a thread's surfaces work in: its project, checkout and capabilities. */ +function useThreadSurfaceContext(threadRef: ScopedThreadRef) { + const serverThread = useQuietThreadShell(threadRef); + const draftThread = useComposerDraftStore((store) => store.getDraftThreadByRef(threadRef)); + const draftId = useComposerDraftStore((store) => store.getDraftIdByRef(threadRef)); + const environmentId = threadRef.environmentId; + const threadless = isThreadlessSurfacesRef(threadRef); + const projectId = + serverThread?.projectId ?? + draftThread?.projectId ?? + threadlessSurfacesProjectRef(threadRef)?.projectId ?? + null; + const projectRef = projectId === null ? null : scopeProjectRef(environmentId, projectId); + const project = useProject(projectRef); + const settings = useEnvironmentSettings(environmentId); + const projectScripts = project ? resolveProjectScripts(settings, project) : []; + const worktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null; + // Without a project, a threadless ref works in the server's home. + const home = useThreadlessHome(threadRef); + const workspaceRoot = worktreePath ?? project?.workspaceRoot ?? home; + const gitCwd = project + ? projectScriptCwd({ project: { cwd: project.workspaceRoot }, worktreePath }) + : home; + const serverConfig = useServerConfigs().get(environmentId) ?? null; + const bootstrapped = useAllEnvironmentShellsBootstrapped(); + const isServerThread = serverThread !== null; + return { + serverThread, + draftThread, + isServerThread, + threadless, + project, + projectScripts, + worktreePath, + workspaceRoot, + gitCwd, + serverConfig, + projectRef, + // A threadless surface has no composer, so nothing to comment into. + composerDraftTarget: threadless + ? undefined + : isServerThread + ? threadRef + : (draftId ?? threadRef), + // A threadless ref is gone with its project; one without a project never is. + gone: + bootstrapped && + (threadless + ? projectId !== null && project === null + : serverThread === null && draftThread === null), + }; +} + +/** + * What a thread can open as a surface, and how: the launcher's rows and a + * surface tab's "+". Each opens through `rightPanelStore`, and the layout + * gives it a tab (surfaceSync.ts). + */ +export function useThreadSurfaceOpeners(threadRef: ScopedThreadRef) { + const ref = threadRef; + const context = useThreadSurfaceContext(ref); + const canOperatePreview = useEnvironmentScope(threadRef.environmentId, AuthPreviewOperateScope); + const sessionStatus = useEnvironmentSessionStatus(threadRef.environmentId); + const canOperateTerminal = useEnvironmentScope(threadRef.environmentId, AuthTerminalOperateScope); + const serverBrowser = useEnvironmentSupportsServerBrowser(threadRef.environmentId); + const openPreview = useAtomCommand(previewEnvironment.open, { reportFailure: false }); + const openTerminal = useAtomCommand(terminalEnvironment.open, "terminal open"); + const allocateTerminalId = useTerminalIdAllocator(ref); + const writeTerminal = useAtomCommand(terminalEnvironment.write, "terminal write"); + const canOperateThread = useEnvironmentScope( + threadRef.environmentId, + AuthOrchestrationOperateScope, + ); + const projection = useQuietThreadProjection(threadRef)?.projection; + const sideChat = useSideChat({ + threadRef: + canOperateThread && + context.serverThread !== null && + context.serverThread.lineage.sideChat !== true && + context.serverThread.lineage.relationshipToParent !== "subagent" && + context.serverThread.delegatedFrom === null && + !orchestrationV2WorktreePending(context.worktreePath, projection?.runs ?? []) && + context.serverConfig?.environment.capabilities.sideChats === true + ? threadRef + : null, + // No composer here, so the side chat takes the parent's saved mode and model. + readSettings: () => ({ + runtimeMode: context.serverThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE, + modelSelection: context.serverThread?.modelSelection, + }), + onError: (description) => + toastManager.add({ type: "error", title: "Could not open side chat", description }), + }); + const serverConfigLoaded = context.serverConfig !== null; + const shell = context.serverThread; + const threadPullRequestTarget = + shell && serverConfigLoaded + ? threadPullRequestPanelTarget({ + projectId: shell.projectId, + pullRequests: shell.pullRequests, + linkedPullRequest: shell.linkedPullRequest, + branchPullRequest: shell.branchPullRequest, + }) + : null; + // A threadless ref's pull request is the one for its project checkout's branch. + const projectStatus = useEnvironmentQuery( + context.threadless && context.gitCwd !== null && serverConfigLoaded + ? vcsEnvironment.status({ + environmentId: threadRef.environmentId, + input: { cwd: context.gitCwd }, + }) + : null, + ); + const pullRequestTarget = + threadPullRequestTarget ?? + projectPullRequestTarget( + context.project, + projectStatus.data?.pr?.url ?? null, + serverConfigLoaded, + ); + const linkedCount = visibleThreadPullRequests(shell?.pullRequests ?? []).length; + // Why the browser cannot open here: each case its own reason. + const browserUnavailableReason = + sessionStatus === "Initial" + ? "Connecting to the environment." + : sessionStatus === "Failure" + ? "Can't reach the environment." + : !canOperatePreview + ? "Ask the environment owner for preview access." + : isPreviewSupportedInRuntime() || serverBrowser + ? null + : "Only available in the desktop app."; + const available: Record = { + browser: browserUnavailableReason === null, + terminal: context.gitCwd !== null && canOperateTerminal, + files: context.workspaceRoot !== null, + // Outside a Git repository it opens on its empty state. + diff: (context.isServerThread || context.threadless) && context.gitCwd !== null, + "pull-request": pullRequestTarget !== null, + "pull-requests": context.isServerThread && serverConfigLoaded && linkedCount > 0, + device: true, + "side-chat": sideChat.available, + }; + const open = (kind: LauncherSurfaceKind, browserProfileId?: string) => { + if (!available[kind]) return; + const store = useRightPanelStore.getState(); + switch (kind) { + case "side-chat": + void sideChat.open(); + return; + case "diff": + // A project checkout opens on its uncommitted changes; a thread on its branch's. + useDiffPanelStore + .getState() + .selectGitScope(threadRef, context.threadless ? "unstaged" : "branch"); + store.open(threadRef, "diff"); + return; + case "files": + store.open(threadRef, "files"); + return; + case "pull-requests": + store.open(threadRef, "pull-requests"); + return; + case "device": + store.open(threadRef, "device"); + return; + case "pull-request": + if (pullRequestTarget) store.openPullRequest(threadRef, pullRequestTarget); + return; + case "browser": + void addBrowserSurface({ + threadRef, + openPreview, + ...(browserProfileId === undefined ? {} : { profileId: browserProfileId }), + }).then((result) => { + if (result._tag !== "Failure" || isAtomCommandInterrupted(result)) return; + const error = squashAtomCommandFailure(result); + if (isBrowserSettingsReadError(error)) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Unable to open browser", + description: error.message, + }), + ); + } + }); + return; + case "terminal": { + if (!readEnvironmentScope(threadRef.environmentId, AuthTerminalOperateScope)) return; + const terminalId = allocateTerminalId(); + const input = terminalOpenInput(threadRef, terminalId, context); + if (input === null) return; + store.openTerminal(threadRef, terminalId); + focusTerminal(threadRef, terminalId); + void openTerminal({ environmentId: threadRef.environmentId, input }); + return; + } + } + }; + const runShellCommand = (command: string) => { + if (!readEnvironmentScope(threadRef.environmentId, AuthTerminalOperateScope)) return; + const terminalId = allocateTerminalId(); + const input = terminalOpenInput(threadRef, terminalId, context); + if (input === null) return; + useRightPanelStore.getState().openTerminal(threadRef, terminalId); + focusTerminal(threadRef, terminalId); + void openTerminal({ environmentId: threadRef.environmentId, input }).then((result) => { + if (result._tag !== "Success") return; + return writeTerminal({ + environmentId: threadRef.environmentId, + input: { + threadId: threadRef.threadId, + terminalId, + data: `${command}\n`, + }, + }); + }); + }; + return { available, browserUnavailableReason, open, runShellCommand }; +} + +/** A new shell in a surface's folder: its checkout, or the server's home. */ +function terminalOpenInput( + threadRef: ScopedThreadRef, + terminalId: string, + context: ReturnType, +) { + const { gitCwd, project, worktreePath } = context; + if (gitCwd === null) return null; + return { + threadId: threadRef.threadId, + terminalId, + cwd: gitCwd, + ...(worktreePath != null ? { worktreePath } : {}), + ...(project + ? { env: projectScriptRuntimeEnv({ project: { cwd: project.workspaceRoot }, worktreePath }) } + : {}), + }; +} + +/** + * A project checkout's branch pull request as a surface target, the way a + * link to it opens one; null when its URL is not this project's repository. + */ +function projectPullRequestTarget( + project: EnvironmentProject | null, + url: string | null, + threadPullRequests: boolean, +) { + if (!project || url === null) return null; + const link = sourceControlClients.parseChangeRequestUrl(url)?.link; + if (!link || !findProjectForChangeRequest([project], link)) return null; + return { + projectId: project.id, + ...(threadPullRequests ? { host: link.authority ?? link.host } : {}), + repository: threadPullRequests + ? link.repository + : (sourceControlClients.repositorySelector(project.repositoryIdentity) ?? link.repository), + number: link.number, + url, + }; +} + +/** A terminal id the thread has not used, as ChatView picks them. */ +function useTerminalIdAllocator(threadRef: ScopedThreadRef): () => string { + const known = useKnownTerminalSessions({ + environmentId: threadRef.environmentId, + threadId: threadRef.threadId, + }); + const drawerIds = useTerminalUiStateStore( + (state) => selectThreadTerminalUiState(state.terminalUiStateByThreadKey, threadRef).terminalIds, + ); + const panelSurfaces = useRightPanelStore( + (state) => selectThreadRightPanelState(state.byThreadKey, threadRef).surfaces, + ); + return () => { + const ids = new Set(drawerIds); + for (const session of known ?? []) { + if (session.target.threadId === threadRef.threadId) ids.add(session.target.terminalId); + } + for (const surface of panelSurfaces) { + if (surface.kind === "terminal") for (const id of surface.terminalIds) ids.add(id); + } + const canReuse = + known !== null && readEnvironmentScope(threadRef.environmentId, AuthTerminalReadScope); + return nextTerminalId([...ids], canReuse ? undefined : randomUUID()); + }; +} + +/** The terminal keys' labels for its tooltips (new, close), as a focused terminal hears them. */ +function useTerminalShortcutLabels() { + const keybindings = useAtomValue(primaryServerKeybindingsAtom); + const options = { context: { terminalFocus: true, terminalOpen: true } }; + const label = (command: "terminal.new" | "terminal.close") => + shortcutLabelForCommand(keybindings, command, options) ?? undefined; + return { + newShortcutLabel: label("terminal.new"), + closeShortcutLabel: label("terminal.close"), + }; +} + +function useTerminalLabels(threadRef: ScopedThreadRef): ReadonlyMap { + const known = useKnownTerminalSessions({ + environmentId: threadRef.environmentId, + threadId: threadRef.threadId, + }); + const labels = new Map(); + for (const session of known ?? []) { + if (session.target.threadId !== threadRef.threadId) continue; + labels.set( + session.target.terminalId, + resolveTerminalSessionLabel(session.target.terminalId, session.state.summary), + ); + } + return labels; +} + +/** A surface tab: the thread's terminal, or one of its `rightPanelStore` surfaces. */ +export function ThreadSurfaceTab({ + threadRef, + surfaceId, +}: { + threadRef: ScopedThreadRef; + surfaceId: string; +}) { + if (surfaceId === THREAD_TERMINAL_SURFACE_ID) return ; + return ; +} + +function ThreadTerminalTab({ threadRef }: { threadRef: ScopedThreadRef }) { + const keybindings = useAtomValue(primaryServerKeybindingsAtom); + const bridge = useThreadChatBridge(threadRef); + const shortcutLabels = useTerminalShortcutLabels(); + const { paneId } = useLayoutPane(); + return ( + addTerminalContextFromChat(threadRef, paneId, selection) + } + paneMode + /> + ); +} + +/** The linked thread stays discoverable in a surface tab's tooltip. */ +function useThreadTitle(threadRef: ScopedThreadRef): string | null { + const shell = useQuietThreadShell(threadRef); + const draft = useComposerDraftStore((store) => store.getDraftThreadByRef(threadRef)); + return shell?.title ?? (draft ? "New thread" : null); +} + +function ThreadSurfacePane({ + threadRef, + surfaceId, +}: { + threadRef: ScopedThreadRef; + surfaceId: string; +}) { + const surface = useRightPanelStore( + (state) => + selectThreadRightPanelState(state.byThreadKey, threadRef).surfaces.find( + (candidate) => candidate.id === surfaceId, + ) ?? null, + ); + const keybindings = useAtomValue(primaryServerKeybindingsAtom); + const context = useThreadSurfaceContext(threadRef); + const openers = useThreadSurfaceOpeners(threadRef); + const previewState = useThreadPreviewState(threadRef); + const terminalLabels = useTerminalLabels(threadRef); + const threadTitle = useThreadTitle(threadRef); + const { visible, paneId } = useLayoutPane(); + const handleNewThread = useNewThreadHandler(); + const { projectRef } = context; + const add = (kind: LauncherSurfaceKind, browserProfileId?: string) => { + if (paneId !== null && openers.available[kind]) { + requestLayoutSurfacePlacement(threadRef, { kind: "pane", paneId }); + } + openers.open(kind, browserProfileId); + }; + const getShortcutContext = () => ({ + terminalFocus: false, + terminalOpen: false, + previewFocus: false, + previewOpen: surface?.kind === "preview", + isWeb: !isElectron, + isDesktop: isElectron, + }); + const resolveRuntimeTabId = (tabId: string) => + previewRuntimeTabId(threadRef, previewState.serverEpoch, tabId); + // A browser tab shown makes its preview the thread's active one. + const previewResourceId = surface?.kind === "preview" ? surface.resourceId : null; + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Keep the external preview store active tab aligned with the visible layout surface. + useEffect(() => { + if (visible && previewResourceId) setActivePreviewTab(threadRef, previewResourceId); + }, [previewResourceId, threadRef, visible]); + if (!surface) return null; + const close = () => void closeThreadSurface(threadRef, surface.id); + return ( + { + // Choosing a side chat's tab puts the keyboard in its message box, even when it shows. + if (active.kind === "side-chat") requestSideChatComposerFocus(threadRef, active.id); + }} + onCloseSurface={close} + onRenameDevice={(id, title) => + useRightPanelStore.getState().renameDevice(threadRef, id, title) + } + onCloseOtherSurfaces={noop} + onCloseSurfacesToRight={noop} + onCloseAllSurfaces={noop} + onCopyFilePath={(path) => copyLayoutFilePath(threadRef, path)} + onAddBrowser={() => add("browser")} + onAddBrowserInProfile={(profileId) => add("browser", profileId)} + onNewThread={ + projectRef === null + ? undefined + : () => void runInNewLayoutTab(() => handleNewThread(projectRef)) + } + onAddTerminal={() => add("terminal")} + onAddDiff={() => add("diff")} + onAddFiles={() => add("files")} + onAddPullRequest={() => add("pull-request")} + onAddPullRequests={() => add("pull-requests")} + onAddDevice={() => add("device")} + onAddSideChat={() => add("side-chat")} + sideChatAvailable={openers.available["side-chat"]} + browserAvailable={openers.available.browser} + browserUnavailableReason={openers.browserUnavailableReason} + terminalAvailable={openers.available.terminal} + diffAvailable={openers.available.diff} + filesAvailable={openers.available.files} + pullRequestAvailable={openers.available["pull-request"]} + pullRequestsAvailable={openers.available["pull-requests"]} + deviceAvailable={openers.available.device} + > + {context.gone ? ( + + ) : ( + + )} + + ); +} + +function ThreadGoneState({ threadless, onClose }: { threadless: boolean; onClose: () => void }) { + return ( + + + + + + {threadless ? "Its project is gone" : "Its thread is gone"} + + {threadless + ? "The project this was opened in no longer exists." + : "The thread this was opened from no longer exists."} + +
+ +
+
+
+ ); +} + +function ThreadSurfaceContent({ + threadRef, + surface, + context, + visible, + openers, + openInPane, + getShortcutContext, +}: { + threadRef: ScopedThreadRef; + surface: RightPanelSurface; + context: ReturnType; + visible: boolean; + openers: ReturnType; + openInPane: (kind: LauncherSurfaceKind) => void; + getShortcutContext: () => { + terminalFocus: boolean; + terminalOpen: boolean; + previewFocus: boolean; + previewOpen: boolean; + isWeb: boolean; + isDesktop: boolean; + }; +}) { + const bridge = useThreadChatBridge(threadRef); + const { paneId } = useLayoutPane(); + const keybindings = useAtomValue(primaryServerKeybindingsAtom); + const availableEditors = + useAtomValue(serverEnvironment.configValueAtom(threadRef.environmentId))?.availableEditors ?? + []; + const workspaceMutationId = bridge?.workspaceMutationId ?? null; + const [pendingFiles, setPendingFiles] = useState>(EMPTY_PENDING); + const onPendingChange = (relativePath: string, pending: boolean) => { + setPendingFiles((current) => { + const id = `file:${relativePath}`; + if (current.has(id) === pending) return current; + const next = new Set(current); + if (pending) next.add(id); + else next.delete(id); + return next; + }); + }; + const configuredUrls = getConfiguredPreviewUrls(context.projectScripts); + const terminal = useTerminalSurfaceActions(threadRef, surface, context); + const shortcutLabels = useTerminalShortcutLabels(); + const shell = context.serverThread; + const sideChatParentPanel: SideChatParentPanel = { + openToolDiff: (target) => { + useDiffPanelStore.getState().selectToolCall(threadRef, target); + useRightPanelStore.getState().open(threadRef, "diff"); + }, + runShellCommand: openers.runShellCommand, + }; + + switch (surface.kind) { + case "side-chat": + return ( + + void closeThreadSurface(threadRef, surface.id)} + /> + + ); + case "preview": + return ( + + void sendAnnotationFromChat(threadRef, paneId, annotation, image), + })} + /> + + ); + case "terminal": + return ( + addTerminalContextFromChat(threadRef, paneId, selection) + } + onSplitTerminal={terminal.split} + onSplitTerminalVertical={terminal.splitVertical} + onNewTerminal={() => openInPane("terminal")} + onActiveTerminalChange={terminal.activate} + onCloseTerminal={terminal.close} + {...shortcutLabels} + /> + ); + case "diff": + return ( + + + + ); + case "pull-request": + if (!context.serverConfig) return ; + return ( + }> + { + useRightPanelStore + .getState() + .settlePullRequestReveal(threadRef, surface.id, revealUrl); + }} + onTabChange={(tab) => + useRightPanelStore.getState().setPullRequestDetailTab(threadRef, surface.id, tab) + } + getShortcutContext={getShortcutContext} + shortcutsEnabled={visible} + environmentId={threadRef.environmentId} + onSelectPullRequest={(reference) => + useRightPanelStore.getState().openPullRequest(threadRef, { + projectId: reference.projectId, + repository: reference.repository, + number: reference.number, + ...(reference.host ? { host: reference.host } : {}), + }) + } + threadRef={context.threadless ? null : threadRef} + reference={{ + projectId: surface.projectId as ProjectId, + ...(surface.host ? { host: surface.host } : {}), + repository: surface.repository, + number: surface.number, + }} + context={pullRequestPanelContext( + { + projectId: shell?.projectId ?? context.project?.id ?? null, + pullRequests: visibleThreadPullRequests(shell?.pullRequests ?? []), + linkedPullRequest: shell?.linkedPullRequest ?? null, + branchPullRequest: shell?.branchPullRequest ?? null, + }, + surface, + )} + // Threadless, its hand-offs start a thread, as the pull requests page's do. + {...(context.composerDraftTarget + ? { composerDraftTarget: context.composerDraftTarget } + : {})} + onBack={ + openers.available["pull-requests"] && + visibleThreadPullRequests(shell?.pullRequests ?? []).length > 1 + ? () => openInPane("pull-requests") + : undefined + } + /> + + ); + case "pull-requests": + return ; + case "device": + return ( + + void closeThreadSurface(threadRef, surface.id)} + /> + + ); + case "files": + case "file": { + const attachment = surface.kind === "file" ? surface.attachment : undefined; + if (!context.workspaceRoot && !attachment) { + return ( + } + title="No project folder" + description="This thread has no workspace folder, so there are no files to show." + /> + ); + } + return ( + + + useRightPanelStore.getState().openFile(threadRef, relativePath) + } + onNavigateFolder={(relativePath, folderTrail, keepSource) => + useRightPanelStore + .getState() + .navigateFolder(threadRef, surface.id, relativePath, folderTrail, keepSource) + } + folderTrail={surface.folderTrail} + onPendingChange={onPendingChange} + selectedFilePending={surface.kind === "file" && pendingFiles.has(surface.id)} + workspaceMutationId={workspaceMutationId} + /> + + ); + } + } +} + +async function addTerminalContextFromChat( + threadRef: ScopedThreadRef, + paneId: string | null, + selection: TerminalContextSelection, +): Promise { + let bridge = useThreadChatBridgeStore.getState().byThreadKey[scopedThreadKey(threadRef)] ?? null; + if (!bridge) { + showThreadChatBeside(threadRef, paneId); + bridge = await waitForThreadChatSendReady(threadRef, 15_000); + } + if (bridge) { + bridge.addTerminalContext(selection); + return true; + } + toastManager.add({ + type: "error", + title: "Could not add terminal output to chat", + description: "The chat is not ready. Your terminal selection is still available to add again.", + }); + return false; +} + +/** + * Send a browser annotation the way the chat sends anything: from its + * composer, which already holds the annotation. A closed chat opens beside + * the browser first and sends once it can. + */ +async function sendAnnotationFromChat( + threadRef: ScopedThreadRef, + paneId: string | null, + annotation: PreviewAnnotationPayload, + image: ComposerImageAttachment | null, +): Promise { + const open = useThreadChatBridgeStore.getState().byThreadKey[scopedThreadKey(threadRef)]; + if (open) { + open.sendAnnotation(annotation, image); + return; + } + showThreadChatBeside(threadRef, paneId); + const bridge = await waitForThreadChatSendReady(threadRef, 15_000); + if (bridge) { + bridge.sendAnnotation(annotation, image); + return; + } + toastManager.add( + stackedThreadToast({ + type: "info", + title: "Annotation attached to draft", + description: "The chat could not send it yet. Send it from the chat.", + }), + ); +} + +/** A terminal surface's own split, switch and close, as ChatView's panel had them. */ +function useTerminalSurfaceActions( + threadRef: ScopedThreadRef, + surface: RightPanelSurface, + context: ReturnType, +) { + const openTerminal = useAtomCommand(terminalEnvironment.open, "terminal open"); + const closeTerminalMutation = useAtomCommand(terminalEnvironment.close, "terminal close"); + const writeTerminal = useAtomCommand(terminalEnvironment.write, "terminal write"); + const storeCloseTerminal = useTerminalUiStateStore((state) => state.closeTerminal); + const allocateTerminalId = useTerminalIdAllocator(threadRef); + const canWrite = () => readEnvironmentScope(threadRef.environmentId, AuthTerminalOperateScope); + const split = (direction: "horizontal" | "vertical" = "horizontal") => { + if ( + !canWrite() || + surface.kind !== "terminal" || + surface.terminalIds.length >= MAX_TERMINALS_PER_GROUP + ) { + return; + } + const terminalId = allocateTerminalId(); + const input = terminalOpenInput(threadRef, terminalId, context); + if (input === null) return; + useRightPanelStore.getState().splitTerminal(threadRef, surface.id, terminalId, direction); + focusTerminal(threadRef, terminalId); + void openTerminal({ environmentId: threadRef.environmentId, input }); + }; + return { + split: () => split("horizontal"), + splitVertical: () => split("vertical"), + activate: (terminalId: string) => { + if (surface.kind !== "terminal") return; + useRightPanelStore.getState().activateTerminal(threadRef, surface.id, terminalId); + focusTerminal(threadRef, terminalId); + }, + close: (terminalId: string) => { + if (!canWrite() || surface.kind !== "terminal") return; + void closeTerminalMutation({ + environmentId: threadRef.environmentId, + input: { threadId: threadRef.threadId, terminalId, deleteHistory: true }, + }).then((result) => { + if ( + result._tag === "Failure" && + !isAtomCommandInterrupted(result) && + readEnvironmentScope(threadRef.environmentId, AuthTerminalOperateScope) + ) { + return writeTerminal({ + environmentId: threadRef.environmentId, + input: { threadId: threadRef.threadId, terminalId, data: "exit\n" }, + }); + } + }); + storeCloseTerminal(threadRef, terminalId); + useRightPanelStore.getState().closeTerminal(threadRef, surface.id, terminalId); + focusActivePanelTerminal(threadRef); + }, + }; +} + +// Closing + +type SurfaceCloser = (threadRef: ScopedThreadRef, surfaces: readonly RightPanelSurface[]) => void; +/** Registered by `ThreadSurfaceServices`: ends what closed surfaces ran. */ +let surfaceServices: { + cleanup: SurfaceCloser; + endTerminals: (threadRef: ScopedThreadRef, terminalIds: readonly string[]) => void; +} | null = null; + +function readTerminalTabTitles( + threadRef: ScopedThreadRef, + terminalIds: readonly string[], +): string[] { + const sessions = readEnvironmentScope(threadRef.environmentId, AuthTerminalReadScope) + ? appAtomRegistry.get(knownTerminalSessionsAtom(threadRef.environmentId, threadRef.threadId)) + : null; + return terminalIds.map((terminalId) => + terminalTabTitle( + terminalId, + resolveTerminalSessionLabel( + terminalId, + sessions?.find((session) => session.target.terminalId === terminalId)?.state.summary, + ), + ), + ); +} + +/** Closing a surface ends its resources; cmd+J preserves the thread terminal's shells. */ +export async function closeThreadSurface( + threadRef: ScopedThreadRef, + surfaceId: string, + options: { bulk?: boolean } = {}, +): Promise { + if (surfaceId === THREAD_TERMINAL_SURFACE_ID) { + if (readEnvironmentScope(threadRef.environmentId, AuthTerminalOperateScope)) { + const { terminalIds } = selectThreadTerminalUiState( + useTerminalUiStateStore.getState().terminalUiStateByThreadKey, + threadRef, + ); + const [first, ...rest] = readTerminalTabTitles(threadRef, terminalIds); + if (first !== undefined) { + if (!options.bulk && !(await confirmTerminalClose([first, ...rest]))) return; + surfaceServices?.endTerminals(threadRef, terminalIds); + } + } + useTerminalUiStateStore.getState().setTerminalOpen(threadRef, false); + return; + } + const surface = selectThreadRightPanelState( + useRightPanelStore.getState().byThreadKey, + threadRef, + ).surfaces.find((candidate) => candidate.id === surfaceId); + if (!surface) return; + const finish = () => { + surfaceServices?.cleanup(threadRef, [surface]); + useRightPanelStore.getState().closeSurface(threadRef, surface.id); + }; + if (surface.kind === "preview") { + const message = agentControlledBrowserCloseConfirmation( + [surface], + readThreadPreviewState(threadRef).desktopByTabId, + ); + if (message) { + const confirmed = await readLocalApi() + ?.dialogs.confirm(message, { variant: "destructive" }) + .catch(() => false); + if (!confirmed) return; + } + finish(); + return; + } + if ( + surface.kind === "terminal" && + !options.bulk && + readEnvironmentScope(threadRef.environmentId, AuthTerminalOperateScope) + ) { + const [first = "Terminal", ...rest] = readTerminalTabTitles(threadRef, surface.terminalIds); + if (!(await confirmTerminalClose([first, ...rest]))) return; + } + finish(); +} + +/** + * Take a thread's surface tabs off the layout, its terminal tab included. + * Nothing they run ends; alt+cmd+B and cmd+J bring them back. + */ +export function hideThreadSurfaceTabs(threadRef: ScopedThreadRef): void { + useRightPanelStore.getState().close(threadRef); + useTerminalUiStateStore.getState().setTerminalOpen(threadRef, false); +} + +/** + * Close every surface of a thread without asking, because the thread itself is + * being discarded: browsers and terminals end, and its terminal tab hides. + */ +export function discardThreadSurfaces(threadRef: ScopedThreadRef): void { + const { surfaces } = selectThreadRightPanelState( + useRightPanelStore.getState().byThreadKey, + threadRef, + ); + surfaceServices?.cleanup(threadRef, surfaces); + useRightPanelStore.getState().closeAllSurfaces(threadRef); + useTerminalUiStateStore.getState().setTerminalOpen(threadRef, false); +} + +/** + * Hide a thread's surfaces (alt+cmd+B): a live browser or device on screen + * floats in its mini player instead of disappearing. + */ +export function hideThreadSurfaces(threadRef: ScopedThreadRef): void { + const state = selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, threadRef); + const active = state.surfaces.find((surface) => surface.id === state.activeSurfaceId); + if (active?.kind === "preview" && active.resourceId) { + usePreviewMiniPlayerStore + .getState() + .open(threadRef, browserMiniPlayerSource(active.resourceId)); + } else if (active?.kind === "device" && active.target) { + usePreviewMiniPlayerStore.getState().open(threadRef, { kind: "device", ...active.target }); + } + useRightPanelStore.getState().close(threadRef); +} + +/** + * Mounted once with the layout host: ends what closed surfaces ran (preview + * sessions, terminal sessions) through this client's connections. + */ +export function ThreadSurfaceServices() { + const deleteThread = useAtomCommand(threadEnvironment.delete, { reportFailure: false }); + const closePreview = useAtomCommand(previewEnvironment.close, "preview close"); + const closeTerminalMutation = useAtomCommand(terminalEnvironment.close, "terminal close"); + const writeTerminal = useAtomCommand(terminalEnvironment.write, "terminal write"); + const storeCloseTerminal = useTerminalUiStateStore((state) => state.closeTerminal); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Register mounted surface resource closers with the external lifecycle registry. + useEffect(() => { + const end = (threadRef: ScopedThreadRef, terminalIds: readonly string[]) => { + if (!readEnvironmentScope(threadRef.environmentId, AuthTerminalOperateScope)) return; + for (const terminalId of terminalIds) { + storeCloseTerminal(threadRef, terminalId); + void closeTerminalMutation({ + environmentId: threadRef.environmentId, + input: { threadId: threadRef.threadId, terminalId, deleteHistory: true }, + }).then((result) => { + if ( + result._tag === "Failure" && + !isAtomCommandInterrupted(result) && + readEnvironmentScope(threadRef.environmentId, AuthTerminalOperateScope) + ) { + return writeTerminal({ + environmentId: threadRef.environmentId, + input: { threadId: threadRef.threadId, terminalId, data: "exit\n" }, + }); + } + }); + } + }; + const closer: SurfaceCloser = (threadRef, surfaces) => { + for (const surface of surfaces) { + if ( + surface.kind === "side-chat" && + readEnvironmentScope(threadRef.environmentId, AuthOrchestrationOperateScope) + ) { + void deleteThread({ + environmentId: threadRef.environmentId, + input: { threadId: surface.threadId, onlyIfUnused: true }, + }); + } + if ( + surface.kind === "preview" && + surface.resourceId && + readEnvironmentScope(threadRef.environmentId, AuthPreviewOperateScope) + ) { + void closePreviewSession({ + closePreview, + snapshot: readThreadPreviewState(threadRef).sessions[surface.resourceId] ?? null, + tabId: surface.resourceId, + threadRef, + }); + } + if ( + surface.kind === "terminal" && + readEnvironmentScope(threadRef.environmentId, AuthTerminalOperateScope) + ) { + end(threadRef, surface.terminalIds); + } + } + }; + const services = { cleanup: closer, endTerminals: end }; + surfaceServices = services; + return () => { + if (surfaceServices === services) surfaceServices = null; + }; + }, [closePreview, closeTerminalMutation, storeCloseTerminal, writeTerminal, deleteThread]); + return null; +} diff --git a/apps/web/src/components/ThreadTerminalDrawer.browser.test.tsx b/apps/web/src/components/ThreadTerminalDrawer.browser.test.tsx index 739136162d31f..22450ea071d1e 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.browser.test.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.browser.test.tsx @@ -17,6 +17,7 @@ import { extractTerminalLinks } from "../terminal-links"; import { expect, it, vi } from "vite-plus/test"; import { act, fakeTimers, render } from "../../test/render"; import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; +import type { TerminalContextSelection } from "../lib/terminalContext"; const fixture = vi.hoisted(() => ({ statusByThreadId: new Map(), @@ -40,6 +41,10 @@ const fixture = vi.hoisted(() => ({ string, Atom.Writable> >(), + contextMenu: undefined as ((event: MouseEvent) => void) | undefined, + showContextMenu: vi.fn(), + selection: "", + clearSelection: vi.fn(), })); // Every other surface method is a no-op; `then` stays undefined so awaiting the surface resolves to it. @@ -51,6 +56,14 @@ const fakeSurface: object = new Proxy( const size = fixture.reportedSize; if (size) fixture.surfaceOptions.at(-1)!.onResize(size.cols, size.rows); }, + hasSelection: () => fixture.selection.length > 0, + getSelection: () => fixture.selection, + getSelectionPosition: () => ({ start: { y: 0 }, end: { y: 1 } }), + getSelectionEndClientRect: () => null, + clearSelection: () => { + fixture.clearSelection(); + fixture.selection = ""; + }, }, { get: (target, key) => @@ -62,6 +75,7 @@ vi.mock("~/terminal/ghostty/surface", () => ({ create: (_mount: HTMLElement, options: GhosttyTerminalSurfaceOptions) => new Promise((resolve) => { fixture.surfaceOptions.push(options); + fixture.contextMenu = options.onContextMenu; fixture.pendingSurfaces.push(() => { fixture.typeIntoSurface = options.onData; fixture.pressKey = options.beforeKey ?? null; @@ -122,7 +136,11 @@ vi.mock("../state/use-atom-command", () => ({ vi.mock("../state/server", async () => { const Atom = await import("effect/reactivity/Atom"); const config = Atom.make(null); - return { serverEnvironment: { configValueAtom: () => config } }; + return { + serverEnvironment: { configValueAtom: () => config }, + // The pane notch reads the theme (black chrome keeps the notch), which reads server themes. + themeServerEnvironmentThemesAtom: Atom.make([]), + }; }); vi.mock("../state/terminal", () => ({ terminalEnvironment: { write: {}, resize: fixture.resizeCommand }, @@ -152,9 +170,16 @@ vi.mock("../state/projects", async () => { }, }; }); +vi.mock("../layout/PaneTabs", () => ({ + LayoutPaneTab: () => null, + LayoutPaneTabStrip: ({ children }: { children: React.ReactNode }) => children, + useLayoutPaneSiblingTabs: () => ({ before: [], after: [] }), +})); vi.mock("../editorPreferences", () => ({ useOpenInPreferredEditor: () => () => {} })); vi.mock("./preview/openTerminalLinkInPreview", () => ({ openTerminalLinkInPreview: () => {} })); -vi.mock("~/localApi", () => ({ readLocalApi: () => null })); +vi.mock("~/localApi", () => ({ + readLocalApi: () => ({ contextMenu: { show: fixture.showContextMenu } }), +})); vi.mock("../hooks/useSettings", async () => { const { DEFAULT_CLIENT_SETTINGS } = await import("@t3tools/contracts/settings"); return { @@ -168,6 +193,7 @@ function Drawer(props: { cwd?: string; onCloseTerminal: (terminalId: string, threadRef?: ScopedThreadRef) => void; keybindings?: ResolvedKeybindingsConfig; + onAddTerminalContext?: (selection: TerminalContextSelection) => Promise; }) { return ( {}} onCloseTerminal={props.onCloseTerminal} onHeightChange={() => {}} - onAddTerminalContext={() => {}} + onAddTerminalContext={props.onAddTerminalContext} keybindings={props.keybindings ?? []} /> ); @@ -215,6 +241,10 @@ function setup(originalStatus: "starting" | "exited" = "exited") { fixture.metadata.clear(); useRightPanelStore.setState(useRightPanelStore.getInitialState()); useTerminalUiStateStore.setState(useTerminalUiStateStore.getInitialState()); + fixture.contextMenu = undefined; + fixture.showContextMenu.mockReset().mockResolvedValue("add-to-chat"); + fixture.selection = "selected output"; + fixture.clearSelection.mockReset(); return { original, other }; } @@ -412,3 +442,29 @@ it("resends a failed resize once in each writable session", async () => { }; expect(resizes).toEqual([resize, resize, resize]); }); + +it.each([false, true])("clears selected output only after chat accepts it: %s", async (added) => { + const { other } = setup(); + let accept: (added: boolean) => void = () => {}; + const onAddTerminalContext = vi.fn( + () => + new Promise((resolve) => { + accept = resolve; + }), + ); + await using _screen = await render( + {}} + onAddTerminalContext={onAddTerminalContext} + />, + ); + await act(() => fixture.pendingSurfaces.shift()?.()); + await act(() => fixture.contextMenu?.(new MouseEvent("contextmenu", { cancelable: true }))); + expect(onAddTerminalContext).toHaveBeenCalledWith( + expect.objectContaining({ text: "selected output" }), + ); + expect(fixture.clearSelection).not.toHaveBeenCalled(); + await act(() => accept(added)); + expect(fixture.clearSelection).toHaveBeenCalledTimes(added ? 1 : 0); +}); diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index 6631054a46a25..5506d1fe3f4cb 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -13,13 +13,8 @@ import { type TerminalOutputUpdate, type TerminalSessionState, } from "@t3tools/client-runtime/state/terminal"; -import { - Plus, - SquareSplitHorizontal, - SquareSplitVertical, - SquareTerminal, - Trash2, -} from "lucide-react"; +import { Plus, SquareTerminal } from "lucide-react"; +import { scopedThreadKey } from "@t3tools/client-runtime/environment"; import { AuthPreviewOperateScope, AuthFilesystemReadScope, @@ -32,11 +27,14 @@ import { type ThreadId, } from "@t3tools/contracts"; import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; +import { terminalTabTitle } from "../layout/surfaceTabLabels"; import * as Schema from "effect/Schema"; import { AsyncResult } from "effect/reactivity"; import { + type KeyboardEvent as ReactKeyboardEvent, type PointerEvent as ReactPointerEvent, type ReactNode, + type RefObject, useEffect, useContext, useEffectEvent, @@ -47,11 +45,17 @@ import { import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover"; import { Button } from "~/components/ui/button"; import { PanelTabCloseButton } from "~/components/ui/panel-tab-close-button"; +import { PaneNotch } from "~/components/PaneNotch"; +import { beginLayoutTabDrag } from "~/layout/layoutDrag"; +import { useLayoutStore } from "~/layout/layoutStore"; +import { useLayoutPane, useLayoutPaneFocusedRef } from "~/layout/layoutPaneContext"; +import { LayoutPaneTab, LayoutPaneTabStrip, useLayoutPaneSiblingTabs } from "~/layout/PaneTabs"; import { ScrollArea } from "~/components/ui/scroll-area"; import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; import { stackedThreadToast, toastManager } from "~/components/ui/toast"; import { readTextFromClipboard, writeTextToClipboard } from "~/hooks/useCopyToClipboard"; import { cn } from "~/lib/utils"; +import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar"; import { type TerminalContextSelection } from "~/lib/terminalContext"; import { GhosttyTerminalSurface, @@ -59,6 +63,7 @@ import { } from "~/terminal/ghostty/surface"; import { type GhosttyColor, type GhosttyTheme } from "~/terminal/ghostty/core"; import { useOpenInPreferredEditor } from "../editorPreferences"; +import { useStableCallback } from "../hooks/useStableCallback"; import { isTerminalUrl, resolveTerminalLinkTarget } from "../terminal-links"; import { isDiffToggleShortcut, @@ -68,6 +73,7 @@ import { isTerminalSplitVerticalShortcut, isTerminalToggleShortcut, isThreadNavigationShortcut, + resolveShortcutCommand, terminalDeleteShortcutData, terminalNavigationShortcutData, } from "../keybindings"; @@ -77,13 +83,15 @@ import { type ThreadTerminalGroup, } from "../types"; import { readLocalApi } from "~/localApi"; -import { confirmTerminalClose } from "~/lib/terminalCloseConfirm"; +import { confirmTerminalClose, isTerminalCloseConfirmPending } from "~/lib/terminalCloseConfirm"; +import { getTerminalFocusOwner, registerTerminalSplitTarget } from "~/lib/terminalFocus"; import { useClientSettings } from "../hooks/useSettings"; import { getFileMetadataQueryAtom } from "../hooks/useFileMetadata"; import { FileMetadataThreadContext } from "../hooks/FileMetadataThreadProvider"; import { useLocalStorage } from "../hooks/useLocalStorage"; import { attachedTerminalSessionAtom } from "../state/terminalSessions"; import { appAtomRegistry } from "../rpc/atomRegistry"; +import { useRevealActiveTab } from "../hooks/useRevealActiveTab"; import { serverEnvironment } from "../state/server"; import { previewEnvironment } from "../state/preview"; import { readEnvironmentScope } from "../state/session"; @@ -469,7 +477,9 @@ interface TerminalViewportProps { runtimeEnv?: Record; providerInstanceId?: ProviderInstanceId; onSessionExited: () => void; - onAddTerminalContext?: (selection: TerminalContextSelection) => void; + onAddTerminalContext?: + | ((selection: TerminalContextSelection) => void | Promise) + | undefined; /** Focuses the viewport when it mounts; otherwise focus arrives through `focusTerminal`. */ autoFocus?: boolean; visible: boolean; @@ -514,6 +524,7 @@ function TerminalSessionViewport({ const autoFocusedRef = useRef(false); const terminalRef = useRef(null); const visibleRef = useRef(visible); + const paneFocusedRef = useLayoutPaneFocusedRef(); const environmentId = threadRef.environmentId; const canOperateTerminal = useEnvironmentScope(environmentId, AuthTerminalOperateScope); const hasTerminalWriteAccess = useEffectEvent(() => @@ -583,7 +594,7 @@ function TerminalSessionViewport({ if (hasTerminalWriteAccess()) onSessionExited(); }); const handleAddTerminalContext = useEffectEvent((selection: TerminalContextSelection) => { - onAddTerminalContext?.(selection); + return onAddTerminalContext?.(selection); }); const canAddSelectionToChat = useEffectEvent(() => onAddTerminalContext !== undefined); const readTerminalLabel = useEffectEvent(() => terminalLabel); @@ -827,14 +838,21 @@ function TerminalSessionViewport({ }; }; - const addSelectionToChat = (clipboardText: string) => { + const addSelectionToChat = async (clipboardText: string) => { // Output keeps moving the selected rows while a menu is open, so take // the line numbers now, and only if the same text is still selected. const current = readSelectionAction(); if (current?.clipboardText !== clipboardText) return; - handleAddTerminalContext(current.selection); - terminalRef.current?.clearSelection(); - terminalRef.current?.focus(); + const activeTerminal = terminalRef.current; + const added = await handleAddTerminalContext(current.selection); + if ( + added === false || + terminalRef.current !== activeTerminal || + activeTerminal?.getSelection() !== clipboardText + ) + return; + activeTerminal.clearSelection(); + activeTerminal.focus(); }; const reportIfCurrent = (requestId: number, error: unknown, fallback: string) => { @@ -905,7 +923,7 @@ function TerminalSessionViewport({ switch (clicked) { case "add-to-chat": if (selectionAction && canAddSelectionToChat()) { - addSelectionToChat(selectionAction.clipboardText); + await addSelectionToChat(selectionAction.clipboardText); } return; case "copy": @@ -961,6 +979,9 @@ function TerminalSessionViewport({ ) { return false; } + // Tab switching belongs to the layout, so it bubbles to the app's handler. + const command = resolveShortcutCommand(event, currentKeybindings, options); + if (command === "tab.next" || command === "tab.previous") return false; const navigationData = terminalNavigationShortcutData(event); if (navigationData !== null) { @@ -1109,7 +1130,8 @@ function TerminalSessionViewport({ focusTerminalViewport({ mount, surface: terminalRef.current, - canOperate: readEnvironmentScope(environmentId, AuthTerminalOperateScope), + canOperate: + paneFocusedRef.current && readEnvironmentScope(environmentId, AuthTerminalOperateScope), visible: visibleRef.current, }); // Once per viewport: a re-attached ref (a fresh callback identity) must not @@ -1141,9 +1163,11 @@ function TerminalSessionViewport({ } interface ThreadTerminalDrawerProps { - mode?: "drawer" | "panel"; + /** "pane" fills a layout pane (cmd+J) and still owns the drawer's terminals. */ + mode?: "drawer" | "panel" | "pane"; threadRef: ScopedThreadRef; threadId: ThreadId; + threadTitle?: string | null; cwd: string; worktreePath?: string | null; runtimeEnv?: Record; @@ -1158,65 +1182,21 @@ interface ThreadTerminalDrawerProps { onSplitTerminal: () => void; onSplitTerminalVertical: () => void; onNewTerminal: () => void; - splitShortcutLabel?: string | undefined; - splitVerticalShortcutLabel?: string | undefined; newShortcutLabel?: string | undefined; closeShortcutLabel?: string | undefined; onActiveTerminalChange: (terminalId: string) => void; onCloseTerminal: (terminalId: string, threadRef?: ScopedThreadRef) => void; onHeightChange: (height: number) => void; - onAddTerminalContext: (selection: TerminalContextSelection) => void; + onAddTerminalContext: + | ((selection: TerminalContextSelection) => void | Promise) + | undefined; keybindings: ResolvedKeybindingsConfig; terminalLabelsById?: ReadonlyMap; /** Run status per terminal for the tab's tooltip; missing ids read as an idle shell. */ terminalStatusById?: ReadonlyMap; terminalLaunchLocationsById?: ReadonlyMap; -} - -interface TerminalActionButtonProps { - label: string; - onClick: () => void; - /** Keeps the tooltip reachable (it explains the limit), unlike `disabled`. */ - unavailable?: boolean; - children: ReactNode; - disabled?: boolean; -} - -function TerminalActionButton({ - label, - onClick, - unavailable, - children, -}: TerminalActionButtonProps) { - return ( - - { - if (!unavailable) onClick(); - }} - aria-label={label} - aria-disabled={unavailable || undefined} - /> - } - > - {children} - - - {label} - - - ); + /** Its sessions are still loading: with none to show yet, it draws just its background. */ + attaching?: boolean; } function TerminalSplitPanes({ @@ -1244,7 +1224,9 @@ function TerminalSplitPanes({ advancedTypography: boolean; threadRef: ScopedThreadRef; threadId: ThreadId; - onAddTerminalContext: (selection: TerminalContextSelection) => void; + onAddTerminalContext: + | ((selection: TerminalContextSelection) => void | Promise) + | undefined; visible: boolean; keybindings: ResolvedKeybindingsConfig; }) { @@ -1304,10 +1286,118 @@ function TerminalSplitPanes({ ); } +interface TerminalActionButtonProps { + label: string; + onClick: () => void; + unavailable?: boolean; + children: ReactNode; + /** "bare" for the notch's "+", which matches the chat tab strip's. */ + variant?: "ghost-muted" | "bare"; +} + +/** + * The terminal pane's tabs: in a layout pane they sit in the pane's notch with + * "+" as its last child, like a chat's tabs. The drawer keeps a plain row. + */ +function TerminalTabsNotch({ + notch, + newTerminalButton, + children, +}: { + notch: boolean; + newTerminalButton: ReactNode; + children: ReactNode; +}) { + if (!notch) return children; + return ( + + {children} + + {newTerminalButton} + + + ); +} + +function TerminalTabStrip({ + notch, + ref, + children, +}: { + notch: boolean; + ref: RefObject; + children: ReactNode; +}) { + const { tabId } = useLayoutPane(); + if (notch) { + return ( +
+ {children} +
+ ); + } + return ( + +
+ {children} +
+
+ ); +} + +function TerminalActionButton({ + label, + onClick, + unavailable, + children, + variant = "ghost-muted", +}: TerminalActionButtonProps) { + return ( + + { + if (!unavailable) onClick(); + }} + aria-label={label} + aria-disabled={unavailable || undefined} + /> + } + > + {children} + + + {label} + + + ); +} + export default function ThreadTerminalDrawer({ mode = "drawer", threadRef, threadId, + threadTitle, cwd, worktreePath, runtimeEnv, @@ -1321,8 +1411,6 @@ export default function ThreadTerminalDrawer({ onSplitTerminal, onSplitTerminalVertical, onNewTerminal, - splitShortcutLabel, - splitVerticalShortcutLabel, newShortcutLabel, closeShortcutLabel, onActiveTerminalChange, @@ -1333,10 +1421,16 @@ export default function ThreadTerminalDrawer({ terminalLabelsById, terminalStatusById, terminalLaunchLocationsById, + attaching = false, }: ThreadTerminalDrawerProps) { const canOperateTerminal = useEnvironmentScope(threadRef.environmentId, AuthTerminalOperateScope); const terminalArea = workspaceAreaProps("terminal", 5, "Terminal"); const isPanel = mode === "panel"; + const fillsContainer = mode !== "drawer"; + const terminalOwner = mode === "panel" ? "right-panel" : "drawer"; + // As a pane, this is a layout tab: dragging one of its terminals moves it. + const { tabId: layoutTabId, leadingEdge, trailingEdge } = useLayoutPane(); + const siblingTabs = useLayoutPaneSiblingTabs(); const [advancedTypography] = useLocalStorage( TYPOGRAPHY_ADVANCED_STORAGE_KEY, false, @@ -1476,44 +1570,24 @@ export default function ThreadTerminalDrawer({ const splitDirection = resolvedTerminalGroups[resolvedActiveGroupIndex]?.splitDirection ?? "horizontal"; const isSplitView = visibleTerminalIds.length > 1; + const tabStripRef = useRef(null); + useRevealActiveTab(tabStripRef, resolvedActiveTerminalId); const resolveTerminalLaunchLocation = (terminalId: string): TerminalLaunchLocation => terminalLaunchLocationsById?.get(terminalId) ?? { cwd, ...(worktreePath !== undefined ? { worktreePath } : {}), ...(runtimeEnv ? { runtimeEnv } : {}), }; - const hasReachedSplitLimit = visibleTerminalIds.length >= MAX_TERMINALS_PER_GROUP; - const splitTerminalActionLabel = hasReachedSplitLimit - ? `Split Terminal Horizontally (max ${MAX_TERMINALS_PER_GROUP} per group)` - : splitShortcutLabel - ? `Split Terminal Horizontally (${splitShortcutLabel})` - : "Split Terminal Horizontally"; - const splitTerminalVerticalActionLabel = hasReachedSplitLimit - ? `Split Terminal Vertically (max ${MAX_TERMINALS_PER_GROUP} per group)` - : splitVerticalShortcutLabel - ? `Split Terminal Vertically (${splitVerticalShortcutLabel})` - : "Split Terminal Vertically"; - const onSplitTerminalAction = () => { - if (!canOperateTerminal || hasReachedSplitLimit) return; - onSplitTerminal(); - }; - const onSplitTerminalVerticalAction = () => { - if (!canOperateTerminal || hasReachedSplitLimit) return; - onSplitTerminalVertical(); - }; const newTerminalActionLabel = newShortcutLabel ? `New Terminal (${newShortcutLabel})` : "New Terminal"; - const closeTerminalActionLabel = closeShortcutLabel - ? `Close Terminal (${closeShortcutLabel})` - : "Close Terminal"; const onNewTerminalAction = () => { if (!canOperateTerminal) return; onNewTerminal(); }; const confirmCloseTerminal = (terminalId: string) => { if (!canOperateTerminal) return; - const label = terminalLabelById.get(terminalId) ?? getTerminalLabel(terminalId); + const label = terminalTabTitle(terminalId, terminalLabelById.get(terminalId)); void confirmTerminalClose([label]).then((confirmed) => { if (confirmed && readEnvironmentScope(threadRef.environmentId, AuthTerminalOperateScope)) { onCloseTerminal(terminalId, threadRef); @@ -1521,6 +1595,77 @@ export default function ThreadTerminalDrawer({ }); }; + // The terminal's own keys answer here, so they work whether or not its + // thread's chat is open. Without operate access they keep their native meaning. + const onShortcutKeyDown = (event: ReactKeyboardEvent) => { + if (!canOperateTerminal || getTerminalFocusOwner() === null) return; + const command = resolveShortcutCommand(event.nativeEvent, keybindings, { + context: { terminalFocus: true, terminalOpen: true }, + }); + if ( + command !== "terminal.split" && + command !== "terminal.splitVertical" && + command !== "terminal.new" && + command !== "terminal.close" + ) { + return; + } + event.preventDefault(); + event.stopPropagation(); + if (command === "terminal.split") onSplitTerminal(); + else if (command === "terminal.splitVertical") onSplitTerminalVertical(); + else if (command === "terminal.new") onNewTerminalAction(); + else if (!event.repeat && !isTerminalCloseConfirmPending()) { + confirmCloseTerminal(resolvedActiveTerminalId); + } + }; + + const hasTerminal = useStableCallback(() => visibleTerminalIds.length > 0); + const canSplitTerminal = useStableCallback( + () => canOperateTerminal && visibleTerminalIds.length < MAX_TERMINALS_PER_GROUP, + ); + const paneFocusedRef = useLayoutPaneFocusedRef(); + const threadKey = scopedThreadKey(threadRef); + const splitTerminalFromPalette = useStableCallback((direction: "horizontal" | "vertical") => { + if (direction === "vertical") onSplitTerminalVertical(); + else onSplitTerminal(); + }); + // A terminal takes focus only in the focused pane, so a terminal pane claims it first. + const focusActiveTerminalNow = useStableCallback(() => { + if (resolvedActiveTerminalId) focusTerminal(threadRef, resolvedActiveTerminalId); + }); + const focusActiveTerminal = useStableCallback(() => { + if (layoutTabId === null || paneFocusedRef.current) { + focusActiveTerminalNow(); + return; + } + useLayoutStore.getState().focusTab(layoutTabId); + window.requestAnimationFrame(focusActiveTerminalNow); + }); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- The command palette reaches this pane's split, the one its keys run, while the pane is mounted. + useEffect( + () => + registerTerminalSplitTarget({ + threadKey, + contains: (element) => drawerRef.current?.contains(element) ?? false, + inFocusedPane: () => visible && paneFocusedRef.current, + visible: () => visible, + hasTerminal, + canSplit: canSplitTerminal, + split: splitTerminalFromPalette, + focusActive: focusActiveTerminal, + }), + [ + canSplitTerminal, + focusActiveTerminal, + hasTerminal, + paneFocusedRef, + splitTerminalFromPalette, + threadKey, + visible, + ], + ); + const handleResizePointerDown = (event: ReactPointerEvent) => { if (event.button !== 0) return; event.preventDefault(); @@ -1597,7 +1742,7 @@ export default function ThreadTerminalDrawer({ } // The column and window may have resized while the drawer was hidden and // not listening, so measure before the first visible paint. - const column = isPanel ? null : chatColumnOf(drawerRef.current); + const column = fillsContainer ? null : chatColumnOf(drawerRef.current); const onWindowResize = () => updateDrawerLayout(column, "window"); onWindowResize(); const header = column?.querySelector("[data-chat-header]"); @@ -1611,9 +1756,9 @@ export default function ThreadTerminalDrawer({ stopObserving?.(); window.removeEventListener("resize", onWindowResize); }; - }, [isPanel, visible]); + }, [fillsContainer, visible]); - const resizeHandle = isPanel ? null : ( + const resizeHandle = fillsContainer ? null : (
{resizeHandle} -
-

No terminal sessions for this thread yet.

- -
+ {attaching ? null : ( +
+

No terminal sessions for this thread yet.

+ +
+ )} ); } @@ -1667,59 +1815,50 @@ export default function ThreadTerminalDrawer({ ); - // Keep every terminal action in the header so controls never cover shell output. - const terminalActions = ( -
- - - - - - - {newTerminalButton} - confirmCloseTerminal(resolvedActiveTerminalId)} - unavailable={!canOperateTerminal} - > - - -
- ); - return (