diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index 2d13351713251cbae8c924c6b925152277420fec..e22db07c0cd8eb0d98c492bde060b5dd7d089a3e 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -68,6 +68,7 @@ function makeFakeBrowserWindow() { once: vi.fn(), restore: vi.fn(), setBackgroundColor: vi.fn(), + setAutoHideCursor: vi.fn(), setTitle: vi.fn(), setTitleBarOverlay: vi.fn(), show: vi.fn(), @@ -78,6 +79,7 @@ function makeFakeBrowserWindow() { window: window as unknown as Electron.BrowserWindow, loadURL: window.loadURL, openDevTools: webContents.openDevTools, + setAutoHideCursor: window.setAutoHideCursor, webContentsListeners, }; } @@ -134,10 +136,17 @@ function makeTestLayer(input: { readonly window: Electron.BrowserWindow; readonly createCount: Ref.Ref; readonly mainWindow: Ref.Ref>; + readonly createdWindowOptions?: Electron.BrowserWindowConstructorOptions[]; readonly openedExternalUrls?: unknown[]; }) { const electronWindowLayer = Layer.succeed(ElectronWindow.ElectronWindow, { - create: () => Ref.update(input.createCount, (count) => count + 1).pipe(Effect.as(input.window)), + create: (options) => + Effect.sync(() => { + input.createdWindowOptions?.push(options); + }).pipe( + Effect.andThen(Ref.update(input.createCount, (count) => count + 1)), + Effect.as(input.window), + ), main: Ref.get(input.mainWindow), currentMainOrFirst: Ref.get(input.mainWindow), focusedMainOrFirst: Ref.get(input.mainWindow), @@ -205,10 +214,12 @@ describe("DesktopWindow", () => { const fakeWindow = makeFakeBrowserWindow(); const createCount = yield* Ref.make(0); const mainWindow = yield* Ref.make>(Option.none()); + const createdWindowOptions: Electron.BrowserWindowConstructorOptions[] = []; const layer = makeTestLayer({ window: fakeWindow.window, createCount, mainWindow, + createdWindowOptions, }); yield* Effect.gen(function* () { @@ -218,6 +229,8 @@ describe("DesktopWindow", () => { yield* desktopWindow.handleBackendReady; assert.equal(yield* Ref.get(createCount), 1); + assert.isTrue(createdWindowOptions[0]?.disableAutoHideCursor); + assert.deepEqual(fakeWindow.setAutoHideCursor.mock.calls, [[false]]); assert.deepEqual(fakeWindow.loadURL.mock.calls[0], ["http://127.0.0.1:5733/"]); assert.equal(fakeWindow.openDevTools.mock.calls.length, 1); }).pipe(Effect.provide(layer)); diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index 6f045eb80b6c8e7282d32e603b5eb21a7e63a281..642abd535ae1be3210f0037b20e5da9c12a98d54 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -193,6 +193,7 @@ const make = Effect.gen(function* () { minHeight: 620, show: false, autoHideMenuBar: true, + ...(environment.platform === "darwin" ? { disableAutoHideCursor: true } : {}), backgroundColor: getInitialWindowBackgroundColor(shouldUseDarkColors), ...iconOption, title: environment.displayName, @@ -206,6 +207,10 @@ const make = Effect.gen(function* () { }, }); + if (environment.platform === "darwin") { + window.setAutoHideCursor(false); + } + yield* previewManager.setMainWindow(window); window.webContents.on("will-attach-webview", (event, webPreferences, params) => { if ( diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index fb2bef6e72ff0addd5853baf8b548d3a214afbe6..0ef7764325194cfe7775ceb871cbd1934120c23c 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -2146,6 +2146,11 @@ describe("ChatView timeline estimator parity (full app)", () => { expect(panelLayoutControls.getBoundingClientRect().top).toBe( chatHeader.getBoundingClientRect().top, ); + expect( + window.getComputedStyle(panelLayoutControls).getPropertyValue("-webkit-app-region"), + ).toBe("no-drag"); + expect(chatHeader.classList.contains("drag-region")).toBe(false); + expect(chatHeader.contains(panelLayoutControls)).toBe(true); const initialTerminalRect = terminalToggle.getBoundingClientRect(); const initialRightPanelRect = rightPanelToggle.getBoundingClientRect(); const initialControlRects = [initialTerminalRect, initialRightPanelRect]; @@ -2179,17 +2184,51 @@ describe("ChatView timeline estimator parity (full app)", () => { () => document.querySelector("[data-right-panel-tabbar]"), "Unable to find right panel tab bar.", ); + const rightPanelTabList = await waitForElement( + () => document.querySelector("[data-right-panel-tab-list]"), + "Unable to find right panel tab list.", + ); const maximizeRect = maximizeButton.getBoundingClientRect(); const rightPanelTabbarRect = rightPanelTabbar.getBoundingClientRect(); + const openPanelLayoutControls = await waitForElement( + () => document.querySelector("[data-panel-layout-controls]"), + "Unable to find open panel layout controls.", + ); + const openTerminalToggle = await waitForElement( + () => + document.querySelector('button[aria-label="Toggle terminal drawer"]'), + "Unable to find open panel terminal toggle.", + ); + const openRightPanelToggle = await waitForElement( + () => document.querySelector('button[aria-label="Toggle right panel"]'), + "Unable to find open panel right panel toggle.", + ); expect(document.querySelector('button[aria-label="Add panel surface"]')).toBeNull(); expect(rightPanelTabbarRect.height).toBe(52); expect(rightPanelTabbarRect.top).toBe(chatHeader.getBoundingClientRect().top); + expect(chatHeader.contains(openPanelLayoutControls)).toBe(false); + expect( + window.getComputedStyle(rightPanelTabbar).getPropertyValue("-webkit-app-region"), + ).not.toBe("drag"); + expect(rightPanelTabList.classList.contains("drag-region")).toBe(false); + expect(window.getComputedStyle(maximizeButton).getPropertyValue("-webkit-app-region")).toBe( + "no-drag", + ); + expect( + window.getComputedStyle(openTerminalToggle).getPropertyValue("-webkit-app-region"), + ).toBe("no-drag"); + expect( + window.getComputedStyle(openRightPanelToggle).getPropertyValue("-webkit-app-region"), + ).toBe("no-drag"); expect(maximizeRect.width).toBe(28); expect(maximizeRect.height).toBe(28); expect(maximizeRect.top).toBe(initialTerminalRect.top); expect(initialTerminalRect.left - maximizeRect.right).toBe(4); - expect(terminalToggle.getBoundingClientRect().left).toBeCloseTo(initialTerminalRect.left, 1); - expect(rightPanelToggle.getBoundingClientRect().left).toBeCloseTo( + expect(openTerminalToggle.getBoundingClientRect().left).toBeCloseTo( + initialTerminalRect.left, + 1, + ); + expect(openRightPanelToggle.getBoundingClientRect().left).toBeCloseTo( initialRightPanelRect.left, 1, ); @@ -2206,10 +2245,10 @@ describe("ChatView timeline estimator parity (full app)", () => { document.documentElement.classList.add("wco"); expect(rightPanelTabbar.getBoundingClientRect().height).toBe( - panelLayoutControls.getBoundingClientRect().height, + openPanelLayoutControls.getBoundingClientRect().height, ); expect(rightPanelTabbar.getBoundingClientRect().top).toBe( - panelLayoutControls.getBoundingClientRect().top, + openPanelLayoutControls.getBoundingClientRect().top, ); document.documentElement.classList.remove("wco"); @@ -2227,20 +2266,83 @@ describe("ChatView timeline estimator parity (full app)", () => { expect( document.querySelector('button[aria-label="Restore panel size"]'), ).not.toBeNull(); - expect(terminalToggle.getBoundingClientRect().left).toBeCloseTo( - initialTerminalRect.left, - 1, - ); - expect(rightPanelToggle.getBoundingClientRect().left).toBeCloseTo( - initialRightPanelRect.left, - 1, - ); + expect( + document + .querySelector('button[aria-label="Toggle terminal drawer"]') + ?.getBoundingClientRect().left, + ).toBeCloseTo(initialTerminalRect.left, 1); + expect( + document + .querySelector('button[aria-label="Toggle right panel"]') + ?.getBoundingClientRect().left, + ).toBeCloseTo(initialRightPanelRect.left, 1); }); } finally { await mounted.cleanup(); } }); + it("loads file previews from the active thread worktree", async () => { + const worktreePath = "/repo/worktrees/file-preview-thread"; + const snapshot = createSnapshotForTargetUser({ + targetMessageId: "msg-user-worktree-file-preview" as MessageId, + targetText: "open the worktree file preview", + }); + const targetThread = snapshot.threads.find((thread) => thread.id === THREAD_ID); + if (!targetThread) { + throw new Error("Missing target thread fixture."); + } + + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: { + ...snapshot, + threads: snapshot.threads.map((thread) => + thread.id === THREAD_ID ? { ...thread, worktreePath } : thread, + ), + }, + resolveRpc: (body) => { + if (body._tag === WS_METHODS.projectsListEntries) { + return { entries: [{ path: "src/index.ts", kind: "file" }], truncated: false }; + } + if (body._tag === WS_METHODS.projectsReadFile) { + return { + relativePath: "src/index.ts", + contents: "export const worktree = true;\n", + byteLength: 30, + truncated: false, + }; + } + return undefined; + }, + }); + + try { + useRightPanelStore.getState().open(THREAD_REF, "files"); + await waitForElement( + () => document.querySelector("[data-file-browser-panel]"), + "Unable to find the worktree file explorer.", + ); + + useRightPanelStore.getState().openFile(THREAD_REF, "src/index.ts"); + await waitForElement( + () => document.querySelector(".file-preview-virtualizer"), + "Unable to find the worktree file preview.", + ); + + const listRequest = wsRequests.find( + (request) => request._tag === WS_METHODS.projectsListEntries, + ); + const readRequest = wsRequests.find( + (request) => request._tag === WS_METHODS.projectsReadFile, + ); + expect(listRequest).toMatchObject({ cwd: worktreePath }); + expect(readRequest).toMatchObject({ cwd: worktreePath, relativePath: "src/index.ts" }); + } finally { + await mounted.cleanup(); + } + }); + it("scrolls file tabs and preserves the workspace explorer across file previews", async () => { const workspaceEntries = [ { path: "src", kind: "directory" as const }, @@ -2355,6 +2457,7 @@ describe("ChatView timeline estimator parity (full app)", () => { () => document.querySelector("[data-file-breadcrumbs]"), "Unable to find the responsive file breadcrumbs.", ); + const fileSubheader = breadcrumbs.closest("[data-surface-subheader]"); const breadcrumbViewport = await waitForElement( () => breadcrumbs.querySelector('[data-slot="scroll-area-viewport"]'), "Unable to find the file breadcrumb viewport.", @@ -2382,6 +2485,9 @@ describe("ChatView timeline estimator parity (full app)", () => { expect(explorerToggle.getAttribute("aria-pressed")).toBe("true"); expect(explorerToggle.getBoundingClientRect().width).toBe(28); expect(explorerToggle.getBoundingClientRect().height).toBe(28); + expect(fileSubheader?.getBoundingClientRect().height).toBe(40); + expect(window.getComputedStyle(fileSubheader!).borderTopWidth).toBe("0px"); + expect(window.getComputedStyle(fileSubheader!).borderBottomWidth).toBe("1px"); }); const fileSearchButton = await waitForElement( diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 030cb94a6d2541f893649ddeb604a0f2393e3dcf..b260ddec0fdb509398d440a4abad07f4a08eaad9 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -4558,24 +4558,28 @@ function ChatViewContent(props: ChatViewProps) { return ; } + const panelLayoutControls = ( + + ); + return (
{isElectron && activeThreadRef ? ( ) : null} - + {rightPanelOpen ? panelLayoutControls : null}
+ {!rightPanelOpen ? panelLayoutControls : null} ) : (activeRightPanelSurface?.kind === "files" || activeRightPanelSurface?.kind === "file") && - activeProject ? ( + activeProject && + activeWorkspaceRoot ? ( ) : (activeRightPanelSurface?.kind === "files" || activeRightPanelSurface?.kind === "file") && - activeProject ? ( + activeProject && + activeWorkspaceRoot ? ( { - const api = readLocalApi(); - if (!api) return; - const targetPath = activeCwd ? resolvePathLinkTarget(filePath, activeCwd) : filePath; - void openInPreferredEditor(api, targetPath).catch((error) => { - console.warn("Failed to open diff file in editor.", error); + openDiffFilePrimaryAction({ + threadRef: routeThreadRef, + filePath, + activeCwd, + openInEditor: (targetPath) => { + const api = readLocalApi(); + if (!api) return; + void openInPreferredEditor(api, targetPath).catch((error) => { + console.warn("Failed to open diff file in editor.", error); + }); + }, }); }, - [activeCwd], + [activeCwd, routeThreadRef], ); const toggleDiffFileCollapsed = useCallback((fileKey: string) => { setCollapsedDiffFileKeys((current) => { @@ -664,7 +670,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { return node.hasAttribute("data-title"); }); if (!clickedHeader) return; - openDiffFileInEditor(filePath); + openDiffFile(filePath); }} > { + it("uses the shared compact surface subheader in embedded mode", async () => { + const screen = await render( + Diff controls}> +
Diff content
+
, + ); + const subheader = screen.container.querySelector("[data-surface-subheader]"); + + expect(subheader).not.toBeNull(); + expect(subheader?.getBoundingClientRect().height).toBe(40); + expect(window.getComputedStyle(subheader!).borderTopWidth).toBe("0px"); + expect(window.getComputedStyle(subheader!).borderBottomWidth).toBe("1px"); + }); +}); diff --git a/apps/web/src/components/DiffPanelShell.tsx b/apps/web/src/components/DiffPanelShell.tsx index 6a2219d610ff68204fb09026f549f80da10fad17..4dd569d2823a484ab1ca94fe24a636005a6e9f0d 100644 --- a/apps/web/src/components/DiffPanelShell.tsx +++ b/apps/web/src/components/DiffPanelShell.tsx @@ -13,7 +13,7 @@ function getDiffPanelHeaderRowClassName(mode: DiffPanelMode) { "flex items-center justify-between gap-2 px-4", shouldUseDragRegion ? "drag-region h-[52px] border-b border-border wco:h-[env(titlebar-area-height)] wco:pr-[calc(100vw-env(titlebar-area-width)-env(titlebar-area-x)+1em)]" - : "h-12 wco:max-h-[env(titlebar-area-height)]", + : "surface-subheader", ); } @@ -36,8 +36,8 @@ export function DiffPanelShell(props: { {shouldUseDragRegion ? (
{props.header}
) : ( -
-
{props.header}
+
+ {props.header}
)} {props.children} diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index 330ee67b0062a5ea8563c7c76a96f817bfb3131c..a47653ed0da49ac01a28e33016526cd7d3a17036 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -211,8 +211,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) { className={cn( "px-2 pr-28", props.mode === "inline" ? "workspace-topbar" : "flex h-10 shrink-0 items-center", - ownsDesktopTitleBar && - "drag-region wco:pr-[calc(var(--workspace-native-controls-inset)+6rem)]", + ownsDesktopTitleBar && "wco:pr-[calc(var(--workspace-native-controls-inset)+6rem)]", )} data-right-panel-tabbar > @@ -220,7 +219,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) { ref={tabListRef} hideScrollbars scrollFade - className="min-w-0 flex-1 rounded-none" + className={cn("min-w-0 flex-1 rounded-none", ownsDesktopTitleBar && "drag-region")} data-right-panel-tab-list >
diff --git a/apps/web/src/components/chat/PanelLayoutControls.tsx b/apps/web/src/components/chat/PanelLayoutControls.tsx index af012eebac1d56a4802f4658dec636772eef2389..3b7fbf4ad967a52f5a811c5b98cc18b051485390 100644 --- a/apps/web/src/components/chat/PanelLayoutControls.tsx +++ b/apps/web/src/components/chat/PanelLayoutControls.tsx @@ -34,13 +34,16 @@ export const PanelLayoutControls = memo(function PanelLayoutControls({ onToggleRightPanelMaximized, }: PanelLayoutControlsProps) { return ( -
+
{rightPanelOpen ? ( {relativePath ? ( -
+
{ + it("uses the shared compact surface subheader treatment", async () => { + const screen = await render(); + const subheader = screen.container.querySelector("[data-surface-subheader]"); + + expect(subheader).not.toBeNull(); + expect(subheader?.getBoundingClientRect().height).toBe(40); + expect(window.getComputedStyle(subheader!).borderTopWidth).toBe("0px"); + expect(window.getComputedStyle(subheader!).borderBottomWidth).toBe("1px"); + }); + it("only focuses the URL input after an explicit focus request", async () => { const previouslyFocused = document.createElement("button"); document.body.append(previouslyFocused); diff --git a/apps/web/src/components/preview/PreviewChromeRow.tsx b/apps/web/src/components/preview/PreviewChromeRow.tsx index 057db6d801f0a76fc2e51f9b0b0f690392c15ec9..469be486ee8e2952be7e0af3b0f6060f46f921d9 100644 --- a/apps/web/src/components/preview/PreviewChromeRow.tsx +++ b/apps/web/src/components/preview/PreviewChromeRow.tsx @@ -110,10 +110,7 @@ export function PreviewChromeRow({ return (
-
+
{ + beforeEach(() => { + useRightPanelStore.setState({ byThreadKey: {} }); + }); + + it("opens diff files in the thread file viewer", () => { + const openInEditor = vi.fn(); + + openDiffFilePrimaryAction({ + threadRef: THREAD_REF, + filePath: "apps/web/src/components/DiffPanel.tsx", + activeCwd: "/repo/project", + openInEditor, + }); + + expect( + selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, THREAD_REF), + ).toMatchObject({ + isOpen: true, + activeSurfaceId: "file:apps/web/src/components/DiffPanel.tsx", + }); + expect(openInEditor).not.toHaveBeenCalled(); + }); + + it("falls back to the editor without thread context", () => { + const openInEditor = vi.fn(); + + openDiffFilePrimaryAction({ + threadRef: null, + filePath: "apps/web/src/components/DiffPanel.tsx", + activeCwd: "/repo/project", + openInEditor, + }); + + expect(openInEditor).toHaveBeenCalledWith( + "/repo/project/apps/web/src/components/DiffPanel.tsx", + ); + }); +}); diff --git a/apps/web/src/diffFileActions.ts b/apps/web/src/diffFileActions.ts new file mode 100644 index 0000000000000000000000000000000000000000..335ad21fccf99671494fe56f8c0481f52669dbc7 --- /dev/null +++ b/apps/web/src/diffFileActions.ts @@ -0,0 +1,25 @@ +import type { ScopedThreadRef } from "@t3tools/contracts"; + +import { useRightPanelStore } from "./rightPanelStore"; +import { resolvePathLinkTarget } from "./terminal-links"; + +interface OpenDiffFilePrimaryActionInput { + readonly threadRef: ScopedThreadRef | null; + readonly filePath: string; + readonly activeCwd: string | undefined; + readonly openInEditor: (targetPath: string) => void; +} + +export function openDiffFilePrimaryAction({ + threadRef, + filePath, + activeCwd, + openInEditor, +}: OpenDiffFilePrimaryActionInput): void { + if (threadRef) { + useRightPanelStore.getState().openFile(threadRef, filePath); + return; + } + + openInEditor(activeCwd ? resolvePathLinkTarget(filePath, activeCwd) : filePath); +} diff --git a/apps/web/src/index.css b/apps/web/src/index.css index c59cfb490d4b46472c8980081c51297d031ad889..e9d2b50275a60f9478615f3de6e8cf6cd7e03c79 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -111,6 +111,11 @@ display: flex; height: var(--workspace-topbar-height); align-items: center; + -webkit-app-region: no-drag; + } + + .surface-subheader { + @apply flex h-10 min-h-10 shrink-0 items-center border-b border-border/60 bg-background; } } diff --git a/apps/web/src/keybindings.test.ts b/apps/web/src/keybindings.test.ts index 5563378da97ff7ef9b9815971d7d46cb4c1b5d3c..d4fc945cc0460bcdc797901468cf504808ea9938 100644 --- a/apps/web/src/keybindings.test.ts +++ b/apps/web/src/keybindings.test.ts @@ -621,6 +621,17 @@ describe("resolveShortcutCommand", () => { "thread.next", ); }); + + it("matches Option-modified letters using the physical key code on macOS", () => { + assert.strictEqual( + resolveShortcutCommand( + event({ key: "∫", code: "KeyB", metaKey: true, altKey: true }), + DEFAULT_BINDINGS, + { platform: "MacIntel" }, + ), + "rightPanel.toggle", + ); + }); }); describe("formatShortcutLabel", () => { diff --git a/apps/web/src/keybindings.ts b/apps/web/src/keybindings.ts index 24ed02223d09859d8e2e56016122dc3b9a87808c..9d6109a7780654557d165d9b2bd9a6246573b2f1 100644 --- a/apps/web/src/keybindings.ts +++ b/apps/web/src/keybindings.ts @@ -72,6 +72,10 @@ function normalizeEventKey(key: string): string { function resolveEventKeys(event: ShortcutEventLike): Set { const keys = new Set([normalizeEventKey(event.key)]); + const letterCode = event.code?.match(/^Key([A-Z])$/)?.[1]; + if (letterCode) { + keys.add(letterCode.toLowerCase()); + } const aliases = event.code ? EVENT_CODE_KEY_ALIASES[event.code] : undefined; if (!aliases) return keys;