authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-15 15:58:03-07:00
committergravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-15 15:58:03-07:00
logc2d44a312c072c29056f7f87938026f366879d75
tree378f818b85662270015f97d487ff3934aa4bbbef
parentde8bdc10fa3d92c49bc7a4365926f030d71682fd

Unify compact subheaders and diff file navigation

- Add shared compact subheader styling for preview, file, and diff panels - Open diff files in the thread file viewer when available - Fix macOS cursor auto-hide and Option-modified shortcut matching

17 files changed, 321 insertions(+), 57 deletions(-)

apps/desktop/src/window/DesktopWindow.test.ts+14-1
...@@ -68,6 +68,7 @@ function makeFakeBrowserWindow() {...@@ -68,6 +68,7 @@ function makeFakeBrowserWindow() {
68 once: vi.fn(),68 once: vi.fn(),
69 restore: vi.fn(),69 restore: vi.fn(),
70 setBackgroundColor: vi.fn(),70 setBackgroundColor: vi.fn(),
71 setAutoHideCursor: vi.fn(),
71 setTitle: vi.fn(),72 setTitle: vi.fn(),
72 setTitleBarOverlay: vi.fn(),73 setTitleBarOverlay: vi.fn(),
73 show: vi.fn(),74 show: vi.fn(),
...@@ -78,6 +79,7 @@ function makeFakeBrowserWindow() {...@@ -78,6 +79,7 @@ function makeFakeBrowserWindow() {
78 window: window as unknown as Electron.BrowserWindow,79 window: window as unknown as Electron.BrowserWindow,
79 loadURL: window.loadURL,80 loadURL: window.loadURL,
80 openDevTools: webContents.openDevTools,81 openDevTools: webContents.openDevTools,
82 setAutoHideCursor: window.setAutoHideCursor,
81 webContentsListeners,83 webContentsListeners,
82 };84 };
83}85}
...@@ -134,10 +136,17 @@ function makeTestLayer(input: {...@@ -134,10 +136,17 @@ function makeTestLayer(input: {
134 readonly window: Electron.BrowserWindow;136 readonly window: Electron.BrowserWindow;
135 readonly createCount: Ref.Ref<number>;137 readonly createCount: Ref.Ref<number>;
136 readonly mainWindow: Ref.Ref<Option.Option<Electron.BrowserWindow>>;138 readonly mainWindow: Ref.Ref<Option.Option<Electron.BrowserWindow>>;
139 readonly createdWindowOptions?: Electron.BrowserWindowConstructorOptions[];
137 readonly openedExternalUrls?: unknown[];140 readonly openedExternalUrls?: unknown[];
138}) {141}) {
139 const electronWindowLayer = Layer.succeed(ElectronWindow.ElectronWindow, {142 const electronWindowLayer = Layer.succeed(ElectronWindow.ElectronWindow, {
140 create: () => Ref.update(input.createCount, (count) => count + 1).pipe(Effect.as(input.window)),143 create: (options) =>
144 Effect.sync(() => {
145 input.createdWindowOptions?.push(options);
146 }).pipe(
147 Effect.andThen(Ref.update(input.createCount, (count) => count + 1)),
148 Effect.as(input.window),
149 ),
141 main: Ref.get(input.mainWindow),150 main: Ref.get(input.mainWindow),
142 currentMainOrFirst: Ref.get(input.mainWindow),151 currentMainOrFirst: Ref.get(input.mainWindow),
143 focusedMainOrFirst: Ref.get(input.mainWindow),152 focusedMainOrFirst: Ref.get(input.mainWindow),
...@@ -205,10 +214,12 @@ describe("DesktopWindow", () => {...@@ -205,10 +214,12 @@ describe("DesktopWindow", () => {
205 const fakeWindow = makeFakeBrowserWindow();214 const fakeWindow = makeFakeBrowserWindow();
206 const createCount = yield* Ref.make(0);215 const createCount = yield* Ref.make(0);
207 const mainWindow = yield* Ref.make<Option.Option<Electron.BrowserWindow>>(Option.none());216 const mainWindow = yield* Ref.make<Option.Option<Electron.BrowserWindow>>(Option.none());
217 const createdWindowOptions: Electron.BrowserWindowConstructorOptions[] = [];
208 const layer = makeTestLayer({218 const layer = makeTestLayer({
209 window: fakeWindow.window,219 window: fakeWindow.window,
210 createCount,220 createCount,
211 mainWindow,221 mainWindow,
222 createdWindowOptions,
212 });223 });
213224
214 yield* Effect.gen(function* () {225 yield* Effect.gen(function* () {
...@@ -218,6 +229,8 @@ describe("DesktopWindow", () => {...@@ -218,6 +229,8 @@ describe("DesktopWindow", () => {
218229
219 yield* desktopWindow.handleBackendReady;230 yield* desktopWindow.handleBackendReady;
220 assert.equal(yield* Ref.get(createCount), 1);231 assert.equal(yield* Ref.get(createCount), 1);
232 assert.isTrue(createdWindowOptions[0]?.disableAutoHideCursor);
233 assert.deepEqual(fakeWindow.setAutoHideCursor.mock.calls, [[false]]);
221 assert.deepEqual(fakeWindow.loadURL.mock.calls[0], ["http://127.0.0.1:5733/"]);234 assert.deepEqual(fakeWindow.loadURL.mock.calls[0], ["http://127.0.0.1:5733/"]);
222 assert.equal(fakeWindow.openDevTools.mock.calls.length, 1);235 assert.equal(fakeWindow.openDevTools.mock.calls.length, 1);
223 }).pipe(Effect.provide(layer));236 }).pipe(Effect.provide(layer));
apps/desktop/src/window/DesktopWindow.ts+5
...@@ -193,6 +193,7 @@ const make = Effect.gen(function* () {...@@ -193,6 +193,7 @@ const make = Effect.gen(function* () {
193 minHeight: 620,193 minHeight: 620,
194 show: false,194 show: false,
195 autoHideMenuBar: true,195 autoHideMenuBar: true,
196 ...(environment.platform === "darwin" ? { disableAutoHideCursor: true } : {}),
196 backgroundColor: getInitialWindowBackgroundColor(shouldUseDarkColors),197 backgroundColor: getInitialWindowBackgroundColor(shouldUseDarkColors),
197 ...iconOption,198 ...iconOption,
198 title: environment.displayName,199 title: environment.displayName,
...@@ -206,6 +207,10 @@ const make = Effect.gen(function* () {...@@ -206,6 +207,10 @@ const make = Effect.gen(function* () {
206 },207 },
207 });208 });
208209
210 if (environment.platform === "darwin") {
211 window.setAutoHideCursor(false);
212 }
213
209 yield* previewManager.setMainWindow(window);214 yield* previewManager.setMainWindow(window);
210 window.webContents.on("will-attach-webview", (event, webPreferences, params) => {215 window.webContents.on("will-attach-webview", (event, webPreferences, params) => {
211 if (216 if (
apps/web/src/components/ChatView.browser.tsx+118-12
...@@ -2146,6 +2146,11 @@ describe("ChatView timeline estimator parity (full app)", () => {...@@ -2146,6 +2146,11 @@ describe("ChatView timeline estimator parity (full app)", () => {
2146 expect(panelLayoutControls.getBoundingClientRect().top).toBe(2146 expect(panelLayoutControls.getBoundingClientRect().top).toBe(
2147 chatHeader.getBoundingClientRect().top,2147 chatHeader.getBoundingClientRect().top,
2148 );2148 );
2149 expect(
2150 window.getComputedStyle(panelLayoutControls).getPropertyValue("-webkit-app-region"),
2151 ).toBe("no-drag");
2152 expect(chatHeader.classList.contains("drag-region")).toBe(false);
2153 expect(chatHeader.contains(panelLayoutControls)).toBe(true);
2149 const initialTerminalRect = terminalToggle.getBoundingClientRect();2154 const initialTerminalRect = terminalToggle.getBoundingClientRect();
2150 const initialRightPanelRect = rightPanelToggle.getBoundingClientRect();2155 const initialRightPanelRect = rightPanelToggle.getBoundingClientRect();
2151 const initialControlRects = [initialTerminalRect, initialRightPanelRect];2156 const initialControlRects = [initialTerminalRect, initialRightPanelRect];
...@@ -2179,17 +2184,51 @@ describe("ChatView timeline estimator parity (full app)", () => {...@@ -2179,17 +2184,51 @@ describe("ChatView timeline estimator parity (full app)", () => {
2179 () => document.querySelector<HTMLElement>("[data-right-panel-tabbar]"),2184 () => document.querySelector<HTMLElement>("[data-right-panel-tabbar]"),
2180 "Unable to find right panel tab bar.",2185 "Unable to find right panel tab bar.",
2181 );2186 );
2187 const rightPanelTabList = await waitForElement(
2188 () => document.querySelector<HTMLElement>("[data-right-panel-tab-list]"),
2189 "Unable to find right panel tab list.",
2190 );
2182 const maximizeRect = maximizeButton.getBoundingClientRect();2191 const maximizeRect = maximizeButton.getBoundingClientRect();
2183 const rightPanelTabbarRect = rightPanelTabbar.getBoundingClientRect();2192 const rightPanelTabbarRect = rightPanelTabbar.getBoundingClientRect();
2193 const openPanelLayoutControls = await waitForElement(
2194 () => document.querySelector<HTMLElement>("[data-panel-layout-controls]"),
2195 "Unable to find open panel layout controls.",
2196 );
2197 const openTerminalToggle = await waitForElement(
2198 () =>
2199 document.querySelector<HTMLButtonElement>('button[aria-label="Toggle terminal drawer"]'),
2200 "Unable to find open panel terminal toggle.",
2201 );
2202 const openRightPanelToggle = await waitForElement(
2203 () => document.querySelector<HTMLButtonElement>('button[aria-label="Toggle right panel"]'),
2204 "Unable to find open panel right panel toggle.",
2205 );
2184 expect(document.querySelector('button[aria-label="Add panel surface"]')).toBeNull();2206 expect(document.querySelector('button[aria-label="Add panel surface"]')).toBeNull();
2185 expect(rightPanelTabbarRect.height).toBe(52);2207 expect(rightPanelTabbarRect.height).toBe(52);
2186 expect(rightPanelTabbarRect.top).toBe(chatHeader.getBoundingClientRect().top);2208 expect(rightPanelTabbarRect.top).toBe(chatHeader.getBoundingClientRect().top);
2209 expect(chatHeader.contains(openPanelLayoutControls)).toBe(false);
2210 expect(
2211 window.getComputedStyle(rightPanelTabbar).getPropertyValue("-webkit-app-region"),
2212 ).not.toBe("drag");
2213 expect(rightPanelTabList.classList.contains("drag-region")).toBe(false);
2214 expect(window.getComputedStyle(maximizeButton).getPropertyValue("-webkit-app-region")).toBe(
2215 "no-drag",
2216 );
2217 expect(
2218 window.getComputedStyle(openTerminalToggle).getPropertyValue("-webkit-app-region"),
2219 ).toBe("no-drag");
2220 expect(
2221 window.getComputedStyle(openRightPanelToggle).getPropertyValue("-webkit-app-region"),
2222 ).toBe("no-drag");
2187 expect(maximizeRect.width).toBe(28);2223 expect(maximizeRect.width).toBe(28);
2188 expect(maximizeRect.height).toBe(28);2224 expect(maximizeRect.height).toBe(28);
2189 expect(maximizeRect.top).toBe(initialTerminalRect.top);2225 expect(maximizeRect.top).toBe(initialTerminalRect.top);
2190 expect(initialTerminalRect.left - maximizeRect.right).toBe(4);2226 expect(initialTerminalRect.left - maximizeRect.right).toBe(4);
2191 expect(terminalToggle.getBoundingClientRect().left).toBeCloseTo(initialTerminalRect.left, 1);2227 expect(openTerminalToggle.getBoundingClientRect().left).toBeCloseTo(
2192 expect(rightPanelToggle.getBoundingClientRect().left).toBeCloseTo(2228 initialTerminalRect.left,
2229 1,
2230 );
2231 expect(openRightPanelToggle.getBoundingClientRect().left).toBeCloseTo(
2193 initialRightPanelRect.left,2232 initialRightPanelRect.left,
2194 1,2233 1,
2195 );2234 );
...@@ -2206,10 +2245,10 @@ describe("ChatView timeline estimator parity (full app)", () => {...@@ -2206,10 +2245,10 @@ describe("ChatView timeline estimator parity (full app)", () => {
22062245
2207 document.documentElement.classList.add("wco");2246 document.documentElement.classList.add("wco");
2208 expect(rightPanelTabbar.getBoundingClientRect().height).toBe(2247 expect(rightPanelTabbar.getBoundingClientRect().height).toBe(
2209 panelLayoutControls.getBoundingClientRect().height,2248 openPanelLayoutControls.getBoundingClientRect().height,
2210 );2249 );
2211 expect(rightPanelTabbar.getBoundingClientRect().top).toBe(2250 expect(rightPanelTabbar.getBoundingClientRect().top).toBe(
2212 panelLayoutControls.getBoundingClientRect().top,2251 openPanelLayoutControls.getBoundingClientRect().top,
2213 );2252 );
2214 document.documentElement.classList.remove("wco");2253 document.documentElement.classList.remove("wco");
22152254
...@@ -2227,20 +2266,83 @@ describe("ChatView timeline estimator parity (full app)", () => {...@@ -2227,20 +2266,83 @@ describe("ChatView timeline estimator parity (full app)", () => {
2227 expect(2266 expect(
2228 document.querySelector<HTMLButtonElement>('button[aria-label="Restore panel size"]'),2267 document.querySelector<HTMLButtonElement>('button[aria-label="Restore panel size"]'),
2229 ).not.toBeNull();2268 ).not.toBeNull();
2230 expect(terminalToggle.getBoundingClientRect().left).toBeCloseTo(2269 expect(
2231 initialTerminalRect.left,2270 document
2232 1,2271 .querySelector<HTMLButtonElement>('button[aria-label="Toggle terminal drawer"]')
2233 );2272 ?.getBoundingClientRect().left,
2234 expect(rightPanelToggle.getBoundingClientRect().left).toBeCloseTo(2273 ).toBeCloseTo(initialTerminalRect.left, 1);
2235 initialRightPanelRect.left,2274 expect(
2236 1,2275 document
2237 );2276 .querySelector<HTMLButtonElement>('button[aria-label="Toggle right panel"]')
2277 ?.getBoundingClientRect().left,
2278 ).toBeCloseTo(initialRightPanelRect.left, 1);
2238 });2279 });
2239 } finally {2280 } finally {
2240 await mounted.cleanup();2281 await mounted.cleanup();
2241 }2282 }
2242 });2283 });
22432284
2285 it("loads file previews from the active thread worktree", async () => {
2286 const worktreePath = "/repo/worktrees/file-preview-thread";
2287 const snapshot = createSnapshotForTargetUser({
2288 targetMessageId: "msg-user-worktree-file-preview" as MessageId,
2289 targetText: "open the worktree file preview",
2290 });
2291 const targetThread = snapshot.threads.find((thread) => thread.id === THREAD_ID);
2292 if (!targetThread) {
2293 throw new Error("Missing target thread fixture.");
2294 }
2295
2296 const mounted = await mountChatView({
2297 viewport: DEFAULT_VIEWPORT,
2298 snapshot: {
2299 ...snapshot,
2300 threads: snapshot.threads.map((thread) =>
2301 thread.id === THREAD_ID ? { ...thread, worktreePath } : thread,
2302 ),
2303 },
2304 resolveRpc: (body) => {
2305 if (body._tag === WS_METHODS.projectsListEntries) {
2306 return { entries: [{ path: "src/index.ts", kind: "file" }], truncated: false };
2307 }
2308 if (body._tag === WS_METHODS.projectsReadFile) {
2309 return {
2310 relativePath: "src/index.ts",
2311 contents: "export const worktree = true;\n",
2312 byteLength: 30,
2313 truncated: false,
2314 };
2315 }
2316 return undefined;
2317 },
2318 });
2319
2320 try {
2321 useRightPanelStore.getState().open(THREAD_REF, "files");
2322 await waitForElement(
2323 () => document.querySelector<HTMLElement>("[data-file-browser-panel]"),
2324 "Unable to find the worktree file explorer.",
2325 );
2326
2327 useRightPanelStore.getState().openFile(THREAD_REF, "src/index.ts");
2328 await waitForElement(
2329 () => document.querySelector<HTMLElement>(".file-preview-virtualizer"),
2330 "Unable to find the worktree file preview.",
2331 );
2332
2333 const listRequest = wsRequests.find(
2334 (request) => request._tag === WS_METHODS.projectsListEntries,
2335 );
2336 const readRequest = wsRequests.find(
2337 (request) => request._tag === WS_METHODS.projectsReadFile,
2338 );
2339 expect(listRequest).toMatchObject({ cwd: worktreePath });
2340 expect(readRequest).toMatchObject({ cwd: worktreePath, relativePath: "src/index.ts" });
2341 } finally {
2342 await mounted.cleanup();
2343 }
2344 });
2345
2244 it("scrolls file tabs and preserves the workspace explorer across file previews", async () => {2346 it("scrolls file tabs and preserves the workspace explorer across file previews", async () => {
2245 const workspaceEntries = [2347 const workspaceEntries = [
2246 { path: "src", kind: "directory" as const },2348 { path: "src", kind: "directory" as const },
...@@ -2355,6 +2457,7 @@ describe("ChatView timeline estimator parity (full app)", () => {...@@ -2355,6 +2457,7 @@ describe("ChatView timeline estimator parity (full app)", () => {
2355 () => document.querySelector<HTMLElement>("[data-file-breadcrumbs]"),2457 () => document.querySelector<HTMLElement>("[data-file-breadcrumbs]"),
2356 "Unable to find the responsive file breadcrumbs.",2458 "Unable to find the responsive file breadcrumbs.",
2357 );2459 );
2460 const fileSubheader = breadcrumbs.closest<HTMLElement>("[data-surface-subheader]");
2358 const breadcrumbViewport = await waitForElement(2461 const breadcrumbViewport = await waitForElement(
2359 () => breadcrumbs.querySelector<HTMLElement>('[data-slot="scroll-area-viewport"]'),2462 () => breadcrumbs.querySelector<HTMLElement>('[data-slot="scroll-area-viewport"]'),
2360 "Unable to find the file breadcrumb viewport.",2463 "Unable to find the file breadcrumb viewport.",
...@@ -2382,6 +2485,9 @@ describe("ChatView timeline estimator parity (full app)", () => {...@@ -2382,6 +2485,9 @@ describe("ChatView timeline estimator parity (full app)", () => {
2382 expect(explorerToggle.getAttribute("aria-pressed")).toBe("true");2485 expect(explorerToggle.getAttribute("aria-pressed")).toBe("true");
2383 expect(explorerToggle.getBoundingClientRect().width).toBe(28);2486 expect(explorerToggle.getBoundingClientRect().width).toBe(28);
2384 expect(explorerToggle.getBoundingClientRect().height).toBe(28);2487 expect(explorerToggle.getBoundingClientRect().height).toBe(28);
2488 expect(fileSubheader?.getBoundingClientRect().height).toBe(40);
2489 expect(window.getComputedStyle(fileSubheader!).borderTopWidth).toBe("0px");
2490 expect(window.getComputedStyle(fileSubheader!).borderBottomWidth).toBe("1px");
2385 });2491 });
23862492
2387 const fileSearchButton = await waitForElement(2493 const fileSearchButton = await waitForElement(
apps/web/src/components/ChatView.tsx+27-20
...@@ -4558,24 +4558,28 @@ function ChatViewContent(props: ChatViewProps) {...@@ -4558,24 +4558,28 @@ function ChatViewContent(props: ChatViewProps) {
4558 return <NoActiveThreadState />;4558 return <NoActiveThreadState />;
4559 }4559 }
45604560
4561 const panelLayoutControls = (
4562 <PanelLayoutControls
4563 terminalAvailable={Boolean(activeProject)}
4564 terminalOpen={Boolean(terminalUiState.terminalOpen)}
4565 terminalShortcutLabel={terminalToggleShortcutLabel}
4566 rightPanelAvailable={Boolean(activeProject)}
4567 rightPanelOpen={rightPanelOpen}
4568 rightPanelShortcutLabel={rightPanelToggleShortcutLabel}
4569 rightPanelMaximized={rightPanelMaximized}
4570 canMaximizeRightPanel={canMaximizeRightPanel}
4571 onToggleTerminal={toggleTerminalVisibility}
4572 onToggleRightPanel={toggleRightPanel}
4573 onToggleRightPanelMaximized={toggleRightPanelMaximized}
4574 />
4575 );
4576
4561 return (4577 return (
4562 <div className="relative flex min-h-0 min-w-0 flex-1 overflow-hidden bg-background">4578 <div className="relative flex min-h-0 min-w-0 flex-1 overflow-hidden bg-background">
4563 {isElectron && activeThreadRef ? (4579 {isElectron && activeThreadRef ? (
4564 <PreviewAutomationOwner threadRef={activeThreadRef} visible={previewPanelOpen} />4580 <PreviewAutomationOwner threadRef={activeThreadRef} visible={previewPanelOpen} />
4565 ) : null}4581 ) : null}
4566 <PanelLayoutControls4582 {rightPanelOpen ? panelLayoutControls : null}
4567 terminalAvailable={Boolean(activeProject)}
4568 terminalOpen={Boolean(terminalUiState.terminalOpen)}
4569 terminalShortcutLabel={terminalToggleShortcutLabel}
4570 rightPanelAvailable={Boolean(activeProject)}
4571 rightPanelOpen={rightPanelOpen}
4572 rightPanelShortcutLabel={rightPanelToggleShortcutLabel}
4573 rightPanelMaximized={rightPanelMaximized}
4574 canMaximizeRightPanel={canMaximizeRightPanel}
4575 onToggleTerminal={toggleTerminalVisibility}
4576 onToggleRightPanel={toggleRightPanel}
4577 onToggleRightPanelMaximized={toggleRightPanelMaximized}
4578 />
4579 <div4583 <div
4580 className={cn(4584 className={cn(
4581 "flex min-h-0 min-w-0 flex-col overflow-hidden",4585 "flex min-h-0 min-w-0 flex-col overflow-hidden",
...@@ -4590,7 +4594,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -4590,7 +4594,7 @@ function ChatViewContent(props: ChatViewProps) {
4590 "border-b border-border",4594 "border-b border-border",
4591 isElectron4595 isElectron
4592 ? cn(4596 ? cn(
4593 "workspace-topbar drag-region px-3 sm:px-5",4597 "workspace-topbar drag-region relative px-3 sm:px-5",
4594 reserveTitleBarControlInset &&4598 reserveTitleBarControlInset &&
4595 !inlineRightPanelOwnsTitleBar &&4599 !inlineRightPanelOwnsTitleBar &&
4596 "wco:pr-[var(--workspace-native-controls-inset)]",4600 "wco:pr-[var(--workspace-native-controls-inset)]",
...@@ -4598,6 +4602,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -4598,6 +4602,7 @@ function ChatViewContent(props: ChatViewProps) {
4598 : "workspace-topbar pl-[calc(env(safe-area-inset-left)+0.75rem)] pr-[calc(env(safe-area-inset-right)+0.75rem)] sm:pl-[calc(env(safe-area-inset-left)+1.25rem)] sm:pr-[calc(env(safe-area-inset-right)+1.25rem)]",4602 : "workspace-topbar pl-[calc(env(safe-area-inset-left)+0.75rem)] pr-[calc(env(safe-area-inset-right)+0.75rem)] sm:pl-[calc(env(safe-area-inset-left)+1.25rem)] sm:pr-[calc(env(safe-area-inset-right)+1.25rem)]",
4599 )}4603 )}
4600 >4604 >
4605 {!rightPanelOpen ? panelLayoutControls : null}
4601 <ChatHeader4606 <ChatHeader
4602 activeThreadEnvironmentId={activeThread.environmentId}4607 activeThreadEnvironmentId={activeThread.environmentId}
4603 activeThreadId={activeThread.id}4608 activeThreadId={activeThread.id}
...@@ -4873,12 +4878,13 @@ function ChatViewContent(props: ChatViewProps) {...@@ -4873,12 +4878,13 @@ function ChatViewContent(props: ChatViewProps) {
4873 </Suspense>4878 </Suspense>
4874 ) : (activeRightPanelSurface?.kind === "files" ||4879 ) : (activeRightPanelSurface?.kind === "files" ||
4875 activeRightPanelSurface?.kind === "file") &&4880 activeRightPanelSurface?.kind === "file") &&
4876 activeProject ? (4881 activeProject &&
4882 activeWorkspaceRoot ? (
4877 <Suspense fallback={null}>4883 <Suspense fallback={null}>
4878 <FilePreviewPanel4884 <FilePreviewPanel
4879 key={`${activeProject.environmentId}:${activeProject.cwd}`}4885 key={`${activeProject.environmentId}:${activeWorkspaceRoot}`}
4880 environmentId={activeProject.environmentId}4886 environmentId={activeProject.environmentId}
4881 cwd={activeProject.cwd}4887 cwd={activeWorkspaceRoot}
4882 projectName={activeProject.name}4888 projectName={activeProject.name}
4883 relativePath={4889 relativePath={
4884 activeRightPanelSurface.kind === "file"4890 activeRightPanelSurface.kind === "file"
...@@ -4959,12 +4965,13 @@ function ChatViewContent(props: ChatViewProps) {...@@ -4959,12 +4965,13 @@ function ChatViewContent(props: ChatViewProps) {
4959 />4965 />
4960 ) : (activeRightPanelSurface?.kind === "files" ||4966 ) : (activeRightPanelSurface?.kind === "files" ||
4961 activeRightPanelSurface?.kind === "file") &&4967 activeRightPanelSurface?.kind === "file") &&
4962 activeProject ? (4968 activeProject &&
4969 activeWorkspaceRoot ? (
4963 <Suspense fallback={null}>4970 <Suspense fallback={null}>
4964 <FilePreviewPanel4971 <FilePreviewPanel
4965 key={`${activeProject.environmentId}:${activeProject.cwd}`}4972 key={`${activeProject.environmentId}:${activeWorkspaceRoot}`}
4966 environmentId={activeProject.environmentId}4973 environmentId={activeProject.environmentId}
4967 cwd={activeProject.cwd}4974 cwd={activeWorkspaceRoot}
4968 projectName={activeProject.name}4975 projectName={activeProject.name}
4969 relativePath={4976 relativePath={
4970 activeRightPanelSurface.kind === "file"4977 activeRightPanelSurface.kind === "file"
apps/web/src/components/DiffPanel.tsx+15-9
...@@ -23,8 +23,8 @@ import { openInPreferredEditor } from "../editorPreferences";...@@ -23,8 +23,8 @@ import { openInPreferredEditor } from "../editorPreferences";
23import { useCheckpointDiff } from "~/lib/checkpointDiffState";23import { useCheckpointDiff } from "~/lib/checkpointDiffState";
24import { useVcsStatus } from "~/lib/vcsStatusState";24import { useVcsStatus } from "~/lib/vcsStatusState";
25import { cn } from "~/lib/utils";25import { cn } from "~/lib/utils";
26import { openDiffFilePrimaryAction } from "../diffFileActions";
26import { readLocalApi } from "../localApi";27import { readLocalApi } from "../localApi";
27import { resolvePathLinkTarget } from "../terminal-links";
28import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch";28import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch";
29import { useTheme } from "../hooks/useTheme";29import { useTheme } from "../hooks/useTheme";
30import {30import {
...@@ -328,16 +328,22 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {...@@ -328,16 +328,22 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
328 target?.scrollIntoView({ block: "nearest" });328 target?.scrollIntoView({ block: "nearest" });
329 }, [selectedFilePath, renderableFiles]);329 }, [selectedFilePath, renderableFiles]);
330330
331 const openDiffFileInEditor = useCallback(331 const openDiffFile = useCallback(
332 (filePath: string) => {332 (filePath: string) => {
333 const api = readLocalApi();333 openDiffFilePrimaryAction({
334 if (!api) return;334 threadRef: routeThreadRef,
335 const targetPath = activeCwd ? resolvePathLinkTarget(filePath, activeCwd) : filePath;335 filePath,
336 void openInPreferredEditor(api, targetPath).catch((error) => {336 activeCwd,
337 console.warn("Failed to open diff file in editor.", error);337 openInEditor: (targetPath) => {
338 const api = readLocalApi();
339 if (!api) return;
340 void openInPreferredEditor(api, targetPath).catch((error) => {
341 console.warn("Failed to open diff file in editor.", error);
342 });
343 },
338 });344 });
339 },345 },
340 [activeCwd],346 [activeCwd, routeThreadRef],
341 );347 );
342 const toggleDiffFileCollapsed = useCallback((fileKey: string) => {348 const toggleDiffFileCollapsed = useCallback((fileKey: string) => {
343 setCollapsedDiffFileKeys((current) => {349 setCollapsedDiffFileKeys((current) => {
...@@ -664,7 +670,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {...@@ -664,7 +670,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
664 return node.hasAttribute("data-title");670 return node.hasAttribute("data-title");
665 });671 });
666 if (!clickedHeader) return;672 if (!clickedHeader) return;
667 openDiffFileInEditor(filePath);673 openDiffFile(filePath);
668 }}674 }}
669 >675 >
670 <FileDiff676 <FileDiff
apps/web/src/components/DiffPanelShell.browser.tsx created+22
...@@ -0,0 +1,22 @@
1import "../index.css";
2
3import { describe, expect, it } from "vite-plus/test";
4import { render } from "vitest-browser-react";
5
6import { DiffPanelShell } from "./DiffPanelShell";
7
8describe("DiffPanelShell", () => {
9 it("uses the shared compact surface subheader in embedded mode", async () => {
10 const screen = await render(
11 <DiffPanelShell mode="embedded" header={<span>Diff controls</span>}>
12 <div>Diff content</div>
13 </DiffPanelShell>,
14 );
15 const subheader = screen.container.querySelector<HTMLElement>("[data-surface-subheader]");
16
17 expect(subheader).not.toBeNull();
18 expect(subheader?.getBoundingClientRect().height).toBe(40);
19 expect(window.getComputedStyle(subheader!).borderTopWidth).toBe("0px");
20 expect(window.getComputedStyle(subheader!).borderBottomWidth).toBe("1px");
21 });
22});
apps/web/src/components/DiffPanelShell.tsx+3-3
...@@ -13,7 +13,7 @@ function getDiffPanelHeaderRowClassName(mode: DiffPanelMode) {...@@ -13,7 +13,7 @@ function getDiffPanelHeaderRowClassName(mode: DiffPanelMode) {
13 "flex items-center justify-between gap-2 px-4",13 "flex items-center justify-between gap-2 px-4",
14 shouldUseDragRegion14 shouldUseDragRegion
15 ? "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)]"15 ? "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)]"
16 : "h-12 wco:max-h-[env(titlebar-area-height)]",16 : "surface-subheader",
17 );17 );
18}18}
1919
...@@ -36,8 +36,8 @@ export function DiffPanelShell(props: {...@@ -36,8 +36,8 @@ export function DiffPanelShell(props: {
36 {shouldUseDragRegion ? (36 {shouldUseDragRegion ? (
37 <div className={getDiffPanelHeaderRowClassName(props.mode)}>{props.header}</div>37 <div className={getDiffPanelHeaderRowClassName(props.mode)}>{props.header}</div>
38 ) : (38 ) : (
39 <div className="border-b border-border">39 <div className={getDiffPanelHeaderRowClassName(props.mode)} data-surface-subheader>
40 <div className={getDiffPanelHeaderRowClassName(props.mode)}>{props.header}</div>40 {props.header}
41 </div>41 </div>
42 )}42 )}
43 {props.children}43 {props.children}
apps/web/src/components/RightPanelTabs.tsx+2-3
...@@ -211,8 +211,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) {...@@ -211,8 +211,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) {
211 className={cn(211 className={cn(
212 "px-2 pr-28",212 "px-2 pr-28",
213 props.mode === "inline" ? "workspace-topbar" : "flex h-10 shrink-0 items-center",213 props.mode === "inline" ? "workspace-topbar" : "flex h-10 shrink-0 items-center",
214 ownsDesktopTitleBar &&214 ownsDesktopTitleBar && "wco:pr-[calc(var(--workspace-native-controls-inset)+6rem)]",
215 "drag-region wco:pr-[calc(var(--workspace-native-controls-inset)+6rem)]",
216 )}215 )}
217 data-right-panel-tabbar216 data-right-panel-tabbar
218 >217 >
...@@ -220,7 +219,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) {...@@ -220,7 +219,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) {
220 ref={tabListRef}219 ref={tabListRef}
221 hideScrollbars220 hideScrollbars
222 scrollFade221 scrollFade
223 className="min-w-0 flex-1 rounded-none"222 className={cn("min-w-0 flex-1 rounded-none", ownsDesktopTitleBar && "drag-region")}
224 data-right-panel-tab-list223 data-right-panel-tab-list
225 >224 >
226 <div className="flex h-full w-max min-w-full items-center gap-1">225 <div className="flex h-full w-max min-w-full items-center gap-1">
apps/web/src/components/chat/PanelLayoutControls.tsx+7-4
...@@ -34,13 +34,16 @@ export const PanelLayoutControls = memo(function PanelLayoutControls({...@@ -34,13 +34,16 @@ export const PanelLayoutControls = memo(function PanelLayoutControls({
34 onToggleRightPanelMaximized,34 onToggleRightPanelMaximized,
35}: PanelLayoutControlsProps) {35}: PanelLayoutControlsProps) {
36 return (36 return (
37 <div className={cn("workspace-titlebar-controls z-50 gap-1")} data-panel-layout-controls>37 <div
38 className={cn("workspace-titlebar-controls z-50 gap-1 [-webkit-app-region:no-drag]")}
39 data-panel-layout-controls
40 >
38 {rightPanelOpen ? (41 {rightPanelOpen ? (
39 <Tooltip>42 <Tooltip>
40 <TooltipTrigger43 <TooltipTrigger
41 render={44 render={
42 <Toggle45 <Toggle
43 className="shrink-0"46 className="shrink-0 [-webkit-app-region:no-drag]"
44 pressed={rightPanelMaximized}47 pressed={rightPanelMaximized}
45 onPressedChange={onToggleRightPanelMaximized}48 onPressedChange={onToggleRightPanelMaximized}
46 aria-label={rightPanelMaximized ? "Restore panel size" : "Maximize panel"}49 aria-label={rightPanelMaximized ? "Restore panel size" : "Maximize panel"}
...@@ -69,7 +72,7 @@ export const PanelLayoutControls = memo(function PanelLayoutControls({...@@ -69,7 +72,7 @@ export const PanelLayoutControls = memo(function PanelLayoutControls({
69 <TooltipTrigger72 <TooltipTrigger
70 render={73 render={
71 <Toggle74 <Toggle
72 className="shrink-0"75 className="shrink-0 [-webkit-app-region:no-drag]"
73 pressed={terminalOpen}76 pressed={terminalOpen}
74 onPressedChange={onToggleTerminal}77 onPressedChange={onToggleTerminal}
75 aria-label="Toggle terminal drawer"78 aria-label="Toggle terminal drawer"
...@@ -91,7 +94,7 @@ export const PanelLayoutControls = memo(function PanelLayoutControls({...@@ -91,7 +94,7 @@ export const PanelLayoutControls = memo(function PanelLayoutControls({
91 <TooltipTrigger94 <TooltipTrigger
92 render={95 render={
93 <Toggle96 <Toggle
94 className="shrink-0"97 className="shrink-0 [-webkit-app-region:no-drag]"
95 pressed={rightPanelOpen}98 pressed={rightPanelOpen}
96 onPressedChange={onToggleRightPanel}99 onPressedChange={onToggleRightPanel}
97 aria-label="Toggle right panel"100 aria-label="Toggle right panel"
apps/web/src/components/files/FilePreviewPanel.tsx+1-1
...@@ -162,7 +162,7 @@ export default function FilePreviewPanel({...@@ -162,7 +162,7 @@ export default function FilePreviewPanel({
162 return (162 return (
163 <div className="flex min-h-0 flex-1 flex-col overflow-hidden bg-background">163 <div className="flex min-h-0 flex-1 flex-col overflow-hidden bg-background">
164 {relativePath ? (164 {relativePath ? (
165 <div className="flex h-11 shrink-0 items-center gap-2 border-y border-border/60 px-3">165 <div className="surface-subheader gap-2 px-3" data-surface-subheader>
166 <ScrollArea166 <ScrollArea
167 ref={breadcrumbRef}167 ref={breadcrumbRef}
168 hideScrollbars168 hideScrollbars
apps/web/src/components/preview/PreviewChromeRow.browser.tsx+10
...@@ -20,6 +20,16 @@ const defaultProps = {...@@ -20,6 +20,16 @@ const defaultProps = {
20};20};
2121
22describe("PreviewChromeRow", () => {22describe("PreviewChromeRow", () => {
23 it("uses the shared compact surface subheader treatment", async () => {
24 const screen = await render(<PreviewChromeRow {...defaultProps} />);
25 const subheader = screen.container.querySelector<HTMLElement>("[data-surface-subheader]");
26
27 expect(subheader).not.toBeNull();
28 expect(subheader?.getBoundingClientRect().height).toBe(40);
29 expect(window.getComputedStyle(subheader!).borderTopWidth).toBe("0px");
30 expect(window.getComputedStyle(subheader!).borderBottomWidth).toBe("1px");
31 });
32
23 it("only focuses the URL input after an explicit focus request", async () => {33 it("only focuses the URL input after an explicit focus request", async () => {
24 const previouslyFocused = document.createElement("button");34 const previouslyFocused = document.createElement("button");
25 document.body.append(previouslyFocused);35 document.body.append(previouslyFocused);
apps/web/src/components/preview/PreviewChromeRow.tsx+1-4
...@@ -110,10 +110,7 @@ export function PreviewChromeRow({...@@ -110,10 +110,7 @@ export function PreviewChromeRow({
110110
111 return (111 return (
112 <div className="relative">112 <div className="relative">
113 <form113 <form onSubmit={submit} className="surface-subheader gap-1 px-2" data-surface-subheader>
114 onSubmit={submit}
115 className="flex h-10 items-center gap-1 border-b border-border/70 bg-background px-2"
116 >
117 <div className="flex items-center gap-0.5" role="group" aria-label="Navigation">114 <div className="flex items-center gap-0.5" role="group" aria-label="Navigation">
118 <Tooltip>115 <Tooltip>
119 <TooltipTrigger116 <TooltipTrigger
apps/web/src/diffFileActions.test.ts created+51
...@@ -0,0 +1,51 @@
1import { scopeThreadRef } from "@t3tools/client-runtime";
2import { EnvironmentId, ThreadId } from "@t3tools/contracts";
3import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
4
5import { openDiffFilePrimaryAction } from "./diffFileActions";
6import { selectThreadRightPanelState, useRightPanelStore } from "./rightPanelStore";
7
8const THREAD_REF = scopeThreadRef(
9 EnvironmentId.make("environment-local"),
10 ThreadId.make("thread-1"),
11);
12
13describe("openDiffFilePrimaryAction", () => {
14 beforeEach(() => {
15 useRightPanelStore.setState({ byThreadKey: {} });
16 });
17
18 it("opens diff files in the thread file viewer", () => {
19 const openInEditor = vi.fn();
20
21 openDiffFilePrimaryAction({
22 threadRef: THREAD_REF,
23 filePath: "apps/web/src/components/DiffPanel.tsx",
24 activeCwd: "/repo/project",
25 openInEditor,
26 });
27
28 expect(
29 selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, THREAD_REF),
30 ).toMatchObject({
31 isOpen: true,
32 activeSurfaceId: "file:apps/web/src/components/DiffPanel.tsx",
33 });
34 expect(openInEditor).not.toHaveBeenCalled();
35 });
36
37 it("falls back to the editor without thread context", () => {
38 const openInEditor = vi.fn();
39
40 openDiffFilePrimaryAction({
41 threadRef: null,
42 filePath: "apps/web/src/components/DiffPanel.tsx",
43 activeCwd: "/repo/project",
44 openInEditor,
45 });
46
47 expect(openInEditor).toHaveBeenCalledWith(
48 "/repo/project/apps/web/src/components/DiffPanel.tsx",
49 );
50 });
51});
apps/web/src/diffFileActions.ts created+25
...@@ -0,0 +1,25 @@
1import type { ScopedThreadRef } from "@t3tools/contracts";
2
3import { useRightPanelStore } from "./rightPanelStore";
4import { resolvePathLinkTarget } from "./terminal-links";
5
6interface OpenDiffFilePrimaryActionInput {
7 readonly threadRef: ScopedThreadRef | null;
8 readonly filePath: string;
9 readonly activeCwd: string | undefined;
10 readonly openInEditor: (targetPath: string) => void;
11}
12
13export function openDiffFilePrimaryAction({
14 threadRef,
15 filePath,
16 activeCwd,
17 openInEditor,
18}: OpenDiffFilePrimaryActionInput): void {
19 if (threadRef) {
20 useRightPanelStore.getState().openFile(threadRef, filePath);
21 return;
22 }
23
24 openInEditor(activeCwd ? resolvePathLinkTarget(filePath, activeCwd) : filePath);
25}
apps/web/src/index.css+5
...@@ -111,6 +111,11 @@...@@ -111,6 +111,11 @@
111 display: flex;111 display: flex;
112 height: var(--workspace-topbar-height);112 height: var(--workspace-topbar-height);
113 align-items: center;113 align-items: center;
114 -webkit-app-region: no-drag;
115 }
116
117 .surface-subheader {
118 @apply flex h-10 min-h-10 shrink-0 items-center border-b border-border/60 bg-background;
114 }119 }
115}120}
116121
apps/web/src/keybindings.test.ts+11
...@@ -621,6 +621,17 @@ describe("resolveShortcutCommand", () => {...@@ -621,6 +621,17 @@ describe("resolveShortcutCommand", () => {
621 "thread.next",621 "thread.next",
622 );622 );
623 });623 });
624
625 it("matches Option-modified letters using the physical key code on macOS", () => {
626 assert.strictEqual(
627 resolveShortcutCommand(
628 event({ key: "∫", code: "KeyB", metaKey: true, altKey: true }),
629 DEFAULT_BINDINGS,
630 { platform: "MacIntel" },
631 ),
632 "rightPanel.toggle",
633 );
634 });
624});635});
625636
626describe("formatShortcutLabel", () => {637describe("formatShortcutLabel", () => {
apps/web/src/keybindings.ts+4
...@@ -72,6 +72,10 @@ function normalizeEventKey(key: string): string {...@@ -72,6 +72,10 @@ function normalizeEventKey(key: string): string {
7272
73function resolveEventKeys(event: ShortcutEventLike): Set<string> {73function resolveEventKeys(event: ShortcutEventLike): Set<string> {
74 const keys = new Set([normalizeEventKey(event.key)]);74 const keys = new Set([normalizeEventKey(event.key)]);
75 const letterCode = event.code?.match(/^Key([A-Z])$/)?.[1];
76 if (letterCode) {
77 keys.add(letterCode.toLowerCase());
78 }
75 const aliases = event.code ? EVENT_CODE_KEY_ALIASES[event.code] : undefined;79 const aliases = event.code ? EVENT_CODE_KEY_ALIASES[event.code] : undefined;
76 if (!aliases) return keys;80 if (!aliases) return keys;
7781