From 223a70796a11291b1830daecf4dc7d887df670e0 Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Tue, 1 Sep 2026 21:55:45 -0400 Subject: [PATCH 1/7] feat: add independent Quick View workspace tools --- .papercuts/troubleshooting.md | 28 + docs/chatgpt-desktop-ui-inspiration.md | 15 +- docs/chatgpt-ui-element-specimen.html | 91 ++- docs/plans/README.md | 1 + .../quick-view-environment-tools-plan.md | 68 ++ .../components/assistant/assistant-dock.tsx | 23 +- renderer/components/chat-sidebar.test.tsx | 17 +- renderer/components/chat-sidebar.tsx | 2 +- renderer/components/environment-panel.tsx | 619 ++++++++++++------ .../environment-subagents-contract.test.ts | 248 +++---- renderer/components/open-in-editor-picker.tsx | 2 +- renderer/components/subagents-panel.test.tsx | 116 +--- renderer/components/terminal-drawer.tsx | 8 +- renderer/components/ui.tsx | 7 +- .../lib/assistant-motion-contract.test.ts | 3 +- renderer/lib/dialog-motion-contract.test.ts | 6 +- renderer/lib/environment-panel-layout.test.ts | 40 ++ renderer/lib/environment-panel-layout.ts | 56 +- renderer/lib/environment-panel-state.ts | 179 +++-- renderer/lib/slash-command-actions.test.ts | 7 + renderer/lib/slash-command-actions.ts | 4 +- renderer/main/chat-layout.tsx | 23 +- renderer/main/chat-pane.tsx | 11 +- renderer/main/root-view.tsx | 26 +- renderer/shared/keybindings.test.ts | 28 + renderer/shared/keybindings.ts | 19 +- renderer/shared/slash-commands.test.ts | 32 +- renderer/shared/slash-commands.ts | 19 +- renderer/styles.css | 10 +- 29 files changed, 1041 insertions(+), 667 deletions(-) create mode 100644 docs/plans/quick-view-environment-tools-plan.md diff --git a/.papercuts/troubleshooting.md b/.papercuts/troubleshooting.md index 91b39eda..307f3b4d 100644 --- a/.papercuts/troubleshooting.md +++ b/.papercuts/troubleshooting.md @@ -225,3 +225,31 @@ needed to separate this change from that baseline noise. - Revision checks around settings writes need an explicit serialized lane; async read-then-write alone permits stale concurrent mutations. - Backticks in `gh api -f body=...` are evaluated by zsh before submission; use single-quoted plain text or standard input for review replies. - A merged feature does not auto-increment releases; bump both package manifests before merging when the current tag already exists. + +## Codex-hosted UI reference work + +Codex blocks Computer Use from automating its own `com.openai.codex` host. Use +the supplied screenshot and inspect the installed app bundle for behavior and +styling evidence instead of treating self-host automation as available. + +A fresh linked worktree can lack `node_modules`: `npx` may fetch `tsx`, but +React-backed renderer tests still fail to load. Run `npm ci` before the focused +Quick View and Environment verification gates. + +In this linked worktree, `npm ci` installed the Electron package without its +`dist/Electron.app` payload, so `npm run dev` reached Vite but failed in the +macOS runtime preparation `lipo` step. Run Electron's package installer before +attempting dev-app visual acceptance. + +The project instructions reference a `.memory/` folder, but this linked +worktree does not contain one. Use the repository plan and UI reference docs as +the local source of truth, and record the missing folder rather than inventing +project history. + +Adding or changing exports in the Environment or Terminal providers makes Vite +invalidate Fast Refresh and remount the renderer. An open terminal can then +briefly reference a main-process session that the remounted provider no longer +owns; reopen the terminal before judging the final live state. +- Source-regex coverage made the first Quick View split look independent even though both controls still shared one `open + tab` state. Add reducer transition coverage whenever two UI routes are meant to coexist. +- Simultaneous right-edge surfaces need measured workbench geometry, not window breakpoints; the app sidebar changes the available allocation without changing the window width. +- Final dev-window automation was unavailable while macOS was locked; the renderer and Electron process launched, but visual acceptance still requires an unlocked desktop. diff --git a/docs/chatgpt-desktop-ui-inspiration.md b/docs/chatgpt-desktop-ui-inspiration.md index 831039dd..7e278e48 100644 --- a/docs/chatgpt-desktop-ui-inspiration.md +++ b/docs/chatgpt-desktop-ui-inspiration.md @@ -100,23 +100,24 @@ flowchart TD | Toasts | Brief, top-offset status feedback; success and failure copy is action-specific. | Use for completed background actions and recoverable failures, never as the only record of an approval or destructive action. | | Loading | Shimmer/skeleton treatments are used for content and generated assets; button actions still use compact progress indicators. | Use skeletons for delayed lists and model catalogs. Avoid shimmer on ordinary static labels. | -## Environment sidebar state model +## Quick View and Environment tools state model -The shipped ChatGPT/Codex renderer establishes two related but distinct surfaces beside a stable conversation: a compact floating `Environment` summary and larger work surfaces for Review or Files. The summary's `Changes` row opens Review, its execution row distinguishes local/cloud targets, its branch row opens branch/worktree controls, and its later rows lead into commit/push and GitHub comparison workflows. Aiden keeps that separation: Environment is a detached top-right status card, while Review and Files share one persistent, resizable work surface. The tables below separate directly evidenced behavior from the additional production states Aiden needs under loading, empty, error, narrow-window, and concurrent-file-change conditions. +The shipped ChatGPT/Codex renderer establishes two related but distinct surfaces beside a stable conversation: a compact floating summary and larger work surfaces for Review or Files. Aiden names the compact status card **Quick View** and keeps **Environment** for the persistent, resizable Review, Subagents, and Files work surface. Quick View's `Changes` row opens Review, its execution row distinguishes local/cloud targets, its branch row opens branch/worktree controls, and its later rows lead into commit/push and comparison workflows. The tables below separate directly evidenced behavior from the additional production states Aiden needs under loading, empty, error, narrow-window, and concurrent-file-change conditions. ### Shell and layout states | State | User sees | Interaction contract | |---|---|---| -| Closed | Conversation uses the full workbench width; the toolbar toggle remains available. | Both surfaces are hidden from focus and accessibility navigation. `⌘⇧E` or the toolbar button always opens the Environment summary first. | +| Closed | Conversation uses the full workbench width; separate Environment and Quick View toolbar controls remain available. | Each surface has an independent open bit. `⌘⇧E` and `/environment` toggle the last full Environment destination; `/quick-view` toggles Quick View without changing Environment. | | Summary opening / closing | The compact card fades from 4px above and `.98` scale over `180ms`. | It does not resize, dim, or make the conversation inert. Motion is removed under Reduce Motion. | -| Environment summary open | A rounded top-right card shows working changes, local execution, the active branch, commit/push, and compare. | `Changes` deep-links into Review; the header action can open Files or comparison. Local is static until Aiden has a real second execution target; unsupported Git actions do not appear as dead rows. | +| Quick View open | A rounded top-right card shows working changes, local execution, the active branch, commit/push, compare, and current-chat subagents. | The dedicated two-row list control toggles this card. The adjacent panel control opens or closes Environment independently. `Changes` deep-links into Review without clearing Quick View. | +| Quick View + Environment | Both surfaces remain visibly open side by side when the measured workbench has room. | Invoking either surface brings it forward without closing the other. Opening the app sidebar recomputes placement from the remaining workbench width. On smaller layouts, the foreground surface is shown while the background surface is automatically hidden and inert; its open state and mounted tool state are preserved. | | Expanded work surface opening / closing | The right edge expands or retracts over the relaxed `300ms` panel timing. | Width, opacity, and geometry move together; the transition is flattened under Reduce Motion. | -| Review open | Review is selected in the expanded surface and the conversation stays mounted. | Tab, width, and open state persist. Focus enters the selected tab; the Summary control returns to the detached card without losing work. | +| Review open | Review is selected in the expanded surface and the conversation stays mounted. | Tab, width, and open state persist. Focus enters the selected tab; Quick View remains independent and can stay visible beside it. | | Files open | Files is selected; its tree and editor occupy the same shell used by Review. | Switching tabs does not discard an editor draft. Review can deep-link a changed file into Files. | | Resizing | A one-pixel boundary gains hover/focus emphasis while the user drags or uses arrow keys. | Easing is disabled during pointer drag. Arrow keys resize by 16px, Shift+Arrow by 40px, Home/End reach bounds. | | Inline wide-window | Conversation and the expanded Review/Files surface are sibling columns. | Used only when the content region can preserve a useful conversation measure beside the saved panel width; the summary card always floats. | -| Overlay narrow-window | Expanded Review/Files becomes a right-side sheet over a dimmed, inert conversation. | Its width is bounded to the window; Escape, backdrop click, or Close dismisses it and restores focus. The compact summary remains non-modal at every width. | +| Floating narrow-window | Expanded Review/Subagents/Files becomes a rounded right-side surface inset 12px from the workbench edge. | It has no backdrop, blur, dialog role, focus trap, or app-wide inert state. Uncovered chat, composer, toolbar, navigation, command palette, and assistant dock remain interactive and do not dismiss it. Only Close, Escape, or `⌘⇧E` dismisses; focus already moved into chat is preserved. | | No workspace | A quiet explanation fills whichever Environment surface is open. | The user is directed to choose a local workspace; no dead tree, diff, or editor controls appear. | | No Access | The active Environment surface explains that local file access is disabled. | It does not silently widen permission. The composer remains the place to change workspace access. | | Loading | Shape-matched skeleton rows appear in the active view. | Static labels do not shimmer, inactive tabs do not keep polling, and controls expose disabled/loading states. | @@ -209,7 +210,7 @@ Core easing curves: | Motion | Shipped treatment | Aiden adaptation | |---|---|---| -| Panel open/close | `flex-grow` and `max-width` over `300ms`; transitions are disabled during drag. | Keep Aiden's `300ms` motion for expanded Review/Files only; the Environment summary uses the compact-popover motion and never changes conversation width. | +| Panel open/close | `flex-grow` and `max-width` over `300ms`; transitions are disabled during drag. | Keep Aiden's `300ms` motion for expanded Review/Subagents/Files only; Quick View uses the compact-popover motion and never changes conversation width. | | Compact popover | Fade plus `translateY(-4px)` and `scale(.98)` to rest over `150ms`. | Use for menus and small contextual surfaces. It is quieter than a large zoom. | | Model dropdown | Fade and `scale(.98 → 1)` over `320ms` with a short delay. | Reserve this slightly slower entrance for the model picker only; normal menus should stay near `150–200ms`. | | Centered content swap | Enter over `260ms` from 8px lower and `.98` scale; exit over `180ms` with a smaller movement. | A good asymmetric pattern for major mode/content changes, but unnecessary for routine settings navigation. | diff --git a/docs/chatgpt-ui-element-specimen.html b/docs/chatgpt-ui-element-specimen.html index 8b85dece..436ba80a 100644 --- a/docs/chatgpt-ui-element-specimen.html +++ b/docs/chatgpt-ui-element-specimen.html @@ -240,10 +240,10 @@ .environment-controls { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin-bottom: 12px; } .environment-demo { position: relative; display: grid; grid-template-columns: minmax(250px, .72fr) minmax(430px, 1.28fr); height: 560px; overflow: hidden; border: 1px solid var(--line); border-radius: 14px; background: var(--surface); transition: grid-template-columns var(--duration-relaxed) ease-out; } - .environment-demo[data-mode="overview"], .environment-demo[data-open="false"] { grid-template-columns: minmax(0, 1fr) 0; } - .environment-demo[data-mode="overview"] .environment-thread, .environment-demo[data-open="false"] .environment-thread { border-right-color: transparent; } - .environment-demo[data-mode="overview"] .environment-panel-specimen, .environment-demo[data-open="false"] .environment-panel-specimen { visibility: hidden; opacity: 0; pointer-events: none; } - .environment-demo[data-open="false"] .environment-summary-card { visibility: hidden; opacity: 0; pointer-events: none; } + .environment-demo[data-tools-open="false"] { grid-template-columns: minmax(0, 1fr) 0; } + .environment-demo[data-tools-open="false"] .environment-thread { border-right-color: transparent; } + .environment-demo[data-tools-open="false"] .environment-panel-specimen { visibility: hidden; opacity: 0; pointer-events: none; } + .environment-demo[data-quick-open="false"] .environment-summary-card { visibility: hidden; opacity: 0; pointer-events: none; } .environment-thread { display: flex; min-width: 0; flex-direction: column; border-right: 1px solid var(--line); background: var(--surface); } .environment-thread-head, .environment-panel-head { display: flex; height: 44px; flex: 0 0 auto; align-items: center; gap: 8px; padding: 0 10px 0 14px; border-bottom: 1px solid var(--line); } .environment-thread-head strong, .environment-panel-head strong { min-width: 0; flex: 1; overflow: hidden; font-size: 13px; font-weight: 580; text-overflow: ellipsis; white-space: nowrap; } @@ -252,7 +252,7 @@ .environment-thread-copy p { margin: 0; } .environment-panel-specimen { display: flex; min-width: 0; flex-direction: column; background: var(--surface-raised); opacity: 1; transition: opacity var(--duration-basic) ease-out; } .environment-summary-card { position: absolute; z-index: 3; top: 56px; right: 12px; display: flex; width: min(360px, calc(100% - 24px)); max-height: calc(100% - 68px); flex-direction: column; overflow: hidden; border: 1px solid var(--line); border-radius: 22px; background: var(--surface-raised); box-shadow: var(--shadow-dialog); animation: popover-in var(--duration-basic) var(--ease-enter) both; } - .environment-demo:not([data-mode="overview"]) .environment-summary-card { visibility: hidden; opacity: 0; pointer-events: none; } + .environment-demo[data-tools-open="true"][data-quick-open="true"] .environment-summary-card { right: calc(64% + 12px); width: min(360px, calc(36% - 24px)); } .environment-summary-head { display: flex; height: 48px; flex: 0 0 auto; align-items: center; gap: 8px; padding: 0 12px 0 16px; } .environment-summary-head strong { min-width: 0; flex: 1; color: var(--text-tertiary); font-size: 13px; font-weight: 580; } .environment-tabs { display: flex; align-items: center; gap: 2px; padding: 2px; border-radius: 10px; background: var(--surface-subtle); } @@ -398,8 +398,12 @@ .state-grid { grid-template-columns: 78px repeat(5, 104px); overflow-x: auto; } .workbench { grid-template-columns: 1fr; } .sidebar { display: none; } - .environment-demo { grid-template-columns: 1fr; } - .environment-thread { display: none; } + .environment-demo { display: block; } + .environment-thread { height: 100%; border-right: 0; } + .environment-panel-specimen { position: absolute; z-index: 2; inset: 12px 12px 12px auto; width: min(560px, calc(100% - 24px)); overflow: hidden; border: 1px solid var(--line); border-radius: 22px; box-shadow: var(--shadow-dialog); } + .environment-demo[data-tools-open="true"][data-quick-open="true"] .environment-summary-card { right: 12px; width: min(360px, calc(100% - 24px)); } + .environment-demo[data-front="tools"] .environment-summary-card, + .environment-demo[data-front="quick-view"] .environment-panel-specimen { visibility: hidden; opacity: 0; pointer-events: none; } .section, .intro { padding-right: 20px; padding-left: 20px; } .canvas { width: min(100% - 16px, 1120px); margin-top: 12px; } .brand span { display: none; } @@ -554,10 +558,11 @@

