| author | |
| committer | |
| log | c2d44a312c072c29056f7f87938026f366879d75 |
| tree | 378f818b85662270015f97d487ff3934aa4bbbef |
| parent | de8bdc10fa3d92c49bc7a4365926f030d71682fd |
- 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 matching17 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 | }); |
| 213 | 224 | ||
| 214 | yield* Effect.gen(function* () { | 225 | yield* Effect.gen(function* () { |
| ... | @@ -218,6 +229,8 @@ describe("DesktopWindow", () => { | ... | @@ -218,6 +229,8 @@ describe("DesktopWindow", () => { |
| 218 | 229 | ||
| 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 | }); |
| 208 | 209 | ||
| 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)", () => { |
| 2206 | 2245 | ||
| 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"); |
| 2215 | 2254 | ||
| ... | @@ -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 | }); |
| 2243 | 2284 | ||
| 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 | }); |
| 2386 | 2492 | ||
| 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 | } |
| 4560 | 4560 | ||
| 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 | <PanelLayoutControls | 4582 | {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 | <div | 4583 | <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 | isElectron | 4595 | 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 | <ChatHeader | 4606 | <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 | <FilePreviewPanel | 4884 | <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 | <FilePreviewPanel | 4971 | <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"; |
| 23 | import { useCheckpointDiff } from "~/lib/checkpointDiffState"; | 23 | import { useCheckpointDiff } from "~/lib/checkpointDiffState"; |
| 24 | import { useVcsStatus } from "~/lib/vcsStatusState"; | 24 | import { useVcsStatus } from "~/lib/vcsStatusState"; |
| 25 | import { cn } from "~/lib/utils"; | 25 | import { cn } from "~/lib/utils"; |
| 26 | import { openDiffFilePrimaryAction } from "../diffFileActions"; | ||
| 26 | import { readLocalApi } from "../localApi"; | 27 | import { readLocalApi } from "../localApi"; |
| 27 | import { resolvePathLinkTarget } from "../terminal-links"; | ||
| 28 | import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; | 28 | import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; |
| 29 | import { useTheme } from "../hooks/useTheme"; | 29 | import { useTheme } from "../hooks/useTheme"; |
| 30 | import { | 30 | import { |
| ... | @@ -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]); |
| 330 | 330 | ||
| 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 | <FileDiff | 676 | <FileDiff |
apps/web/src/components/DiffPanelShell.browser.tsx created+22| ... | @@ -0,0 +1,22 @@ | ||
| 1 | import "../index.css"; | ||
| 2 | |||
| 3 | import { describe, expect, it } from "vite-plus/test"; | ||
| 4 | import { render } from "vitest-browser-react"; | ||
| 5 | |||
| 6 | import { DiffPanelShell } from "./DiffPanelShell"; | ||
| 7 | |||
| 8 | describe("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 | shouldUseDragRegion | 14 | 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 | } |
| 19 | 19 | ||
| ... | @@ -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-tabbar | 216 | 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 | hideScrollbars | 220 | hideScrollbars |
| 222 | scrollFade | 221 | 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-list | 223 | 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 | <TooltipTrigger | 43 | <TooltipTrigger |
| 41 | render={ | 44 | render={ |
| 42 | <Toggle | 45 | <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 | <TooltipTrigger | 72 | <TooltipTrigger |
| 70 | render={ | 73 | render={ |
| 71 | <Toggle | 74 | <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 | <TooltipTrigger | 94 | <TooltipTrigger |
| 92 | render={ | 95 | render={ |
| 93 | <Toggle | 96 | <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 | <ScrollArea | 166 | <ScrollArea |
| 167 | ref={breadcrumbRef} | 167 | ref={breadcrumbRef} |
| 168 | hideScrollbars | 168 | hideScrollbars |
apps/web/src/components/preview/PreviewChromeRow.browser.tsx+10| ... | @@ -20,6 +20,16 @@ const defaultProps = { | ... | @@ -20,6 +20,16 @@ const defaultProps = { |
| 20 | }; | 20 | }; |
| 21 | 21 | ||
| 22 | describe("PreviewChromeRow", () => { | 22 | describe("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({ |
| 110 | 110 | ||
| 111 | return ( | 111 | return ( |
| 112 | <div className="relative"> | 112 | <div className="relative"> |
| 113 | <form | 113 | <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 | <TooltipTrigger | 116 | <TooltipTrigger |
apps/web/src/diffFileActions.test.ts created+51| ... | @@ -0,0 +1,51 @@ | ||
| 1 | import { scopeThreadRef } from "@t3tools/client-runtime"; | ||
| 2 | import { EnvironmentId, ThreadId } from "@t3tools/contracts"; | ||
| 3 | import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; | ||
| 4 | |||
| 5 | import { openDiffFilePrimaryAction } from "./diffFileActions"; | ||
| 6 | import { selectThreadRightPanelState, useRightPanelStore } from "./rightPanelStore"; | ||
| 7 | |||
| 8 | const THREAD_REF = scopeThreadRef( | ||
| 9 | EnvironmentId.make("environment-local"), | ||
| 10 | ThreadId.make("thread-1"), | ||
| 11 | ); | ||
| 12 | |||
| 13 | describe("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 @@ | ||
| 1 | import type { ScopedThreadRef } from "@t3tools/contracts"; | ||
| 2 | |||
| 3 | import { useRightPanelStore } from "./rightPanelStore"; | ||
| 4 | import { resolvePathLinkTarget } from "./terminal-links"; | ||
| 5 | |||
| 6 | interface OpenDiffFilePrimaryActionInput { | ||
| 7 | readonly threadRef: ScopedThreadRef | null; | ||
| 8 | readonly filePath: string; | ||
| 9 | readonly activeCwd: string | undefined; | ||
| 10 | readonly openInEditor: (targetPath: string) => void; | ||
| 11 | } | ||
| 12 | |||
| 13 | export 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 | } |
| 116 | 121 |
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 | }); |
| 625 | 636 | ||
| 626 | describe("formatShortcutLabel", () => { | 637 | describe("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 { |
| 72 | 72 | ||
| 73 | function resolveEventKeys(event: ShortcutEventLike): Set<string> { | 73 | function 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; |
| 77 | 81 |