From 915ceb00beccfa7c0dcf3342f3cd5e8fe0d197ac Mon Sep 17 00:00:00 2001 From: Trevor Walker Date: Thu, 1 Oct 2026 23:54:17 -0600 Subject: [PATCH] fix(web): adopt upstream hotkey, header and code-block polish Upstream cycle #981, group A: 71a90ae70e3ff880396363809bd9bfcfd520f30c - Adopted: record plain keys and Tab in hotkey settings, preserve modifier requirements for global snapshot shortcuts, and add snapshot guard regression coverage. ced865c224ebb7dd2cbd0f2931d6e33559b9343a - Adopted: collapse constrained PR check summaries to their icon while preserving workflow approval controls and accessible labels. fa6262db3107a4c95b6ff611113e6fa0753e5948 - Adopted: remove blanket older-server update advice; Pylon advertises restart continuation and gates the setting on every connected target capability. 20012ebd80b06a4c10ab0e78893822e7f873b2a8 - Adopted: reserve header action spacing and remove the hidden maximize control from flex flow. 67f640093ffea1adaa585949d87e4bc2010b0810 - Adopted: map dockerfile fences to Dockerfile for the Docker icon. 5a574a77d1bef579b01d137b81888f68eabf7d4b - Adapted: remove only the creation-time ID tie test duplicated by client-runtime sortActiveThreadsByOrderKey coverage. Retain distinct creation/re-entry and pinned-reorder cases, Pylon drag/drop and optimistic preview tests, and the live planPinnedReorder import/export and callers. Validation: 418 tests across nine focused suites; web typecheck; changed-file lint and formatting; git diff --check. Lint retains three pre-existing ChatView unused-variable warnings. Browser geometry and animations were not exercised. --- apps/web/src/components/ChatView.tsx | 7 +++++- apps/web/src/components/Sidebar.logic.test.ts | 9 ------- apps/web/src/components/chat/ChatHeader.tsx | 8 +++--- .../pullRequest/PullRequestDetailPanel.tsx | 15 ++++++++--- .../KeybindingsSettings.logic.test.ts | 25 +++++++++++++++++++ .../settings/KeybindingsSettings.logic.ts | 4 +-- .../settings/KeybindingsSettings.tsx | 6 +++-- .../components/settings/SettingsPanels.tsx | 2 +- .../useSnapShotShortcutRecorder.test.tsx | 16 ++++++++++++ .../settings/useSnapShotShortcutRecorder.tsx | 2 ++ apps/web/src/pierre-icons.test.ts | 5 ++++ apps/web/src/pierre-icons.ts | 11 ++++++-- 12 files changed, 85 insertions(+), 25 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 827269bf35..003a7a2cf9 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -10354,7 +10354,12 @@ export default function ChatView(props: ChatViewProps) { "flex shrink-0", panelAnimationsActive && "motion-safe:transition-opacity motion-safe:duration-(--panel-animation-duration) motion-safe:ease-out", - rightPanelOpen ? "pointer-events-auto opacity-100" : "pointer-events-none opacity-0", + // Closed, the control leaves the flex flow so the cluster is only as wide as the two + // toggles the header reserves room for; anchored to the cluster's left edge, it fades + // out where it stood rather than over the terminal toggle. + rightPanelOpen + ? "pointer-events-auto opacity-100" + : "pointer-events-none absolute right-full mr-1 opacity-0", )} inert={!rightPanelOpen} > diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index c586d216e1..18c6ffe638 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -1057,15 +1057,6 @@ describe("sortThreadsForSidebar", () => { expect(sorted.map((thread) => thread.id)).toEqual(["newest", "middle", "oldest"]); }); - it("breaks creation-time ties by id so the order is stable", () => { - const sorted = sortThreadsForSidebar([ - sortable({ id: "b", createdAt: "2026-03-09T10:00:00.000Z" }), - sortable({ id: "a", createdAt: "2026-03-09T10:00:00.000Z" }), - ]); - - expect(sorted.map((thread) => thread.id)).toEqual(["a", "b"]); - }); - it("surfaces an un-settled thread at the top via its re-entry stamp", () => { const sorted = sortThreadsForSidebar([ { diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index bbe014ddbd..92f615538f 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -492,9 +492,11 @@ export const ChatHeader = memo(function ChatHeader({ data-chat-header-actions className={cn( "flex shrink-0 items-center justify-end gap-2 @3xl/header-actions:gap-3", - // Reserve two panel toggles plus their 4px gaps and 1px edge inset. - // The page header adds 8px more right padding at sm. - rightPanelOpen ? "pr-0" : "pr-18.25 sm:pr-14.25", + // Reserve two panel toggles (32px, 28px at sm) with their 4px gap and 1px edge inset, + // plus the same gap the actions keep between themselves (gap-2, gap-3 at @3xl) so the + // terminal toggle does not sit against the last action. The page header adds 8px more + // right padding at sm. + rightPanelOpen ? "pr-0" : "pr-19.25 sm:pr-15.25 @3xl/header-actions:pr-16.25", "[[data-panel-animations=true]_&]:motion-safe:transition-[padding-right] [[data-panel-animations=true]_&]:motion-safe:duration-(--panel-animation-duration) [[data-panel-animations=true]_&]:motion-safe:ease-out", )} > diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx index 5fcecf4de6..9db394b606 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx @@ -1565,6 +1565,8 @@ export function PullRequestDetailPanel({ const statePresentation = detail ? resolvePullRequestState({ state: detail.state, isDraft: detail.isDraft }) : null; + const showsApproveWorkflows = + workflowApprovalsRequired > 0 && !checksStale && can("approve-workflows"); const checksSummary = checksStale ? checksState === null ? "No checks reported" @@ -2603,8 +2605,13 @@ export function PullRequestDetailPanel({ ))} {tab === "summary" ? ( - - {workflowApprovalsRequired > 0 && !checksStale && can("approve-workflows") ? ( + + {showsApproveWorkflows ? ( ) : ( {checksState !== null ? ( @@ -2653,7 +2660,7 @@ export function PullRequestDetailPanel({ ) : ( )} - {checksSummary} + {checksSummary} )} diff --git a/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts b/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts index bcc6702ac5..0af98c62b2 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts +++ b/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts @@ -117,6 +117,31 @@ describe("KeybindingsSettings.logic", () => { ).toBe("mod+shift+k"); }); + it.each([ + ["k", "KeyK", "k"], + ["Tab", "Tab", "tab"], + ["F5", "F5", "f5"], + ])("captures %s without a modifier", (key, code, expected) => { + const noModifiers = { metaKey: false, ctrlKey: false, altKey: false, shiftKey: false }; + expect(keybindingFromKeyboardEvent({ key, code, ...noModifiers }, "MacIntel")).toBe(expected); + }); + + it("waits for a key when only a modifier is pressed", () => { + expect( + keybindingFromKeyboardEvent( + { + key: "Meta", + code: "MetaLeft", + metaKey: true, + ctrlKey: false, + altKey: false, + shiftKey: false, + }, + "MacIntel", + ), + ).toBeNull(); + }); + it.each([ ["@", "Digit2", "mod+shift+2"], ['"', "Digit2", "mod+shift+2"], diff --git a/apps/web/src/components/settings/KeybindingsSettings.logic.ts b/apps/web/src/components/settings/KeybindingsSettings.logic.ts index 4a8792857c..2a1a4aede0 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.logic.ts +++ b/apps/web/src/components/settings/KeybindingsSettings.logic.ts @@ -355,6 +355,7 @@ function normalizeShortcutKeyToken(key: string): string | null { return null; } +/** Turns a keydown into a binding such as `mod+shift+k` or `tab`. Null for modifier-only presses. */ export function keybindingFromKeyboardEvent( event: Pick, platform: string, @@ -372,9 +373,6 @@ export function keybindingFromKeyboardEvent( } if (event.altKey) parts.push("alt"); if (event.shiftKey) parts.push("shift"); - if (parts.length === 0) { - return null; - } parts.push(keyToken); return parts.join("+"); } diff --git a/apps/web/src/components/settings/KeybindingsSettings.tsx b/apps/web/src/components/settings/KeybindingsSettings.tsx index eed76056ac..1d775a6955 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.tsx +++ b/apps/web/src/components/settings/KeybindingsSettings.tsx @@ -809,7 +809,8 @@ function useKeybindingRowEditor({ }; const captureKeybinding = (event: KeyboardEvent) => { - if (event.key === "Tab") return; + // Tab is recorded like any key while recording; after that it moves focus on. + if (event.key === "Tab" && !isRecording) return; event.preventDefault(); if (event.key === "Escape") { setDraft({ keyDraft: row.key, isRecording: false }); @@ -1129,7 +1130,8 @@ function useNewKeybindingDraft({ }; const captureKeybinding = (event: KeyboardEvent) => { - if (event.key === "Tab") return; + // Tab is recorded like any key while recording; after that it moves focus on. + if (event.key === "Tab" && !isRecording) return; event.preventDefault(); if (event.key === "Escape") { setDraft({ keyDraft: "", isRecording: false }); diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index fadcb02832..0f4149dba9 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -2679,7 +2679,7 @@ export function GeneralSettingsPanel() { {...searchableSetting("continue-threads-after-server-update")} serverScoped settingKeys={["continueThreadsAfterServerUpdate"]} - description="Automatically resume interrupted threads after an update, crash, or machine restart on the selected environments. Update older servers first." + description="Automatically resume interrupted threads after an update, crash, or machine restart on the selected environments." status={ !supportsRestartContinuation ? "All selected connected environments must support restart continuation." diff --git a/apps/web/src/components/settings/useSnapShotShortcutRecorder.test.tsx b/apps/web/src/components/settings/useSnapShotShortcutRecorder.test.tsx index b41113c5cf..69656e5216 100644 --- a/apps/web/src/components/settings/useSnapShotShortcutRecorder.test.tsx +++ b/apps/web/src/components/settings/useSnapShotShortcutRecorder.test.tsx @@ -101,6 +101,22 @@ it("records macOS Command without treating it as Control", async () => { (await start(true)).input.props.onKeyDown(event("k", "KeyK", { metaKey: true })); expect(recorded).toHaveBeenCalledWith(expect.objectContaining({ key: "k", modKey: true })); }); +it.each([ + ["k", "KeyK"], + ["F5", "F5"], +])("ignores unmodified %s and keeps recording for a global shortcut", async (key, code) => { + const recorder = await start(); + recorder.input.props.onKeyDown(event(key, code)); + expect(recorded).not.toHaveBeenCalled(); + expect(render().recording).toBe(true); + expect(suppress).toHaveBeenLastCalledWith(true); + render().input.props.onKeyDown(event("k", "KeyK", { ctrlKey: true })); + expect(recorded).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ key: "k", modKey: true }), + ); + expect(render().recording).toBe(false); + expect(suppress).toHaveBeenLastCalledWith(false); +}); it.each(["Escape", "blur", "unmount"])("cancels on %s without selecting keys", async (cancel) => { const recorder = await start(); if (cancel === "Escape") { diff --git a/apps/web/src/components/settings/useSnapShotShortcutRecorder.tsx b/apps/web/src/components/settings/useSnapShotShortcutRecorder.tsx index 8f377842b6..50fd3b3382 100644 --- a/apps/web/src/components/settings/useSnapShotShortcutRecorder.tsx +++ b/apps/web/src/components/settings/useSnapShotShortcutRecorder.tsx @@ -106,6 +106,8 @@ export function useSnapShotShortcutRecorder({ } return; } + // A global shortcut without a modifier would take that key from every app. + if (!event.metaKey && !event.ctrlKey && !event.altKey && !event.shiftKey) return; const input = keybindingFromKeyboardEvent(event, navigator.platform); if (!input) return; const next = parseKeybindingShortcut(input); diff --git a/apps/web/src/pierre-icons.test.ts b/apps/web/src/pierre-icons.test.ts index 5f5aca691e..6090d7ed9e 100644 --- a/apps/web/src/pierre-icons.test.ts +++ b/apps/web/src/pierre-icons.test.ts @@ -56,7 +56,12 @@ describe("Pierre file icons", () => { it("normalizes common markdown fence language aliases", () => { assert.equal(syntheticFileNameForLanguageId("typescript"), "file.ts"); + assert.equal(syntheticFileNameForLanguageId("dockerfile"), "Dockerfile"); assert.equal(syntheticFileNameForLanguageId("shellscript"), "file.sh"); assert.equal(syntheticFileNameForLanguageId("python"), "file.py"); }); + + it("gives a dockerfile fence the Docker icon", () => { + assert.isTrue(hasSpecificPierreIconForFileName(syntheticFileNameForLanguageId("dockerfile"))); + }); }); diff --git a/apps/web/src/pierre-icons.ts b/apps/web/src/pierre-icons.ts index 49aec9cc4c..46f0099fed 100644 --- a/apps/web/src/pierre-icons.ts +++ b/apps/web/src/pierre-icons.ts @@ -51,7 +51,6 @@ const completeIconResolver = createFileTreeIconResolver(T3_PIERRE_ICONS); const LANGUAGE_EXTENSION_ALIASES: Record = { bash: "sh", csharp: "cs", - dockerfile: "dockerfile", javascript: "js", jsx: "jsx", markdown: "md", @@ -79,9 +78,17 @@ export function inferEntryKindFromPath(pathValue: string): "file" | "directory" return base.includes(".") ? "file" : "directory"; } +/** Languages whose files are recognised by name rather than by extension. */ +const LANGUAGE_FILE_NAMES: Record = { + dockerfile: "Dockerfile", +}; + export function syntheticFileNameForLanguageId(languageId: string): string { const normalized = languageId.toLowerCase(); - return `file.${LANGUAGE_EXTENSION_ALIASES[normalized] ?? normalized}`; + return ( + LANGUAGE_FILE_NAMES[normalized] ?? + `file.${LANGUAGE_EXTENSION_ALIASES[normalized] ?? normalized}` + ); } export function resolvePierreIconForEntry(