Conversation workbench

Environment surfaces

-

A detached Environment card floats over the conversation as a compact status launcher. Review and Files expand into the persistent, resizable work surface only when the task needs a full diff or editor.

+

Quick View and Environment have independent open states. They remain side by side when space permits; narrow layouts automatically present the most recently invoked surface while preserving the other state.

- + + @@ -573,13 +578,13 @@

Environment surfaces

-
+
-
Environment sidebar states
+
Quick View and Environment states
-

Conversation stays mounted. Review and Files appear as working context instead of replacing the thread.

-

The wide diff and editor content escape the conversation measure, while decisions and explanations remain in the chat.

+

Conversation stays mounted and interactive. Review, Subagents, and Files appear as working context instead of replacing the thread.

+

At narrow widths Environment floats without a backdrop or blur. This composer remains focusable and does not dismiss it.

@@ -587,10 +592,10 @@

Environment surfaces

-
@@ -819,6 +824,7 @@

Push branch

const environmentSummaryActions = document.querySelector("#environmentSummaryActions"); const environmentSummaryButton = document.querySelector("#environmentSummaryButton"); const environmentToggleButton = document.querySelector("#environmentToggleButton"); + const quickViewToggleButton = document.querySelector("#quickViewToggleButton"); const environmentCloseButton = document.querySelector("#environmentCloseButton"); const environmentReviewTab = document.querySelector("#environmentReviewTab"); const environmentFilesTab = document.querySelector("#environmentFilesTab"); @@ -862,7 +868,7 @@

