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() {
6868 once: vi.fn(),
6969 restore: vi.fn(),
7070 setBackgroundColor: vi.fn(),
71 setAutoHideCursor: vi.fn(),
7172 setTitle: vi.fn(),
7273 setTitleBarOverlay: vi.fn(),
7374 show: vi.fn(),
......@@ -78,6 +79,7 @@ function makeFakeBrowserWindow() {
7879 window: window as unknown as Electron.BrowserWindow,
7980 loadURL: window.loadURL,
8081 openDevTools: webContents.openDevTools,
82 setAutoHideCursor: window.setAutoHideCursor,
8183 webContentsListeners,
8284 };
8385}
......@@ -134,10 +136,17 @@ function makeTestLayer(input: {
134136 readonly window: Electron.BrowserWindow;
135137 readonly createCount: Ref.Ref<number>;
136138 readonly mainWindow: Ref.Ref<Option.Option<Electron.BrowserWindow>>;
139 readonly createdWindowOptions?: Electron.BrowserWindowConstructorOptions[];
137140 readonly openedExternalUrls?: unknown[];
138141}) {
139142 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 ),
141150 main: Ref.get(input.mainWindow),
142151 currentMainOrFirst: Ref.get(input.mainWindow),
143152 focusedMainOrFirst: Ref.get(input.mainWindow),
......@@ -205,10 +214,12 @@ describe("DesktopWindow", () => {
205214 const fakeWindow = makeFakeBrowserWindow();
206215 const createCount = yield* Ref.make(0);
207216 const mainWindow = yield* Ref.make<Option.Option<Electron.BrowserWindow>>(Option.none());
217 const createdWindowOptions: Electron.BrowserWindowConstructorOptions[] = [];
208218 const layer = makeTestLayer({
209219 window: fakeWindow.window,
210220 createCount,
211221 mainWindow,
222 createdWindowOptions,
212223 });
213224
214225 yield* Effect.gen(function* () {
......@@ -218,6 +229,8 @@ describe("DesktopWindow", () => {
218229
219230 yield* desktopWindow.handleBackendReady;
220231 assert.equal(yield* Ref.get(createCount), 1);
232 assert.isTrue(createdWindowOptions[0]?.disableAutoHideCursor);
233 assert.deepEqual(fakeWindow.setAutoHideCursor.mock.calls, [[false]]);
221234 assert.deepEqual(fakeWindow.loadURL.mock.calls[0], ["http://127.0.0.1:5733/"]);
222235 assert.equal(fakeWindow.openDevTools.mock.calls.length, 1);
223236 }).pipe(Effect.provide(layer));
apps/desktop/src/window/DesktopWindow.ts+5
......@@ -193,6 +193,7 @@ const make = Effect.gen(function* () {
193193 minHeight: 620,
194194 show: false,
195195 autoHideMenuBar: true,
196 ...(environment.platform === "darwin" ? { disableAutoHideCursor: true } : {}),
196197 backgroundColor: getInitialWindowBackgroundColor(shouldUseDarkColors),
197198 ...iconOption,
198199 title: environment.displayName,
......@@ -206,6 +207,10 @@ const make = Effect.gen(function* () {
206207 },
207208 });
208209
210 if (environment.platform === "darwin") {
211 window.setAutoHideCursor(false);
212 }
213
209214 yield* previewManager.setMainWindow(window);
210215 window.webContents.on("will-attach-webview", (event, webPreferences, params) => {
211216 if (
apps/web/src/components/ChatView.browser.tsx+118-12
......@@ -2146,6 +2146,11 @@ describe("ChatView timeline estimator parity (full app)", () => {
21462146 expect(panelLayoutControls.getBoundingClientRect().top).toBe(
21472147 chatHeader.getBoundingClientRect().top,
21482148 );
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);
21492154 const initialTerminalRect = terminalToggle.getBoundingClientRect();
21502155 const initialRightPanelRect = rightPanelToggle.getBoundingClientRect();
21512156 const initialControlRects = [initialTerminalRect, initialRightPanelRect];
......@@ -2179,17 +2184,51 @@ describe("ChatView timeline estimator parity (full app)", () => {
21792184 () => document.querySelector<HTMLElement>("[data-right-panel-tabbar]"),
21802185 "Unable to find right panel tab bar.",
21812186 );
2187 const rightPanelTabList = await waitForElement(
2188 () => document.querySelector<HTMLElement>("[data-right-panel-tab-list]"),
2189 "Unable to find right panel tab list.",
2190 );
21822191 const maximizeRect = maximizeButton.getBoundingClientRect();
21832192 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 );
21842206 expect(document.querySelector('button[aria-label="Add panel surface"]')).toBeNull();
21852207 expect(rightPanelTabbarRect.height).toBe(52);
21862208 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");
21872223 expect(maximizeRect.width).toBe(28);
21882224 expect(maximizeRect.height).toBe(28);
21892225 expect(maximizeRect.top).toBe(initialTerminalRect.top);
21902226 expect(initialTerminalRect.left - maximizeRect.right).toBe(4);
2191 expect(terminalToggle.getBoundingClientRect().left).toBeCloseTo(initialTerminalRect.left, 1);
2192 expect(rightPanelToggle.getBoundingClientRect().left).toBeCloseTo(
2227 expect(openTerminalToggle.getBoundingClientRect().left).toBeCloseTo(
2228 initialTerminalRect.left,
2229 1,
2230 );
2231 expect(openRightPanelToggle.getBoundingClientRect().left).toBeCloseTo(
21932232 initialRightPanelRect.left,
21942233 1,
21952234 );
......@@ -2206,10 +2245,10 @@ describe("ChatView timeline estimator parity (full app)", () => {
22062245
22072246 document.documentElement.classList.add("wco");
22082247 expect(rightPanelTabbar.getBoundingClientRect().height).toBe(
2209 panelLayoutControls.getBoundingClientRect().height,
2248 openPanelLayoutControls.getBoundingClientRect().height,
22102249 );
22112250 expect(rightPanelTabbar.getBoundingClientRect().top).toBe(
2212 panelLayoutControls.getBoundingClientRect().top,
2251 openPanelLayoutControls.getBoundingClientRect().top,
22132252 );
22142253 document.documentElement.classList.remove("wco");
22152254
......@@ -2227,20 +2266,83 @@ describe("ChatView timeline estimator parity (full app)", () => {
22272266 expect(
22282267 document.querySelector<HTMLButtonElement>('button[aria-label="Restore panel size"]'),
22292268 ).not.toBeNull();
2230 expect(terminalToggle.getBoundingClientRect().left).toBeCloseTo(
2231 initialTerminalRect.left,
2232 1,
2233 );
2234 expect(rightPanelToggle.getBoundingClientRect().left).toBeCloseTo(
2235 initialRightPanelRect.left,
2236 1,
2237 );
2269 expect(
2270 document
2271 .querySelector<HTMLButtonElement>('button[aria-label="Toggle terminal drawer"]')
2272 ?.getBoundingClientRect().left,
2273 ).toBeCloseTo(initialTerminalRect.left, 1);
2274 expect(
2275 document
2276 .querySelector<HTMLButtonElement>('button[aria-label="Toggle right panel"]')
2277 ?.getBoundingClientRect().left,
2278 ).toBeCloseTo(initialRightPanelRect.left, 1);
22382279 });
22392280 } finally {
22402281 await mounted.cleanup();
22412282 }
22422283 });
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
22442346 it("scrolls file tabs and preserves the workspace explorer across file previews", async () => {
22452347 const workspaceEntries = [
22462348 { path: "src", kind: "directory" as const },
......@@ -2355,6 +2457,7 @@ describe("ChatView timeline estimator parity (full app)", () => {
23552457 () => document.querySelector<HTMLElement>("[data-file-breadcrumbs]"),
23562458 "Unable to find the responsive file breadcrumbs.",
23572459 );
2460 const fileSubheader = breadcrumbs.closest<HTMLElement>("[data-surface-subheader]");
23582461 const breadcrumbViewport = await waitForElement(
23592462 () => breadcrumbs.querySelector<HTMLElement>('[data-slot="scroll-area-viewport"]'),
23602463 "Unable to find the file breadcrumb viewport.",
......@@ -2382,6 +2485,9 @@ describe("ChatView timeline estimator parity (full app)", () => {
23822485 expect(explorerToggle.getAttribute("aria-pressed")).toBe("true");
23832486 expect(explorerToggle.getBoundingClientRect().width).toBe(28);
23842487 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");
23852491 });
23862492
23872493 const fileSearchButton = await waitForElement(
apps/web/src/components/ChatView.tsx+27-20
......@@ -4558,24 +4558,28 @@ function ChatViewContent(props: ChatViewProps) {
45584558 return <NoActiveThreadState />;
45594559 }
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
45614577 return (
45624578 <div className="relative flex min-h-0 min-w-0 flex-1 overflow-hidden bg-background">
45634579 {isElectron && activeThreadRef ? (
45644580 <PreviewAutomationOwner threadRef={activeThreadRef} visible={previewPanelOpen} />
45654581 ) : null}
4566 <PanelLayoutControls
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 />
4582 {rightPanelOpen ? panelLayoutControls : null}
45794583 <div
45804584 className={cn(
45814585 "flex min-h-0 min-w-0 flex-col overflow-hidden",
......@@ -4590,7 +4594,7 @@ function ChatViewContent(props: ChatViewProps) {
45904594 "border-b border-border",
45914595 isElectron
45924596 ? cn(
4593 "workspace-topbar drag-region px-3 sm:px-5",
4597 "workspace-topbar drag-region relative px-3 sm:px-5",
45944598 reserveTitleBarControlInset &&
45954599 !inlineRightPanelOwnsTitleBar &&
45964600 "wco:pr-[var(--workspace-native-controls-inset)]",
......@@ -4598,6 +4602,7 @@ function ChatViewContent(props: ChatViewProps) {
45984602 : "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)]",
45994603 )}
46004604 >
4605 {!rightPanelOpen ? panelLayoutControls : null}
46014606 <ChatHeader
46024607 activeThreadEnvironmentId={activeThread.environmentId}
46034608 activeThreadId={activeThread.id}
......@@ -4873,12 +4878,13 @@ function ChatViewContent(props: ChatViewProps) {
48734878 </Suspense>
48744879 ) : (activeRightPanelSurface?.kind === "files" ||
48754880 activeRightPanelSurface?.kind === "file") &&
4876 activeProject ? (
4881 activeProject &&
4882 activeWorkspaceRoot ? (
48774883 <Suspense fallback={null}>
48784884 <FilePreviewPanel
4879 key={`${activeProject.environmentId}:${activeProject.cwd}`}
4885 key={`${activeProject.environmentId}:${activeWorkspaceRoot}`}
48804886 environmentId={activeProject.environmentId}
4881 cwd={activeProject.cwd}
4887 cwd={activeWorkspaceRoot}
48824888 projectName={activeProject.name}
48834889 relativePath={
48844890 activeRightPanelSurface.kind === "file"
......@@ -4959,12 +4965,13 @@ function ChatViewContent(props: ChatViewProps) {
49594965 />
49604966 ) : (activeRightPanelSurface?.kind === "files" ||
49614967 activeRightPanelSurface?.kind === "file") &&
4962 activeProject ? (
4968 activeProject &&
4969 activeWorkspaceRoot ? (
49634970 <Suspense fallback={null}>
49644971 <FilePreviewPanel
4965 key={`${activeProject.environmentId}:${activeProject.cwd}`}
4972 key={`${activeProject.environmentId}:${activeWorkspaceRoot}`}
49664973 environmentId={activeProject.environmentId}
4967 cwd={activeProject.cwd}
4974 cwd={activeWorkspaceRoot}
49684975 projectName={activeProject.name}
49694976 relativePath={
49704977 activeRightPanelSurface.kind === "file"
apps/web/src/components/DiffPanel.tsx+15-9
......@@ -23,8 +23,8 @@ import { openInPreferredEditor } from "../editorPreferences";
2323import { useCheckpointDiff } from "~/lib/checkpointDiffState";
2424import { useVcsStatus } from "~/lib/vcsStatusState";
2525import { cn } from "~/lib/utils";
26import { openDiffFilePrimaryAction } from "../diffFileActions";
2627import { readLocalApi } from "../localApi";
27import { resolvePathLinkTarget } from "../terminal-links";
2828import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch";
2929import { useTheme } from "../hooks/useTheme";
3030import {
......@@ -328,16 +328,22 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
328328 target?.scrollIntoView({ block: "nearest" });
329329 }, [selectedFilePath, renderableFiles]);
330330
331 const openDiffFileInEditor = useCallback(
331 const openDiffFile = useCallback(
332332 (filePath: string) => {
333 const api = readLocalApi();
334 if (!api) return;
335 const targetPath = activeCwd ? resolvePathLinkTarget(filePath, activeCwd) : filePath;
336 void openInPreferredEditor(api, targetPath).catch((error) => {
337 console.warn("Failed to open diff file in editor.", error);
333 openDiffFilePrimaryAction({
334 threadRef: routeThreadRef,
335 filePath,
336 activeCwd,
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 },
338344 });
339345 },
340 [activeCwd],
346 [activeCwd, routeThreadRef],
341347 );
342348 const toggleDiffFileCollapsed = useCallback((fileKey: string) => {
343349 setCollapsedDiffFileKeys((current) => {
......@@ -664,7 +670,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
664670 return node.hasAttribute("data-title");
665671 });
666672 if (!clickedHeader) return;
667 openDiffFileInEditor(filePath);
673 openDiffFile(filePath);
668674 }}
669675 >
670676 <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) {
1313 "flex items-center justify-between gap-2 px-4",
1414 shouldUseDragRegion
1515 ? "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",
1717 );
1818}
1919
......@@ -36,8 +36,8 @@ export function DiffPanelShell(props: {
3636 {shouldUseDragRegion ? (
3737 <div className={getDiffPanelHeaderRowClassName(props.mode)}>{props.header}</div>
3838 ) : (
39 <div className="border-b border-border">
40 <div className={getDiffPanelHeaderRowClassName(props.mode)}>{props.header}</div>
39 <div className={getDiffPanelHeaderRowClassName(props.mode)} data-surface-subheader>
40 {props.header}
4141 </div>
4242 )}
4343 {props.children}
apps/web/src/components/RightPanelTabs.tsx+2-3
......@@ -211,8 +211,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) {
211211 className={cn(
212212 "px-2 pr-28",
213213 props.mode === "inline" ? "workspace-topbar" : "flex h-10 shrink-0 items-center",
214 ownsDesktopTitleBar &&
215 "drag-region wco:pr-[calc(var(--workspace-native-controls-inset)+6rem)]",
214 ownsDesktopTitleBar && "wco:pr-[calc(var(--workspace-native-controls-inset)+6rem)]",
216215 )}
217216 data-right-panel-tabbar
218217 >
......@@ -220,7 +219,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) {
220219 ref={tabListRef}
221220 hideScrollbars
222221 scrollFade
223 className="min-w-0 flex-1 rounded-none"
222 className={cn("min-w-0 flex-1 rounded-none", ownsDesktopTitleBar && "drag-region")}
224223 data-right-panel-tab-list
225224 >
226225 <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({
3434 onToggleRightPanelMaximized,
3535}: PanelLayoutControlsProps) {
3636 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 >
3841 {rightPanelOpen ? (
3942 <Tooltip>
4043 <TooltipTrigger
4144 render={
4245 <Toggle
43 className="shrink-0"
46 className="shrink-0 [-webkit-app-region:no-drag]"
4447 pressed={rightPanelMaximized}
4548 onPressedChange={onToggleRightPanelMaximized}
4649 aria-label={rightPanelMaximized ? "Restore panel size" : "Maximize panel"}
......@@ -69,7 +72,7 @@ export const PanelLayoutControls = memo(function PanelLayoutControls({
6972 <TooltipTrigger
7073 render={
7174 <Toggle
72 className="shrink-0"
75 className="shrink-0 [-webkit-app-region:no-drag]"
7376 pressed={terminalOpen}
7477 onPressedChange={onToggleTerminal}
7578 aria-label="Toggle terminal drawer"
......@@ -91,7 +94,7 @@ export const PanelLayoutControls = memo(function PanelLayoutControls({
9194 <TooltipTrigger
9295 render={
9396 <Toggle
94 className="shrink-0"
97 className="shrink-0 [-webkit-app-region:no-drag]"
9598 pressed={rightPanelOpen}
9699 onPressedChange={onToggleRightPanel}
97100 aria-label="Toggle right panel"
apps/web/src/components/files/FilePreviewPanel.tsx+1-1
......@@ -162,7 +162,7 @@ export default function FilePreviewPanel({
162162 return (
163163 <div className="flex min-h-0 flex-1 flex-col overflow-hidden bg-background">
164164 {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>
166166 <ScrollArea
167167 ref={breadcrumbRef}
168168 hideScrollbars
apps/web/src/components/preview/PreviewChromeRow.browser.tsx+10
......@@ -20,6 +20,16 @@ const defaultProps = {
2020};
2121
2222describe("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
2333 it("only focuses the URL input after an explicit focus request", async () => {
2434 const previouslyFocused = document.createElement("button");
2535 document.body.append(previouslyFocused);
apps/web/src/components/preview/PreviewChromeRow.tsx+1-4
......@@ -110,10 +110,7 @@ export function PreviewChromeRow({
110110
111111 return (
112112 <div className="relative">
113 <form
114 onSubmit={submit}
115 className="flex h-10 items-center gap-1 border-b border-border/70 bg-background px-2"
116 >
113 <form onSubmit={submit} className="surface-subheader gap-1 px-2" data-surface-subheader>
117114 <div className="flex items-center gap-0.5" role="group" aria-label="Navigation">
118115 <Tooltip>
119116 <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 @@
111111 display: flex;
112112 height: var(--workspace-topbar-height);
113113 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;
114119 }
115120}
116121
apps/web/src/keybindings.test.ts+11
......@@ -621,6 +621,17 @@ describe("resolveShortcutCommand", () => {
621621 "thread.next",
622622 );
623623 });
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 });
624635});
625636
626637describe("formatShortcutLabel", () => {
apps/web/src/keybindings.ts+4
......@@ -72,6 +72,10 @@ function normalizeEventKey(key: string): string {
7272
7373function resolveEventKeys(event: ShortcutEventLike): Set<string> {
7474 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 }
7579 const aliases = event.code ? EVENT_CODE_KEY_ALIASES[event.code] : undefined;
7680 if (!aliases) return keys;
7781