Push branch

{ button: environmentChangesMode, state: "changes" }, { button: environmentCompareMode, state: "compare" }, ]; - let environmentReturnFocus = environmentToggleButton; + let environmentReturnFocus = quickViewToggleButton; themeButton.addEventListener("click", () => { const dark = root.dataset.theme !== "dark"; @@ -902,35 +908,42 @@

Push branch

document.querySelector("#toastButton").addEventListener("click", () => showToast("Saved", "Interaction tokens are documented.")); const syncEnvironmentSurfaces = () => { - const open = environmentDemo.dataset.open !== "false"; - const summaryOpen = open && environmentDemo.dataset.mode === "overview"; - const panelOpen = open && !summaryOpen; + const summaryOpen = environmentDemo.dataset.quickOpen === "true"; + const panelOpen = environmentDemo.dataset.toolsOpen === "true"; environmentSummaryCard.setAttribute("aria-hidden", String(!summaryOpen)); environmentSummaryCard.inert = !summaryOpen; environmentPanelSpecimen.setAttribute("aria-hidden", String(!panelOpen)); environmentPanelSpecimen.inert = !panelOpen; + environmentToggleButton.setAttribute("aria-pressed", String(panelOpen)); + environmentToggleButton.textContent = panelOpen ? "Close Environment" : "Open Environment"; + quickViewToggleButton.setAttribute("aria-pressed", String(summaryOpen)); + quickViewToggleButton.textContent = summaryOpen ? "Close Quick View" : "Open Quick View"; }; - const setEnvironmentOpen = (open, moveFocus = true) => { - const wasOpen = environmentDemo.dataset.open === "true"; - if (open && !wasOpen && document.activeElement instanceof HTMLElement && document.activeElement !== document.body) { + const setEnvironmentOpen = (surface, open, moveFocus = true) => { + const activeElement = document.activeElement; + const target = surface === "quick-view" ? environmentSummaryCard : environmentPanelSpecimen; + const focusInsideSurface = activeElement instanceof HTMLElement && target.contains(activeElement); + if (open && document.activeElement instanceof HTMLElement && document.activeElement !== document.body) { environmentReturnFocus = document.activeElement; } - environmentDemo.dataset.open = String(open); + environmentDemo.dataset[surface === "quick-view" ? "quickOpen" : "toolsOpen"] = String(open); + if (open) environmentDemo.dataset.front = surface; + else if (environmentDemo.dataset.front === surface) { + environmentDemo.dataset.front = environmentDemo.dataset.toolsOpen === "true" ? "tools" : environmentDemo.dataset.quickOpen === "true" ? "quick-view" : ""; + } syncEnvironmentSurfaces(); - environmentToggleButton.setAttribute("aria-pressed", String(open)); - environmentToggleButton.textContent = open ? "Close environment" : "Open environment"; if (moveFocus) { requestAnimationFrame(() => { - if (open && environmentDemo.dataset.mode === "overview") environmentSummaryActions.focus(); + if (open && surface === "quick-view") environmentSummaryActions.focus(); else if (open) environmentViews.find(({ tab }) => tab.getAttribute("aria-selected") === "true")?.tab.focus(); - else (environmentReturnFocus.isConnected ? environmentReturnFocus : environmentToggleButton).focus(); + else if (focusInsideSurface) (environmentReturnFocus.isConnected ? environmentReturnFocus : surface === "quick-view" ? quickViewToggleButton : environmentToggleButton).focus(); }); } }; const setEnvironmentState = (mode, state) => { - setEnvironmentOpen(true, false); + setEnvironmentOpen(mode === "overview" ? "quick-view" : "tools", true, false); environmentDemo.dataset.mode = mode; environmentDemo.dataset.state = state; environmentViews.forEach(({ mode: viewMode, tab, view }) => { @@ -955,12 +968,16 @@

Push branch

}; environmentToggleButton.addEventListener("click", () => { - if (environmentDemo.dataset.open !== "false") setEnvironmentOpen(false); - else setEnvironmentState("overview", "summary"); + if (environmentDemo.dataset.toolsOpen === "true") setEnvironmentOpen("tools", false); + else setEnvironmentState("review", "changes"); + }); + quickViewToggleButton.addEventListener("click", () => { + if (environmentDemo.dataset.quickOpen === "true") setEnvironmentOpen("quick-view", false); + else setEnvironmentOpen("quick-view", true); }); - environmentCloseButton.addEventListener("click", () => setEnvironmentOpen(false)); + environmentCloseButton.addEventListener("click", () => setEnvironmentOpen("tools", false)); environmentSummaryActions.addEventListener("click", () => setEnvironmentState("files", "editor")); - environmentSummaryButton.addEventListener("click", () => setEnvironmentState("overview", "summary")); + environmentSummaryButton.addEventListener("click", () => setEnvironmentOpen("quick-view", true)); environmentReviewTab.addEventListener("click", () => setEnvironmentState("review", "changes")); environmentFilesTab.addEventListener("click", () => setEnvironmentState("files", "editor")); environmentOverviewChanges.addEventListener("click", () => setEnvironmentState("review", "changes")); @@ -1189,7 +1206,9 @@

Push branch

setPopover(false); return; } - if (environmentDemo.dataset.open !== "false") setEnvironmentOpen(false); + const front = environmentDemo.dataset.front; + if (front === "tools") setEnvironmentOpen("tools", false); + else if (front === "quick-view") setEnvironmentOpen("quick-view", false); } }); diff --git a/docs/plans/README.md b/docs/plans/README.md index 10a017e1..846da9d4 100644 --- a/docs/plans/README.md +++ b/docs/plans/README.md @@ -23,6 +23,7 @@ This directory is the source of truth for Aiden's implementation plans. The engi | [Performance, Stability, Battery, and Efficiency](performance-stability-efficiency-plan.md) | Planned | Whole-app source audit is complete; implementation starts with instrumentation, durable state, and hard memory bounds. | | [Pi Provider Integration](pi-provider-integration-plan.md) | Partial | Pi built-ins, stores, auth, native routing, custom provider composition, canonical assistant provenance, voice credential lookup, and attended structured questions ship; scalable UX and rollout cleanup remain. | | [Pi Compaction and Durable Memory Upgrade](pi-compaction-memory-upgrade-plan.md) | Active | Phases 0–7 are implemented and accepted after two reviews each. Executable replays, staged per-chat format/behavior rollout, crash-safe build-bound receipts, and provider-native defer policy ship; installed/signed and credentialed operator evidence remains Pending. | +| [Quick View and Non-Modal Environment Tools](quick-view-environment-tools-plan.md) | Implemented | Quick View and Environment now have independent persisted state; both render side by side when measured space permits and the background surface auto-hides on smaller allocations. Focus, compact-sidebar priority, compatibility routes, dock placement, focused suites, and production build pass; unlocked visual acceptance is pending. | | [rpiv-advisor integration](rpiv-advisor-integration-plan.md) | Implemented | A bounded, tool-free, provider/auth-aware second opinion now uses an ephemeral per-consultation Ask User Question choice when the prompt does not name a reviewer, with no persistent Advisor settings or IPC. | | [rpiv-todo Integration](rpiv-todo-integration-plan.md) | Partial | Attended desktop chats have a journal-replayed native todo tool, strict fail-closed snapshots, owner-fenced IPC, and a self-hiding floating progress chip with portal details; packaged visual/accessibility acceptance remains open. | | [rpiv-btw Integration](rpiv-btw-integration-plan.md) | Partial | Attended desktop chats have bounded read-only side questions, ephemeral fingerprinted follow-ups, foreground-safe admission, exact provider dispatch, content-free usage accounting, and a native slash/card surface; packaged visual/accessibility acceptance remains open. | diff --git a/docs/plans/quick-view-environment-tools-plan.md b/docs/plans/quick-view-environment-tools-plan.md new file mode 100644 index 00000000..0e907224 --- /dev/null +++ b/docs/plans/quick-view-environment-tools-plan.md @@ -0,0 +1,68 @@ +# Quick View and Non-Modal Environment Tools + +**Status:** Implemented — automated verification passed; unlocked visual acceptance pending on 2026-09-01 + +## Goal + +Match Codex 26.818's two-mode workspace-tools pattern without weakening Aiden's +Git, file, or confirmation safety. The compact summary becomes **Quick View**; +the larger **Environment** surface keeps Review, Subagents, and Files. + +## Delivery contract + +- Keep the current 480–720px panel widths, 560px conversation floor, and + 1040px inline threshold. +- Pin Environment beside chat when space permits. Otherwise float it 12px from + the right edge with a rounded semantic surface and `shadow-dialog`. +- Keep floating Environment non-modal: no backdrop, blur, app-wide inert state, + focus trap, or command blocking. Background interaction does not dismiss it. +- Preserve `environment.toggle`, `Command-Shift-E`, `/environment`, existing + storage keys, tab state, file drafts, selected diffs, subagent detail, width, + polling ownership, and every existing Git/file safety dialog. The Environment + toolbar control and command now open the last full tools destination directly. +- Give Quick View its own two-row list toolbar control, `quick-view.toggle` + command, and `/quick-view` route. Quick View and Environment have independent + open state: either control toggles only its own surface, and Environment deep + links never clear Quick View. +- When both are open, place Quick View beside Environment whenever the measured + workbench fits them. On smaller layouts, automatically present the most + recently invoked surface while preserving the other open bit and mounted tool + state for immediate restoration. +- Restore the opening trigger only when focus is still inside a closing surface; + preserve focus that has already moved to the chat. +- Keep the assistant dock and command system usable, positioning the dock at the + remaining chat edge while Environment is open. + +## Documentation and validation + +- Update the desktop UI inspiration guide and interactive specimen. +- Replace compact-modal regressions with non-modal interaction, focus, layout, + copy, and compatibility coverage. +- Run focused renderer suites, type-check, lint, production build, Impeccable + detection, and `git diff --check` after installing dependencies. +- Inspect native consumers to confirm that no shared DTO changed. Onboarding and + mobile implementation changes are not expected because this is presentation + and desktop interaction only. + +## Known implementation papercuts + +- Codex cannot live-automate its own host; the installed bundle and supplied + screenshot are the reference evidence. +- This worktree started without installed dependencies, so React-backed tests + require `npm ci` before verification. + +## Delivered + +Quick View now owns the compact status card and a dedicated two-row list toolbar +control. The original panel control, `Command-Shift-E`, and `/environment` open +the full Environment surface; `/quick-view` targets the compact card. +Environment retains one mounted Review/Subagents/Files surface, pins at the +existing 1040px allocation threshold, and otherwise floats without a backdrop or +modal interaction boundary. Quick View remains independently open when +Environment or the app sidebar is invoked; the measured workbench shows both +side by side when it fits and automatically hides the background surface on +smaller allocations without clearing its state. Automated verification covers +the independent reducer, measured placement, compact sidebar priority, command +compatibility, responsive floating-to-pinned handoff, and assistant-dock +containment. The dev app is running; final visual acceptance of the new +coexistence state awaits an unlocked macOS desktop. diff --git a/renderer/components/assistant/assistant-dock.tsx b/renderer/components/assistant/assistant-dock.tsx index ec925b12..ad1c10da 100644 --- a/renderer/components/assistant/assistant-dock.tsx +++ b/renderer/components/assistant/assistant-dock.tsx @@ -18,11 +18,7 @@ const PREVIEW_VISIBLE_MS = 8_000; /** Must match aiden-assistant-dock-out in styles.css. */ const PANEL_EXIT_MS = 120; -export function AssistantDock({ - interactionBlocked = false, -}: { - interactionBlocked?: boolean; -}): React.ReactElement { +export function AssistantDock({ rightInset = 0 }: { rightInset?: number }): React.ReactElement { const chat = useAssistantChat(); const [open, setOpen] = React.useState(false); const [present, setPresent] = React.useState(false); @@ -36,7 +32,6 @@ export function AssistantDock({ const lastSeenReplyRef = React.useRef(null); const openPanel = React.useCallback(() => { - if (interactionBlocked) return; if (!open) { const activeElement = document.activeElement; restoreFocusRef.current = activeElement instanceof HTMLElement ? activeElement : null; @@ -47,25 +42,24 @@ export function AssistantDock({ setOpen(true); setUnread(0); setPreview(null); - }, [interactionBlocked, open]); + }, [open]); const minimizePanel = React.useCallback(() => { restoreFocusPendingRef.current = true; setOpen(false); }, []); - useCommandHandler("assistant.open", openPanel, !interactionBlocked); + useCommandHandler("assistant.open", openPanel); React.useEffect( () => onAssistantAutomationComposerRequested(() => { - if (interactionBlocked) return; setDraft(assistantAutomationDraft); openPanel(); }), - [interactionBlocked, openPanel], + [openPanel], ); // Keep the panel mounted through its exit animation, exactly as the - // environment summary card does, so minimizing settles instead of vanishing. + // Quick View does, so minimizing settles instead of vanishing. React.useLayoutEffect(() => { if (open) { setPresent(true); @@ -113,11 +107,8 @@ export function AssistantDock({ return (
{present ? (
{ assert.match(overflowMenu, /avoidCollisions=\{false\}/u); assert.match(overflowMenu, /maxHeight: contentMaxHeight, overflowY: "auto"/u); assert.match(sidebar, /ariaLabel="Organize sidebar"/u); - assert.match( - sidebar, - /ariaLabel="Add workspace"[\s\S]{0,240}triggerIcon=\{\}/u, - ); + assert.match(sidebar, /ariaLabel="Add workspace"[\s\S]{0,240}triggerIcon=\{\}/u); assert.match( sidebar, /ariaLabel=\{`Actions for \$\{workspaceAccessibleName\(group\.workspace\)\}`\}/u, @@ -243,10 +240,7 @@ test("sidebar organizer icons retain contrast on the highlighted accent surface" "const workspaceCreationMenu", ); - assert.equal( - organizer.match(/group-data-\[highlighted\]:text-accent-foreground/gu)?.length, - 2, - ); + assert.equal(organizer.match(/group-data-\[highlighted\]:text-accent-foreground/gu)?.length, 2); }); test("successful chat deletion removes the exact transcript cache before list refresh", () => { @@ -343,7 +337,12 @@ test("allocated composer and settings widths drive their compact layouts", () => test("environment inline handoff uses the same animated spacer pattern", () => { const panel = source("./environment-panel.tsx"); - assert.match(panel, /environment-panel absolute inset-y-0 right-0 z-30/u); + assert.match(panel, /environment-panel absolute z-30/u); + assert.match(panel, /inline\s*\? "inset-y-0 right-0 border-l border-separator"/u); + assert.match( + panel, + /"bottom-3 right-3 top-3 rounded-\[24px\] border border-separator shadow-dialog"/u, + ); assert.match( panel, /transition-\[width\] duration-300 ease-out motion-reduce:transition-none[\s\S]{0,180}fullOpen && inline \? renderedWidth : 0/u, diff --git a/renderer/components/chat-sidebar.tsx b/renderer/components/chat-sidebar.tsx index f539335a..4252344c 100644 --- a/renderer/components/chat-sidebar.tsx +++ b/renderer/components/chat-sidebar.tsx @@ -236,7 +236,7 @@ function UpdateReadyBanner({ blockedReason }: { blockedReason?: string }) { }, [bannerKey, snapshot.status]); // Keep the banner mounted through its exit animation, matching Aiden's - // environment summary and assistant dock presence primitives. + // Quick View and assistant dock presence primitives. React.useLayoutEffect(() => { if (open) { setDisplayedSnapshot(snapshot); diff --git a/renderer/components/environment-panel.tsx b/renderer/components/environment-panel.tsx index 0fd9e90f..f1c35d88 100644 --- a/renderer/components/environment-panel.tsx +++ b/renderer/components/environment-panel.tsx @@ -32,9 +32,9 @@ import { DEFAULT_PANEL_WIDTH, MAX_PANEL_WIDTH, MIN_PANEL_WIDTH, - PANEL_EDGE_GUTTER, - clampEnvironmentPanelWidth, resolveEnvironmentPanelLayout, + resolveEnvironmentPanelResizeBounds, + resolveQuickViewLayout, } from "../lib/environment-panel-layout"; import { useShortcutBinding, useShortcutLabel } from "../lib/command-system"; import { ariaKeyShortcut } from "../shared/keybindings"; @@ -68,12 +68,12 @@ import { import { useAppCapabilities } from "../lib/app-capabilities"; import { availableEnvironmentPanelTabs, - environmentCompactModalFocusableTargets, - environmentCompactModalTabWrapTarget, - focusEnvironmentCompactModalTransition, normalizeEnvironmentPanelTab, - storedEnvironmentPanelTab, + reduceEnvironmentSurfaceState, + shouldRestoreEnvironmentFocus, type EnvironmentSurfaceMode, + type EnvironmentSurface, + type EnvironmentSurfaceState, type EnvironmentPanelTab, } from "../lib/environment-panel-state"; import { @@ -104,17 +104,25 @@ interface EnvironmentFileRequest { } interface EnvironmentPanelContextValue { - open: boolean; - compactModalOpen: boolean; + toolsOpen: boolean; + quickViewOpen: boolean; + frontSurface: EnvironmentSurface | null; + surfaceMode: EnvironmentSurfaceMode; + dockRightInset: number; tab: EnvironmentPanelTab; subagentsEnabled: boolean; reviewMode: EnvironmentReviewMode; fileRequest: EnvironmentFileRequest | null; - close: () => void; - setCompactModalOpen: (open: boolean) => void; + closeAll: () => void; + closeTools: () => void; + closeQuickView: () => void; + reportSurfaceLayout: (layout: { inline: boolean; width: number } | null) => void; setTab: (tab: EnvironmentPanelTab) => void; - show: (tab?: EnvironmentPanelTab) => void; - toggle: (tab?: EnvironmentPanelTab) => void; + showTools: (tab?: EnvironmentPanelTab) => void; + showQuickView: () => void; + activateSurface: (surface: EnvironmentSurface) => void; + toggleTools: () => void; + toggleQuickView: () => void; openFile: (path: string) => void; openReview: (mode: EnvironmentReviewMode) => void; subagents: EnvironmentSubagentContext; @@ -155,7 +163,11 @@ interface EnvironmentPanelContextValue { const EnvironmentPanelContext = React.createContext(null); const OPEN_STORAGE_KEY = "aiden-agent.environment.open"; +const QUICK_VIEW_OPEN_STORAGE_KEY = "aiden-agent.quick-view.open"; +const FRONT_SURFACE_STORAGE_KEY = "aiden-agent.environment.front-surface"; +const SURFACE_STORAGE_VERSION_KEY = "aiden-agent.environment.surface-state-version"; const TAB_STORAGE_KEY = "aiden-agent.environment.tab"; +const LAST_TOOLS_TAB_STORAGE_KEY = "aiden-agent.environment.last-tools-tab"; const WIDTH_STORAGE_KEY = "aiden-agent.environment.width"; const SUMMARY_CARD_EXIT_MS = 120; const EMPTY_EDITOR_STATE: FilesEditorState = { @@ -182,14 +194,60 @@ function storedPanelWidth(): number { : DEFAULT_PANEL_WIDTH; } +function storedLastToolsTab(currentTab: EnvironmentPanelTab, subagentsEnabled: boolean) { + const stored = localStorage.getItem(LAST_TOOLS_TAB_STORAGE_KEY); + if (stored === "files" || stored === "review") return stored; + if (stored === "subagents" && subagentsEnabled) return stored; + return normalizeEnvironmentPanelTab(currentTab, subagentsEnabled); +} + +function initialEnvironmentSurfaceState(subagentsEnabled: boolean): EnvironmentSurfaceState { + const rawTab = localStorage.getItem(TAB_STORAGE_KEY); + const storedTab: EnvironmentPanelTab = + rawTab === "review" || rawTab === "subagents" || rawTab === "files" + ? rawTab + : storedLastToolsTab("review", subagentsEnabled); + const migrated = localStorage.getItem(SURFACE_STORAGE_VERSION_KEY) === "2"; + if (!migrated) { + const legacyOpen = localStorage.getItem(OPEN_STORAGE_KEY) === "1"; + const quickViewOpen = legacyOpen && rawTab === "overview"; + const toolsOpen = legacyOpen && rawTab !== "overview"; + localStorage.setItem(QUICK_VIEW_OPEN_STORAGE_KEY, quickViewOpen ? "1" : "0"); + localStorage.setItem(OPEN_STORAGE_KEY, toolsOpen ? "1" : "0"); + localStorage.setItem(SURFACE_STORAGE_VERSION_KEY, "2"); + return { + quickViewOpen, + toolsOpen, + toolsTab: storedTab, + frontSurface: quickViewOpen ? "quick-view" : toolsOpen ? "tools" : null, + }; + } + const quickViewOpen = localStorage.getItem(QUICK_VIEW_OPEN_STORAGE_KEY) === "1"; + const toolsOpen = localStorage.getItem(OPEN_STORAGE_KEY) === "1"; + const storedFront = localStorage.getItem(FRONT_SURFACE_STORAGE_KEY); + const frontSurface = + storedFront === "quick-view" && quickViewOpen + ? "quick-view" + : storedFront === "tools" && toolsOpen + ? "tools" + : toolsOpen + ? "tools" + : quickViewOpen + ? "quick-view" + : null; + return { quickViewOpen, toolsOpen, toolsTab: storedTab, frontSurface }; +} + export function EnvironmentPanelProvider({ children }: React.PropsWithChildren) { const { activeId } = useActiveWorkspace(); const { subagents: subagentsEnabled } = useAppCapabilities(); - const [open, setOpen] = React.useState(() => localStorage.getItem(OPEN_STORAGE_KEY) === "1"); - const [compactModalOpen, setCompactModalOpen] = React.useState(false); - const [tab, setTabState] = React.useState(() => - storedEnvironmentPanelTab(localStorage, TAB_STORAGE_KEY, subagentsEnabled), + const [surfaceState, dispatchSurface] = React.useReducer( + reduceEnvironmentSurfaceState, + subagentsEnabled, + initialEnvironmentSurfaceState, ); + const [surfaceLayout, setSurfaceLayout] = React.useState({ inline: false, width: 0 }); + const tab = normalizeEnvironmentPanelTab(surfaceState.toolsTab, subagentsEnabled); const [reviewMode, setReviewMode] = React.useState("changes"); const [fileRequest, setFileRequest] = React.useState(null); const [editorState, setEditorState] = React.useState(EMPTY_EDITOR_STATE); @@ -239,7 +297,8 @@ export function EnvironmentPanelProvider({ children }: React.PropsWithChildren) const [cancelAgent, setCancelAgent] = React.useState<(() => void) | undefined>(); const fileRequestIdRef = React.useRef(0); const gitBusyCountRef = React.useRef(0); - const returnFocusRef = React.useRef(null); + const toolsReturnFocusRef = React.useRef(null); + const quickViewReturnFocusRef = React.useRef(null); const returnSubagentRunIdRef = React.useRef(null); const subagentChatIdRef = React.useRef(null); const subagentWorkspaceIdRef = React.useRef(null); @@ -285,60 +344,123 @@ export function EnvironmentPanelProvider({ children }: React.PropsWithChildren) const selectedSubagentGenerationId = selectedSubagentView?.generationId; const selectedSubagentReferenceMessageId = selectedSubagentView?.referenceMessageId; - const rememberFocus = React.useCallback(() => { - if (document.activeElement instanceof HTMLElement) - returnFocusRef.current = document.activeElement; - }, []); + const rememberFocus = React.useCallback( + (surface: EnvironmentSurface) => { + if (!(document.activeElement instanceof HTMLElement)) return; + const target = + surface === "tools" ? toolsReturnFocusRef : quickViewReturnFocusRef; + target.current = document.activeElement; + }, + [], + ); - const show = React.useCallback( + const reportSurfaceLayout = React.useCallback( + (layout: { inline: boolean; width: number } | null) => { + const next = layout ?? { inline: false, width: 0 }; + setSurfaceLayout((current) => + current.inline === next.inline && current.width === next.width ? current : next, + ); + }, + [], + ); + + const showTools = React.useCallback( (nextTab?: EnvironmentPanelTab) => { - const resolvedTab = nextTab - ? normalizeEnvironmentPanelTab(nextTab, subagentsEnabled) - : undefined; - if (!open) rememberFocus(); - if (resolvedTab) { - setTabState(resolvedTab); - localStorage.setItem(TAB_STORAGE_KEY, resolvedTab); - } - setOpen(true); - localStorage.setItem(OPEN_STORAGE_KEY, "1"); + const resolvedTab = nextTab ? normalizeEnvironmentPanelTab(nextTab, subagentsEnabled) : tab; + const activeElement = document.activeElement; + const focusOutsideSurface = + activeElement instanceof HTMLElement && + !shouldRestoreEnvironmentFocus(activeElement, "tools"); + if (!surfaceState.toolsOpen || focusOutsideSurface) rememberFocus("tools"); + dispatchSurface({ type: "show-tools", tab: resolvedTab }); }, - [open, rememberFocus, subagentsEnabled], + [rememberFocus, subagentsEnabled, surfaceState.toolsOpen, tab], ); - const close = React.useCallback(() => { - setOpen(false); - localStorage.setItem(OPEN_STORAGE_KEY, "0"); - const replacementChip = Array.from( - document.querySelectorAll("[data-subagent-chip-run-id]"), - ).find((element) => element.dataset.subagentChipRunId === returnSubagentRunIdRef.current); - const returnTarget = returnFocusRef.current?.isConnected - ? returnFocusRef.current + const restoreSurfaceFocus = React.useCallback((surface: EnvironmentSurface) => { + const activeElement = document.activeElement; + const focusInsideClosingSurface = + activeElement instanceof HTMLElement && shouldRestoreEnvironmentFocus(activeElement, surface); + if (!focusInsideClosingSurface) return; + const returnRef = surface === "tools" ? toolsReturnFocusRef : quickViewReturnFocusRef; + const replacementChip = + surface === "tools" + ? Array.from( + document.querySelectorAll("[data-subagent-chip-run-id]"), + ).find((element) => element.dataset.subagentChipRunId === returnSubagentRunIdRef.current) + : null; + const storedTarget = returnRef.current; + const storedTargetAvailable = + storedTarget?.isConnected && + !storedTarget.closest("[inert]") && + !storedTarget.closest('[aria-hidden="true"]'); + const fallbackSelector = + surface === "tools" ? "[data-environment-toggle]" : "[data-quick-view-toggle]"; + const returnTarget = storedTargetAvailable + ? storedTarget : (replacementChip ?? - document.querySelector("[data-environment-toggle]") ?? + document.querySelector(fallbackSelector) ?? document.querySelector("[data-app-focus-root]")); if (returnTarget?.isConnected) requestAnimationFrame(() => returnTarget.focus()); }, []); + const closeTools = React.useCallback(() => { + if (gitOperationBusy) return; + restoreSurfaceFocus("tools"); + dispatchSurface({ type: "close-tools" }); + }, [gitOperationBusy, restoreSurfaceFocus]); + + const closeQuickView = React.useCallback(() => { + if (gitOperationBusy) return; + restoreSurfaceFocus("quick-view"); + dispatchSurface({ type: "close-quick-view" }); + }, [gitOperationBusy, restoreSurfaceFocus]); + + const closeAll = React.useCallback(() => { + if (gitOperationBusy) return; + dispatchSurface({ type: "close-all" }); + }, [gitOperationBusy]); + + const showQuickView = React.useCallback(() => { + const activeElement = document.activeElement; + const focusOutsideSurface = + activeElement instanceof HTMLElement && + !shouldRestoreEnvironmentFocus(activeElement, "quick-view"); + if (!surfaceState.quickViewOpen || focusOutsideSurface) rememberFocus("quick-view"); + dispatchSurface({ type: "show-quick-view" }); + }, [rememberFocus, surfaceState.quickViewOpen]); + + const activateSurface = React.useCallback((surface: EnvironmentSurface) => { + dispatchSurface({ type: "activate", surface }); + }, []); + const setTab = React.useCallback( (nextTab: EnvironmentPanelTab) => { const resolvedTab = normalizeEnvironmentPanelTab(nextTab, subagentsEnabled); - setTabState(resolvedTab); - localStorage.setItem(TAB_STORAGE_KEY, resolvedTab); + dispatchSurface({ type: "show-tools", tab: resolvedTab }); }, [subagentsEnabled], ); - const toggle = React.useCallback( - (nextTab?: EnvironmentPanelTab) => { - const resolvedTab = nextTab - ? normalizeEnvironmentPanelTab(nextTab, subagentsEnabled) - : undefined; - if (open && (!resolvedTab || resolvedTab === tab)) close(); - else show(resolvedTab); - }, - [close, open, show, subagentsEnabled, tab], - ); + const toggleTools = React.useCallback(() => { + if (gitOperationBusy) return; + if (surfaceState.toolsOpen) { + restoreSurfaceFocus("tools"); + } else { + rememberFocus("tools"); + } + dispatchSurface({ type: "toggle-tools", tab }); + }, [gitOperationBusy, rememberFocus, restoreSurfaceFocus, surfaceState.toolsOpen, tab]); + + const toggleQuickView = React.useCallback(() => { + if (gitOperationBusy) return; + if (surfaceState.quickViewOpen) { + restoreSurfaceFocus("quick-view"); + } else { + rememberFocus("quick-view"); + } + dispatchSurface({ type: "toggle-quick-view" }); + }, [gitOperationBusy, rememberFocus, restoreSurfaceFocus, surfaceState.quickViewOpen]); const openFile = React.useCallback( (path: string) => { @@ -349,17 +471,17 @@ export function EnvironmentPanelProvider({ children }: React.PropsWithChildren) workspaceId: activeId, }); } - show("files"); + showTools("files"); }, - [activeId, show], + [activeId, showTools], ); const openReview = React.useCallback( (mode: EnvironmentReviewMode) => { setReviewMode(mode); - show("review"); + showTools("review"); }, - [show], + [showTools], ); const syncSubagents = React.useCallback( @@ -439,7 +561,7 @@ export function EnvironmentPanelProvider({ children }: React.PropsWithChildren) const openSubagent = React.useCallback( (runId: string, returnTarget?: HTMLElement | null) => { if (!subagentsEnabled) return; - if (returnTarget?.isConnected) returnFocusRef.current = returnTarget; + if (returnTarget?.isConnected) toolsReturnFocusRef.current = returnTarget; returnSubagentRunIdRef.current = runId; subagentDetailRequestRef.current = undefined; setSelectedSubagentRunId(runId); @@ -453,9 +575,9 @@ export function EnvironmentPanelProvider({ children }: React.PropsWithChildren) setSubagentFocusDetailVersion((version) => version + 1); setSubagentDetailRequestVersion((version) => version + 1); setSubagentDetailError(null); - show("subagents"); + showTools("subagents"); }, - [show, subagentViews, subagentsEnabled], + [showTools, subagentViews, subagentsEnabled], ); const selectSubagent = React.useCallback( @@ -590,7 +712,7 @@ export function EnvironmentPanelProvider({ children }: React.PropsWithChildren) setSelectedSubagentRunId(resolved ?? null); return; } - if (open && tab === "subagents" && !selectedSubagentRunId && resolved) { + if (surfaceState.toolsOpen && tab === "subagents" && !selectedSubagentRunId && resolved) { setSelectedSubagentRunId(resolved); setSubagentDetailLoading( Boolean( @@ -600,7 +722,7 @@ export function EnvironmentPanelProvider({ children }: React.PropsWithChildren) ), ); } - }, [open, selectedSubagentRunId, subagentViews, subagentsEnabled, tab]); + }, [selectedSubagentRunId, subagentViews, subagentsEnabled, surfaceState.toolsOpen, tab]); React.useEffect(() => { if (!subagentsEnabled) return; @@ -773,11 +895,23 @@ export function EnvironmentPanelProvider({ children }: React.PropsWithChildren) setCancelAgent(() => handler ?? undefined); }, []); + React.useEffect(() => { + localStorage.setItem(OPEN_STORAGE_KEY, surfaceState.toolsOpen ? "1" : "0"); + localStorage.setItem(QUICK_VIEW_OPEN_STORAGE_KEY, surfaceState.quickViewOpen ? "1" : "0"); + localStorage.setItem(TAB_STORAGE_KEY, surfaceState.toolsTab); + localStorage.setItem(LAST_TOOLS_TAB_STORAGE_KEY, surfaceState.toolsTab); + if (surfaceState.frontSurface) { + localStorage.setItem(FRONT_SURFACE_STORAGE_KEY, surfaceState.frontSurface); + } else { + localStorage.removeItem(FRONT_SURFACE_STORAGE_KEY); + } + }, [surfaceState]); + const activeEditorState = editorState.workspaceId === activeId ? editorState : EMPTY_EDITOR_STATE; const displayedSubagentSelection = subagentPanelSelectionState( subagentViews, selectedSubagentRunId, - open && tab === "subagents", + surfaceState.toolsOpen && tab === "subagents", subagentDetailLoading, subagentDetailError, ); @@ -793,20 +927,36 @@ export function EnvironmentPanelProvider({ children }: React.PropsWithChildren) : activeEditorState.dirty ? "Save or discard the open file's edits before changing Git state." : null; + const surfaceMode: EnvironmentSurfaceMode = !surfaceState.toolsOpen + ? "closed" + : surfaceLayout.inline + ? "tools-pinned" + : "tools-floating"; + // Floating layouts do not have enough guaranteed room for both the tools + // surface and Assistant. Let Assistant layer at the normal chat edge there. + const dockRightInset = surfaceState.toolsOpen && surfaceLayout.inline ? surfaceLayout.width : 0; const value = React.useMemo( () => ({ - open, - compactModalOpen, + toolsOpen: surfaceState.toolsOpen, + quickViewOpen: surfaceState.quickViewOpen, + frontSurface: surfaceState.frontSurface, + surfaceMode, + dockRightInset, tab, subagentsEnabled, reviewMode, fileRequest, - close, - setCompactModalOpen, + closeAll, + closeTools, + closeQuickView, + reportSurfaceLayout, setTab, - show, - toggle, + showTools, + showQuickView, + activateSurface, + toggleTools, + toggleQuickView, openFile, openReview, subagents, @@ -859,13 +1009,15 @@ export function EnvironmentPanelProvider({ children }: React.PropsWithChildren) agentBusy, announceSubagentDetail, cancelAgent, - close, - compactModalOpen, + activateSurface, + closeAll, + closeQuickView, + closeTools, createWorktree, + dockRightInset, fileRequest, gitOperationBusy, gitMutationBlockedReason, - open, openFile, openReview, openSubagent, @@ -873,6 +1025,7 @@ export function EnvironmentPanelProvider({ children }: React.PropsWithChildren) releaseSubagents, reviewMode, retrySubagentDetail, + reportSurfaceLayout, stopSubagent, selectSubagent, selectedSubagentRunId, @@ -882,10 +1035,10 @@ export function EnvironmentPanelProvider({ children }: React.PropsWithChildren) displayedSubagentView?.snapshot?.revision, subagentFocusDetailVersion, setCancelAgentHandler, - setCompactModalOpen, setCreateWorktreeHandler, setTab, - show, + showQuickView, + showTools, subagentDetailError, subagentEffectDetail, subagentStopPending, @@ -894,9 +1047,14 @@ export function EnvironmentPanelProvider({ children }: React.PropsWithChildren) subagentCounts, subagentViews, subagentsEnabled, + surfaceMode, + surfaceState.frontSurface, + surfaceState.quickViewOpen, + surfaceState.toolsOpen, syncSubagents, tab, - toggle, + toggleQuickView, + toggleTools, ], ); return ( @@ -906,7 +1064,7 @@ export function EnvironmentPanelProvider({ children }: React.PropsWithChildren) ownerKey={subagentPanelOwnerKey(subagents.chatId, subagents.workspaceId)} runs={subagents.liveSnapshots} detailRequest={subagentDetailAnnouncement} - portalHost={open && tab !== "overview" ? subagentAnnouncerHost : null} + portalHost={surfaceState.toolsOpen ? subagentAnnouncerHost : null} /> ) : null} {children} @@ -925,6 +1083,7 @@ function EnvironmentPanelSurface({ width, containerWidth, inline, + presented, resizing, setResizing, setWidth, @@ -932,6 +1091,7 @@ function EnvironmentPanelSurface({ width: number; containerWidth: number; inline: boolean; + presented: boolean; resizing: boolean; setResizing: (value: boolean) => void; setWidth: (value: number) => void; @@ -940,8 +1100,7 @@ function EnvironmentPanelSurface({ const toggleShortcut = useShortcutLabel("environment.toggle"); const toggleShortcutBinding = useShortcutBinding("environment.toggle"); const { active } = useActiveWorkspace(); - const fullOpen = panel.open && panel.tab !== "overview"; - const compactModal = fullOpen && !inline; + const fullOpen = panel.toolsOpen; const compactTabs = width < 520; const surfaceRef = React.useRef(null); const setSubagentAnnouncerHost = panel.setSubagentAnnouncerHost; @@ -955,10 +1114,6 @@ function EnvironmentPanelSurface({ const activeTabRef = React.useRef(null); const handledSubagentFocusRef = React.useRef(0); const widthRef = React.useRef(width); - const previousSurfaceModeRef = React.useRef({ - fullOpen, - compactModal, - }); const activeFileRequest = panel.fileRequest?.workspaceId === active?.id ? panel.fileRequest : null; const representativeSubagent = @@ -967,7 +1122,7 @@ function EnvironmentPanelSurface({ widthRef.current = width; React.useLayoutEffect(() => { - if (!fullOpen) return; + if (!fullOpen || !presented || panel.frontSurface !== "tools") return; if ( panel.tab === "subagents" && panel.subagentFocusDetailVersion > handledSubagentFocusRef.current @@ -982,52 +1137,21 @@ function EnvironmentPanelSurface({ return () => window.cancelAnimationFrame(frame); } activeTabRef.current?.focus(); - }, [fullOpen, panel.subagentFocusDetailVersion, panel.tab]); + }, [fullOpen, panel.frontSurface, panel.subagentFocusDetailVersion, panel.tab, presented]); - React.useLayoutEffect(() => { - const previous = previousSurfaceModeRef.current; - const next = { fullOpen, compactModal }; - previousSurfaceModeRef.current = next; - focusEnvironmentCompactModalTransition( - previous, - next, - surfaceRef.current, - document.activeElement, - activeTabRef.current, - ); - }, [compactModal, fullOpen]); - - React.useEffect(() => { - if (!compactModal) return; - const onKeyDown = (event: KeyboardEvent) => { - if (event.defaultPrevented || event.key !== "Tab") return; - if ( - document.querySelector( - '[data-slot="dialog-content"][data-state="open"], [data-slot="popover-content"][data-state="open"]', - ) - ) - return; - const target = environmentCompactModalTabWrapTarget( - environmentCompactModalFocusableTargets(surfaceRef.current), - document.activeElement, - event.shiftKey, - ); - if (target) { - event.preventDefault(); - target.focus(); - } - }; - document.addEventListener("keydown", onKeyDown); - return () => document.removeEventListener("keydown", onKeyDown); - }, [compactModal]); + const resizeBounds = resolveEnvironmentPanelResizeBounds(containerWidth, inline); + const clampToResizeBounds = React.useCallback( + (nextWidth: number) => Math.min(resizeBounds.max, Math.max(resizeBounds.min, nextWidth)), + [resizeBounds.max, resizeBounds.min], + ); const commitWidth = React.useCallback( (nextWidth: number) => { - const clamped = clampEnvironmentPanelWidth(nextWidth, containerWidth); + const clamped = clampToResizeBounds(nextWidth); setWidth(clamped); localStorage.setItem(WIDTH_STORAGE_KEY, String(Math.round(clamped))); }, - [containerWidth, setWidth], + [clampToResizeBounds, setWidth], ); const beginResize = React.useCallback( @@ -1039,9 +1163,7 @@ function EnvironmentPanelSurface({ const startWidth = widthRef.current; setResizing(true); const move = (moveEvent: PointerEvent) => { - setWidth( - clampEnvironmentPanelWidth(startWidth + startX - moveEvent.clientX, containerWidth), - ); + setWidth(clampToResizeBounds(startWidth + startX - moveEvent.clientX)); }; const finish = (endEvent: PointerEvent) => { commitWidth( @@ -1056,7 +1178,7 @@ function EnvironmentPanelSurface({ window.addEventListener("pointerup", finish); window.addEventListener("pointercancel", finish); }, - [commitWidth, containerWidth, fullOpen, setResizing, setWidth], + [clampToResizeBounds, commitWidth, fullOpen, setResizing, setWidth], ); const resizeWithKeyboard = React.useCallback( @@ -1065,46 +1187,49 @@ function EnvironmentPanelSurface({ let next = width; if (event.key === "ArrowLeft") next += increment; else if (event.key === "ArrowRight") next -= increment; - else if (event.key === "Home") next = MIN_PANEL_WIDTH; - else if (event.key === "End") next = MAX_PANEL_WIDTH; + else if (event.key === "Home") next = resizeBounds.min; + else if (event.key === "End") next = resizeBounds.max; else return; event.preventDefault(); commitWidth(next); }, - [commitWidth, width], + [commitWidth, resizeBounds.max, resizeBounds.min, width], ); return (