↔ Review
▤ Files
@@ -683,12 +688,14 @@
Environment surfaces
import { FilesPanel } from "./files-panel";
export function EnvironmentSurfaces() {
- const [tab, setTab] = React.useState("overview");
+ const [toolsOpen, setToolsOpen] = React.useState(false);
+ const [quickViewOpen, setQuickViewOpen] = React.useState(false);
+ const [tab, setTab] = React.useState("review");
return (
<>
- {tab === "overview" ? <EnvironmentSummaryCard /> : null}
- <WorkspacePanel hidden={tab === "overview"}>
+ {quickViewOpen ? <QuickViewCard /> : null}
+ <WorkspacePanel hidden={!toolsOpen}>
{tab === "review" ? <ReviewPanel /> : <FilesPanel />}
</WorkspacePanel>
</>
@@ -714,7 +721,7 @@
Shadow recipes
Control rest
0 1px 2px -1px / 8%Neutral and glass buttons; never enough to look detached.
Control hover
0 2px 4px -1px / 10%Paired with a small background increase over 150ms.
Popover
0.5px edge + 3px/7.5px + 20px ambientMenus and approval cards. Low opacity matters more than large blur.
-
Dialog
0.5px edge + 16px/32px -8px / 30%Reserved for modal interruption; stronger in dark mode.
+
Dialog / floating tools
0.5px edge + 16px/32px -8px / 30%Separates floating work surfaces and modal interruptions; stronger in dark mode.
@@ -819,6 +826,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 +870,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 +910,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 +970,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 +1208,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-sheet 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 cebf29d1..7fb78963 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;
@@ -128,7 +136,6 @@ interface EnvironmentPanelContextValue {
subagentStopPendingRunIds: readonly string[];
subagentStopErrorsByRunId: Readonly
>;
announceSubagentDetail: (ownerKey: string, message: string) => void;
- setSubagentAnnouncerHost: (host: HTMLElement | null) => void;
syncSubagents: (
chatId: string,
workspaceId: string,
@@ -155,7 +162,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 +193,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);
@@ -229,9 +286,6 @@ export function EnvironmentPanelProvider({ children }: React.PropsWithChildren)
const [subagentDetailRequestVersion, setSubagentDetailRequestVersion] = React.useState(0);
const [subagentDetailAnnouncement, setSubagentDetailAnnouncement] =
React.useState(null);
- const [subagentAnnouncerHost, setSubagentAnnouncerHost] = React.useState(
- null,
- );
const [gitOperationBusy, setGitOperationBusyState] = React.useState(false);
const [createWorktree, setCreateWorktree] = React.useState<
((branchName: string) => Promise) | undefined
@@ -239,7 +293,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 +340,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 +467,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 +557,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 +571,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 +708,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 +718,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 +891,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 +923,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,
@@ -835,7 +981,6 @@ export function EnvironmentPanelProvider({ children }: React.PropsWithChildren)
? subagentStopPending.errors
: {},
announceSubagentDetail,
- setSubagentAnnouncerHost,
syncSubagents,
releaseSubagents,
openSubagent,
@@ -859,13 +1004,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 +1020,7 @@ export function EnvironmentPanelProvider({ children }: React.PropsWithChildren)
releaseSubagents,
reviewMode,
retrySubagentDetail,
+ reportSurfaceLayout,
stopSubagent,
selectSubagent,
selectedSubagentRunId,
@@ -882,10 +1030,10 @@ export function EnvironmentPanelProvider({ children }: React.PropsWithChildren)
displayedSubagentView?.snapshot?.revision,
subagentFocusDetailVersion,
setCancelAgentHandler,
- setCompactModalOpen,
setCreateWorktreeHandler,
setTab,
- show,
+ showQuickView,
+ showTools,
subagentDetailError,
subagentEffectDetail,
subagentStopPending,
@@ -894,9 +1042,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 +1059,6 @@ export function EnvironmentPanelProvider({ children }: React.PropsWithChildren)
ownerKey={subagentPanelOwnerKey(subagents.chatId, subagents.workspaceId)}
runs={subagents.liveSnapshots}
detailRequest={subagentDetailAnnouncement}
- portalHost={open && tab !== "overview" ? subagentAnnouncerHost : null}
/>
) : null}
{children}
@@ -925,6 +1077,7 @@ function EnvironmentPanelSurface({
width,
containerWidth,
inline,
+ presented,
resizing,
setResizing,
setWidth,
@@ -932,6 +1085,7 @@ function EnvironmentPanelSurface({
width: number;
containerWidth: number;
inline: boolean;
+ presented: boolean;
resizing: boolean;
setResizing: (value: boolean) => void;
setWidth: (value: number) => void;
@@ -940,25 +1094,12 @@ 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;
- const setSurfaceRef = React.useCallback(
- (node: HTMLElement | null) => {
- surfaceRef.current = node;
- setSubagentAnnouncerHost(node);
- },
- [setSubagentAnnouncerHost],
- );
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 +1108,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 +1123,21 @@ function EnvironmentPanelSurface({
return () => window.cancelAnimationFrame(frame);
}
activeTabRef.current?.focus();
- }, [fullOpen, panel.subagentFocusDetailVersion, panel.tab]);
-
- React.useLayoutEffect(() => {
- const previous = previousSurfaceModeRef.current;
- const next = { fullOpen, compactModal };
- previousSurfaceModeRef.current = next;
- focusEnvironmentCompactModalTransition(
- previous,
- next,
- surfaceRef.current,
- document.activeElement,
- activeTabRef.current,
- );
- }, [compactModal, fullOpen]);
+ }, [fullOpen, panel.frontSurface, panel.subagentFocusDetailVersion, panel.tab, presented]);
- 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 +1149,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 +1164,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 +1173,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 (
panel.activateSurface("tools")}
+ onPointerDownCapture={() => panel.activateSurface("tools")}
className={cn(
- "environment-panel absolute inset-y-0 right-0 z-30 flex h-full min-h-0 flex-col overflow-hidden bg-popover text-primary",
- fullOpen ? "border-l border-separator" : "border-l-0",
- !inline && "shadow-dialog",
+ "environment-panel absolute z-30 flex min-h-0 flex-col overflow-hidden bg-popover text-primary",
+ inline
+ ? "inset-y-0 right-0 border-l border-separator"
+ : "bottom-3 right-3 top-3 rounded-sheet border border-separator shadow-dialog",
resizing
? "transition-none"
: "transition-[width,opacity,transform] duration-300 ease-out motion-reduce:transition-none",
- !fullOpen && !inline && "translate-x-full",
+ (!fullOpen || !presented) && !inline && "translate-x-[calc(100%+0.75rem)]",
)}
style={{
width: fullOpen ? width : inline ? 0 : width,
- opacity: fullOpen ? 1 : 0,
- pointerEvents: fullOpen ? "auto" : "none",
+ opacity: fullOpen && presented ? 1 : 0,
+ pointerEvents: fullOpen && presented ? "auto" : "none",
}}
>
panel.show("overview")}
- aria-label="Show environment summary"
- title="Show environment summary"
+ onClick={panel.showQuickView}
+ aria-label="Show Quick View"
+ title="Show Quick View"
className="no-drag"
>
@@ -1192,7 +1303,7 @@ function EnvironmentPanelSurface({
variant="transparent"
size="small"
iconOnly
- onClick={panel.close}
+ onClick={panel.closeTools}
aria-label="Close environment panel"
aria-keyshortcuts={ariaKeyShortcut(toggleShortcutBinding)}
title={`Close environment panel (${toggleShortcut})`}
@@ -1212,7 +1323,7 @@ function EnvironmentPanelSurface({
>
activeTabRef.current}
/>
@@ -1261,7 +1372,7 @@ function EnvironmentPanelSurface({
>
(null);
const subagentCounts = panel.subagentCounts;
@@ -1304,25 +1423,38 @@ function EnvironmentSummaryCard() {
}, [open, present]);
React.useLayoutEffect(() => {
- if (open && present) menuButtonRef.current?.focus();
- }, [open, present]);
+ if (open && present && presented && panel.frontSurface === "quick-view") {
+ menuButtonRef.current?.focus();
+ }
+ }, [open, panel.frontSurface, present, presented]);
return (
panel.activateSurface("quick-view")}
+ onPointerDownCapture={() => panel.activateSurface("quick-view")}
+ className={cn(
+ "quick-view-card absolute top-14 z-30 flex max-h-[calc(100%-4.25rem)] flex-col overflow-hidden rounded-sheet border border-separator bg-popover text-primary shadow-dialog transition-[right,width,opacity,transform] duration-300 ease-out motion-reduce:transition-none",
+ (!open || !presented) && "translate-x-[calc(100%+0.75rem)] opacity-0",
+ )}
+ style={{
+ width,
+ right,
+ pointerEvents: open && presented ? "auto" : "none",
+ }}
>
{present ? (
<>
- Environment
+ Quick View
@@ -1331,8 +1463,8 @@ function EnvironmentSummaryCard() {
variant="transparent"
size="small"
iconOnly
- aria-label="Environment actions"
- title="Environment actions"
+ aria-label="Quick View actions"
+ title="Quick View actions"
className="no-drag"
>
@@ -1343,7 +1475,7 @@ function EnvironmentSummaryCard() {
Review changes
- panel.show("files")}>
+ panel.showTools("files")}>
Browse files
@@ -1358,7 +1490,7 @@ function EnvironmentSummaryCard() {
panel.show("subagents")}
+ onClick={() => panel.showTools("subagents")}
aria-label={`Open Subagents, ${subagentSummary.ariaLabel}`}
className="grid min-h-11 w-full grid-cols-[20px_minmax(0,1fr)_auto] items-center gap-3 rounded-control px-2 text-left outline-none transition-colors duration-150 ease-out hover:bg-list-hover active:bg-list-selection focus-visible:bg-list-selection focus-visible:outline-none"
>
@@ -1405,11 +1537,23 @@ export function EnvironmentWorkbench({ children }: React.PropsWithChildren) {
const [containerWidth, setContainerWidth] = React.useState(() => window.innerWidth);
const [resizing, setResizing] = React.useState(false);
const [preferredWidth, setPreferredWidth] = React.useState(storedPanelWidth);
- const fullOpen = panel.open && panel.tab !== "overview";
+ const fullOpen = panel.toolsOpen;
const { width: renderedWidth, inline } = resolveEnvironmentPanelLayout(
preferredWidth,
containerWidth,
);
+ const quickViewLayout = resolveQuickViewLayout(
+ containerWidth,
+ panel.toolsOpen,
+ renderedWidth,
+ inline,
+ );
+ const stacked =
+ panel.quickViewOpen && panel.toolsOpen && !quickViewLayout.alongsideTools;
+ const toolsPresented =
+ panel.toolsOpen && (!stacked || panel.frontSurface === "tools");
+ const quickViewPresented =
+ panel.quickViewOpen && (!stacked || panel.frontSurface === "quick-view");
React.useLayoutEffect(() => {
const element = containerRef.current;
@@ -1422,45 +1566,56 @@ export function EnvironmentWorkbench({ children }: React.PropsWithChildren) {
}, []);
React.useEffect(() => {
- if (!panel.open) return;
+ if (!panel.toolsOpen && !panel.quickViewOpen) return;
const onKeyDown = (event: KeyboardEvent) => {
if (event.defaultPrevented || event.key !== "Escape") return;
if (document.querySelector('[data-slot="dialog-content"][data-state="open"]')) return;
+ if (document.querySelector('[data-compact-sidebar-open="true"]')) return;
+ if (panel.gitOperationBusy) return;
+ const activeElement = document.activeElement;
+ const focusedSurface =
+ activeElement instanceof HTMLElement &&
+ shouldRestoreEnvironmentFocus(activeElement, "quick-view")
+ ? "quick-view"
+ : activeElement instanceof HTMLElement && shouldRestoreEnvironmentFocus(activeElement, "tools")
+ ? "tools"
+ : panel.frontSurface;
+ if (!focusedSurface) return;
event.preventDefault();
- panel.close();
+ if (focusedSurface === "quick-view") panel.closeQuickView();
+ else panel.closeTools();
};
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
- }, [panel]);
+ }, [
+ panel.closeQuickView,
+ panel.closeTools,
+ panel.frontSurface,
+ panel.gitOperationBusy,
+ panel.quickViewOpen,
+ panel.toolsOpen,
+ ]);
- const overlayOpen = fullOpen && !inline;
- const setCompactModalOpen = panel.setCompactModalOpen;
+ const reportSurfaceLayout = panel.reportSurfaceLayout;
React.useLayoutEffect(() => {
- setCompactModalOpen(overlayOpen);
- return () => {
- setCompactModalOpen(false);
- };
- }, [overlayOpen, setCompactModalOpen]);
+ reportSurfaceLayout(fullOpen ? { inline, width: renderedWidth } : null);
+ return () => reportSurfaceLayout(null);
+ }, [fullOpen, inline, renderedWidth, reportSurfaceLayout]);
return (
-
-
- {children}
-
- {overlayOpen ? (
-
- ) : null}
-
+
+
{children}
+
(panel.open ? panel.close() : panel.show("overview"))}
- disabled={disabled}
- aria-label={panel.open ? "Hide environment" : "Show environment"}
+ onClick={panel.toggleTools}
+ disabled={disabled || panel.gitOperationBusy}
+ aria-label={active ? "Hide Environment" : "Show Environment"}
aria-keyshortcuts={ariaKeyShortcut(toggleShortcutBinding)}
- aria-pressed={panel.open}
- aria-controls={
- panel.open && panel.tab !== "overview" ? "environment-panel" : "environment-summary-card"
- }
- title={`Toggle environment (${toggleShortcut})`}
+ aria-pressed={active}
+ aria-controls="environment-panel"
+ title={`Toggle Environment (${toggleShortcut})`}
data-environment-toggle
>
- {panel.open ?
:
}
+ {active ?
:
}
+
+ );
+}
+
+function QuickViewIcon() {
+ return (
+
+
+
+
+
+
+ );
+}
+
+export function QuickViewToggle({ disabled = false }: { disabled?: boolean }) {
+ const panel = useEnvironmentPanel();
+ const active = panel.quickViewOpen;
+ return (
+
+
);
}
diff --git a/renderer/components/environment-subagents-contract.test.ts b/renderer/components/environment-subagents-contract.test.ts
index 651b4218..219bc6a2 100644
--- a/renderer/components/environment-subagents-contract.test.ts
+++ b/renderer/components/environment-subagents-contract.test.ts
@@ -4,11 +4,11 @@ import test from "node:test";
import { DISABLED_APP_CAPABILITIES, parseAppCapabilities } from "../lib/app-capabilities.js";
import {
availableEnvironmentPanelTabs,
- focusEnvironmentCompactModalTransition,
normalizeEnvironmentPanelTab,
+ reduceEnvironmentSurfaceState,
+ shouldRestoreEnvironmentFocus,
storedEnvironmentPanelTab,
- type EnvironmentFocusBoundary,
- type EnvironmentFocusTarget,
+ type EnvironmentSurfaceMode,
} from "../lib/environment-panel-state.js";
import {
compactSidebarAutoFocusIntent,
@@ -52,114 +52,78 @@ test("fresh renderer capabilities fail closed until main explicitly enables suba
assert.deepEqual(availableEnvironmentPanelTabs(true), ["review", "subagents", "files"]);
});
-test("a disabled renderer repairs a stored Subagents destination to Overview", () => {
+test("a disabled renderer presents a stored Subagents destination as Review without erasing it", () => {
const values = new Map
([["tab", "subagents"]]);
const storage = {
getItem: (key: string) => values.get(key) ?? null,
setItem: (key: string, value: string) => values.set(key, value),
};
- assert.equal(storedEnvironmentPanelTab(storage, "tab", false), "overview");
- assert.equal(values.get("tab"), "overview");
- assert.equal(normalizeEnvironmentPanelTab("subagents", false), "overview");
+ assert.equal(storedEnvironmentPanelTab(storage, "tab", false), "review");
+ assert.equal(values.get("tab"), "subagents");
+ assert.equal(normalizeEnvironmentPanelTab("subagents", false), "review");
assert.equal(normalizeEnvironmentPanelTab("subagents", true), "subagents");
});
-test("an already-open inline surface moves outside focus into its compact modal before paint", () => {
- const inside = {} as Node;
- const outside = {} as Node;
- let surfaceFocusCount = 0;
- let tabFocusCount = 0;
- const surface: EnvironmentFocusBoundary = {
- isConnected: true,
- contains: (target) => target === inside,
- focus: () => {
- surfaceFocusCount += 1;
- },
- };
- const activeTab: EnvironmentFocusTarget = {
- isConnected: true,
- focus: () => {
- tabFocusCount += 1;
- },
- };
- const inline = { fullOpen: true, compactModal: false };
- const modal = { fullOpen: true, compactModal: true };
-
- assert.equal(
- focusEnvironmentCompactModalTransition(inline, modal, surface, outside, activeTab),
- true,
- );
- assert.equal(tabFocusCount, 1);
- assert.equal(surfaceFocusCount, 0);
-
- assert.equal(
- focusEnvironmentCompactModalTransition(inline, modal, surface, inside, activeTab),
- false,
- "focus already inside the surface must not be stolen",
- );
- assert.equal(
- focusEnvironmentCompactModalTransition(
- { fullOpen: false, compactModal: false },
- modal,
- surface,
- outside,
- activeTab,
- ),
- false,
- "initial compact open keeps the existing initial-open focus path",
- );
- assert.equal(
- focusEnvironmentCompactModalTransition(modal, modal, surface, outside, activeTab),
- false,
- "ordinary compact rerenders must not refocus the modal",
- );
+test("Environment exposes explicit non-modal surface states", () => {
+ const modes: EnvironmentSurfaceMode[] = [
+ "closed",
+ "tools-pinned",
+ "tools-floating",
+ ];
+ assert.deepEqual(modes, ["closed", "tools-pinned", "tools-floating"]);
+});
- activeTab.isConnected = false;
+test("Environment restores its trigger only while focus remains in the closing surface", () => {
assert.equal(
- focusEnvironmentCompactModalTransition(inline, modal, surface, outside, activeTab),
+ shouldRestoreEnvironmentFocus({ closest: (selector) => selector }, "quick-view"),
true,
);
- assert.equal(surfaceFocusCount, 1, "the mounted dialog is the safe fallback");
+ assert.equal(shouldRestoreEnvironmentFocus({ closest: () => null }, "tools"), false);
+ assert.equal(shouldRestoreEnvironmentFocus(null, "tools"), false);
});
-test("compact sidebar remount defers to Environment's exact multi-frame focus restoration", () => {
- const sidebarOpen: CompactSidebarFocusState = {
- compact: true,
- expanded: true,
- contentModalOpen: false,
- };
- const environmentOpen = { ...sidebarOpen, contentModalOpen: true };
- const environmentClosed = { ...sidebarOpen };
- const firstSidebarControl = { id: "new-agent" };
- const exactReturnTarget = { id: "selected-chat" };
- let activeElement = { id: "environment-close" };
- let frames: Array<() => void> = [];
- const requestFrame = (callback: () => void) => frames.push(callback);
- const flushFrame = () => {
- const current = frames;
- frames = [];
- current.forEach((callback) => callback());
+test("Quick View and Environment reduce as independent surfaces", () => {
+ const closed = {
+ quickViewOpen: false,
+ toolsOpen: false,
+ toolsTab: "review" as const,
+ frontSurface: null,
};
+ const quick = reduceEnvironmentSurfaceState(closed, { type: "toggle-quick-view" });
+ assert.deepEqual(quick, { ...closed, quickViewOpen: true, frontSurface: "quick-view" });
+
+ const both = reduceEnvironmentSurfaceState(quick, { type: "show-tools", tab: "files" });
+ assert.deepEqual(both, {
+ quickViewOpen: true,
+ toolsOpen: true,
+ toolsTab: "files",
+ frontSurface: "tools",
+ });
+
+ assert.deepEqual(reduceEnvironmentSurfaceState(both, { type: "close-tools" }), {
+ quickViewOpen: true,
+ toolsOpen: false,
+ toolsTab: "files",
+ frontSurface: "quick-view",
+ });
+ assert.deepEqual(reduceEnvironmentSurfaceState(both, { type: "close-quick-view" }), {
+ quickViewOpen: false,
+ toolsOpen: true,
+ toolsTab: "files",
+ frontSurface: "tools",
+ });
- assert.equal(compactSidebarAutoFocusIntent(sidebarOpen, environmentOpen), null);
- requestFrame(() => {
- activeElement = exactReturnTarget;
+ const toolsThenQuick = reduceEnvironmentSurfaceState(
+ reduceEnvironmentSurfaceState(closed, { type: "toggle-tools", tab: "review" }),
+ { type: "toggle-quick-view" },
+ );
+ assert.deepEqual(toolsThenQuick, {
+ quickViewOpen: true,
+ toolsOpen: true,
+ toolsTab: "review",
+ frontSurface: "quick-view",
});
- flushFrame();
- assert.equal(activeElement, exactReturnTarget);
-
- const resumedIntent = compactSidebarAutoFocusIntent(environmentOpen, environmentClosed);
- if (resumedIntent === "first-control") {
- requestFrame(() => {
- activeElement = firstSidebarControl;
- });
- }
- flushFrame();
-
- assert.equal(resumedIntent, "preserve-current");
- assert.equal(activeElement, exactReturnTarget);
- assert.equal(frames.length, 0, "the resumed trap must not leave a later focus frame queued");
});
test("ordinary compact sidebar opens still auto-focus their first control", () => {
@@ -174,58 +138,63 @@ test("ordinary compact sidebar opens still auto-focus their first control", () =
assert.equal(compactSidebarAutoFocusIntent(opened, opened), null);
});
-test("compact Environment modality blocks every app-level interaction seam and cleans up", () => {
+test("floating Environment remains non-modal across every app-level interaction seam", () => {
const environment = source("./environment-panel.tsx");
const root = source("../main/root-view.tsx");
const layout = source("../main/chat-layout.tsx");
- const splitView = source("./ui.tsx");
const assistant = source("./assistant/assistant-dock.tsx");
- const commands = source("../lib/command-system.tsx");
- assert.match(environment, /const overlayOpen = fullOpen && !inline/u);
- assert.match(environment, /setCompactModalOpen\(overlayOpen\)/u);
- assert.match(environment, /environmentCompactModalFocusableTargets\(surfaceRef\.current\)/u);
+ assert.match(environment, /data-surface-mode=\{inline \? "tools-pinned" : "tools-floating"\}/u);
assert.match(
environment,
- /environmentCompactModalTabWrapTarget\([\s\S]*document\.activeElement,[\s\S]*event\.shiftKey/u,
+ /bottom-3 right-3 top-3 rounded-sheet border border-separator shadow-dialog/u,
);
assert.match(
environment,
- /return \(\) => \{\s*setCompactModalOpen\(false\);\s*\}/u,
- "close, responsive-inline transitions, and route unmount must clear shared modal state",
- );
- assert.doesNotMatch(environment, /\.closest\("main"\)/u);
- assert.doesNotMatch(environment, /const snapshots = background\.map/u);
-
- assert.match(root, //u);
- assert.match(
- root,
- / /u,
- );
- assert.match(layout, /contentModalOpen=\{environmentPanel\.compactModalOpen\}/u);
- assert.match(splitView, /inert=\{collapsed \|\| contentModalOpen \? true : undefined\}/u);
- assert.match(splitView, /tabIndex=\{collapsed \|\| compact \|\| contentModalOpen \? -1 : 0\}/u);
- assert.match(splitView, /useCommandHandler\("sidebar\.toggle", toggle, !contentModalOpen\)/u);
- assert.match(
- splitView,
- /compactSidebarFocusIntentRef\.current = compactSidebarAutoFocusIntent\(\s*previousCompactSidebarFocusStateRef\.current,\s*next,\s*\)/u,
+ /reportSurfaceLayout\(fullOpen \? \{ inline, width: renderedWidth \} : null\)/u,
);
assert.match(
- splitView,
- /compactSidebarFocusIntentRef\.current === "first-control"\s*\?\s*requestAnimationFrame/u,
+ environment,
+ /const toggleTools = React\.useCallback/u,
);
+ assert.match(environment, /\{children\}<\/div>/u);
+ assert.doesNotMatch(environment, /bg-black|backdrop-blur|aria-modal|role=\{.*dialog/u);
+ assert.doesNotMatch(environment, /environmentCompactModal|setCompactModalOpen/u);
- assert.match(assistant, /if \(interactionBlocked\) return/u);
- assert.match(
- assistant,
- /useCommandHandler\("assistant\.open", openPanel, !interactionBlocked\)/u,
- );
- assert.match(assistant, /inert=\{interactionBlocked \? true : undefined\}/u);
- assert.match(assistant, /aria-hidden=\{interactionBlocked \? true : undefined\}/u);
- assert.match(assistant, /visibility: interactionBlocked \? "hidden" : undefined/u);
+ assert.match(root, /
/u);
+ assert.doesNotMatch(root, /applicationModal=|compactModalOpen/u);
+ assert.match(root, / /u);
+ assert.doesNotMatch(layout, /contentModalOpen=/u);
+
+ assert.match(assistant, /useCommandHandler\("assistant\.open", openPanel\)/u);
+ assert.match(assistant, /Math\.max\(0, rightInset\)/u);
+ assert.doesNotMatch(assistant, /interactionBlocked|data-environment-modal-background/u);
+});
- assert.match(commands, /commandExecutionAllowed\(commandId, \{\s*applicationModal/u);
- assert.match(commands, /if \(applicationModal && paletteOpen\) setPaletteOpen\(false\)/u);
+test("Environment and Quick View have independent toolbar and command routes", () => {
+ const environment = source("./environment-panel.tsx");
+ const pane = source("../main/chat-pane.tsx");
+ const root = source("../main/root-view.tsx");
+
+ assert.match(environment, /export function EnvironmentPanelToggle/u);
+ assert.match(environment, /onClick=\{panel\.toggleTools\}/u);
+ assert.match(environment, /aria-controls="environment-panel"/u);
+ assert.match(environment, /title=\{`Toggle Environment/u);
+ assert.match(environment, /export function QuickViewToggle/u);
+ assert.match(environment, /onClick=\{panel\.toggleQuickView\}/u);
+ assert.match(environment, /data-quick-view-toggle/u);
+ assert.match(environment, / /u);
+ assert.match(pane, / /u);
+ assert.match(root, /"environment\.toggle",[\s\S]*environmentPanel\.toggleTools\(\)/u);
+ assert.match(root, /"quick-view\.toggle",[\s\S]*environmentPanel\.toggleQuickView\(\)/u);
+});
+
+test("the active terminal relies on its selected tab instead of outlining the viewport", () => {
+ const terminal = source("./terminal-drawer.tsx");
+
+ assert.match(terminal, /aria-current=\{selected \? "page" : undefined\}/u);
+ assert.doesNotMatch(terminal, /ring-1 ring-inset ring-accent\/35/u);
});
test("archived subagent references remain stored but are invisible while disabled", () => {
@@ -268,7 +237,7 @@ test("the Environment work surface owns one mounted Subagents destination", () =
assert.match(environment, /\{panel\.subagentsEnabled \? \(\s* void update\(\), 1_000\)/u);
assert.match(capabilityProvider, /if \(!cancelled\) setCurrent\(next\)/u);
- assert.match(
- environment,
- /storedEnvironmentPanelTab\(localStorage, TAB_STORAGE_KEY, subagentsEnabled\)/u,
- );
+ assert.match(environment, /const tab = normalizeEnvironmentPanelTab\(/u);
+ assert.match(environment, /surfaceState\.toolsTab, subagentsEnabled/u);
assert.match(environment, /normalizeEnvironmentPanelTab\(nextTab, subagentsEnabled\)/u);
assert.match(environment, /if \(!subagentsEnabled\) return;/u);
assert.match(environment, /\{subagentsEnabled \? \(\s*
0 \? \(/u);
assert.match(pane, /visibleSubagentReferences\(messages, environmentPanel\.subagentsEnabled\)/u);
assert.match(pane, /subagentsEnabled=\{environmentPanel\.subagentsEnabled\}/u);
});
-test("the Environment summary exposes conditional current-chat counts and the shared orb", () => {
+test("Quick View exposes conditional current-chat counts and the shared orb", () => {
const environment = source("./environment-panel.tsx");
assert.match(
@@ -323,7 +287,7 @@ test("the Environment summary exposes conditional current-chat counts and the sh
/const hasSubagents =\s+panel\.subagentsEnabled && subagentCounts\.active \+ subagentCounts\.done > 0/u,
);
assert.match(environment, /\{hasSubagents \? \(/u);
- assert.match(environment, /panel\.show\("subagents"\)/u);
+ assert.match(environment, /panel\.showTools\("subagents"\)/u);
assert.match(environment, /\s*\{subagentsEnabled \? \(\s*
0 ? subagentSnapshotLiveSummary(runs) : "";
const terminal = subagentSnapshotLiveSummaryIsTerminal(runs);
@@ -61,7 +58,8 @@ export function SubagentLiveAnnouncer({
);
}, [detailRequest]);
- const region = (
+ // Keep the live region mounted outside panels that can become inert or hidden.
+ return (
);
- return portalHost ? createPortal(region, portalHost) : region;
}
diff --git a/renderer/components/subagents-panel.test.tsx b/renderer/components/subagents-panel.test.tsx
index 83b586ca..5cdc9fd8 100644
--- a/renderer/components/subagents-panel.test.tsx
+++ b/renderer/components/subagents-panel.test.tsx
@@ -14,11 +14,6 @@ import type {
SubagentWorkspaceWriteApprovalDetails,
} from "../shared/assistant.js";
import { mergeSubagentSnapshots, type SubagentRunView } from "../lib/subagent-view-state.js";
-import {
- ENVIRONMENT_COMPACT_MODAL_FOCUSABLE_SELECTOR,
- environmentCompactModalFocusableTargets,
- environmentCompactModalTabWrapTarget,
-} from "../lib/environment-panel-state.js";
import {
SubagentLiveAnnouncementCoordinator,
captureSubagentChipFocus,
@@ -427,11 +422,9 @@ function MountedSelectionRepairHarness({
function MountedLiveAnnouncerHarness({
detailRequest,
- host,
runs,
}: {
detailRequest: SubagentDetailAnnouncementRequest | null;
- host: HTMLElement;
runs: readonly SubagentRunSnapshotV1[];
}) {
return (
@@ -439,7 +432,6 @@ function MountedLiveAnnouncerHarness({
ownerKey={subagentPanelOwnerKey("chat-1", "workspace-1")}
runs={runs}
detailRequest={detailRequest}
- portalHost={host}
/>
);
}
@@ -2463,94 +2455,7 @@ test("mounted compact selection repair restores Back and breakpoint focus to the
}
});
-test("mounted compact Environment trap keeps a pointer-focused disclosure in Tab order without Jump to latest", async () => {
- const releaseMountedDomTest = await acquireMountedDomTest();
- const mounted = installMountedDom();
- const { createRoot } = await import("react-dom/client");
- const { flushSync } = await import("react-dom");
- const root = createRoot(mounted.container);
-
- try {
- flushSync(() => {
- root.render(
-
,
- );
- });
-
- const surface = mounted.container.getElementsByTagName("aside")[0] as HTMLElement;
- const buttons = Array.from(surface.getElementsByTagName("button")) as HTMLElement[];
- const input = surface.getElementsByTagName("input")[0] as HTMLElement;
- const summary = surface.getElementsByTagName("summary")[0] as HTMLElement;
- const focusableCandidates = [buttons[0]!, buttons[1]!, input, summary, buttons[2]!];
- for (const element of focusableCandidates) {
- Object.defineProperty(element, "offsetParent", {
- configurable: true,
- value: surface,
- });
- Object.defineProperty(element, "closest", {
- configurable: true,
- value: () => null,
- });
- }
- let receivedSelector = "";
- Object.defineProperty(surface, "querySelectorAll", {
- configurable: true,
- value: (selector: string) => {
- receivedSelector = selector;
- return focusableCandidates;
- },
- });
-
- summary.focus();
- const focusable = environmentCompactModalFocusableTargets(surface);
- assert.equal(receivedSelector, ENVIRONMENT_COMPACT_MODAL_FOCUSABLE_SELECTOR);
- assert.ok(
- ENVIRONMENT_COMPACT_MODAL_FOCUSABLE_SELECTOR.includes("summary:not([tabindex='-1'])"),
- );
- assert.ok(focusable.includes(buttons[0]!));
- assert.ok(focusable.includes(buttons[1]!));
- assert.ok(focusable.includes(input));
- assert.ok(focusable.includes(summary));
- assert.equal(mounted.document.activeElement, summary, "the disclosure has pointer focus");
- assert.equal(
- environmentCompactModalTabWrapTarget(focusable, summary, false),
- null,
- "Tab from the disclosure keeps native order instead of wrapping to the modal start",
- );
- assert.equal(
- environmentCompactModalTabWrapTarget(focusable, buttons[2]!, false),
- buttons[0],
- "the last control still wraps to the modal start",
- );
- assert.equal(
- environmentCompactModalTabWrapTarget(focusable, buttons[0]!, true),
- buttons[2],
- "Shift+Tab from the first control still wraps to the modal end",
- );
- assert.equal(
- mountedElementsWithAttribute(mounted.document, "data-subagent-jump-latest").length,
- 0,
- "the regression does not rely on the conditional Jump to latest control",
- );
- } finally {
- flushSync(() => root.unmount());
- await new Promise
((resolve) => setImmediate(resolve));
- mounted.restore();
- releaseMountedDomTest();
- }
-});
-
-test("mounted live announcer stays singular and active in compact and inline surfaces", async () => {
+test("mounted live announcer stays singular and active in floating and pinned surfaces", async () => {
const releaseMountedDomTest = await acquireMountedDomTest();
const mounted = installMountedDom();
const { createRoot } = await import("react-dom/client");
@@ -2564,23 +2469,18 @@ test("mounted live announcer stays singular and active in compact and inline sur
finishedAt: 3_000,
});
const renderHarness = (
- compactModal: boolean,
+ floating: boolean,
detailRequest: SubagentDetailAnnouncementRequest | null,
) => {
- if (compactModal) {
- host.setAttribute("role", "dialog");
- host.setAttribute("aria-modal", "true");
- host.removeAttribute("data-environment-inline");
+ if (floating) {
+ host.setAttribute("data-surface-mode", "tools-floating");
} else {
- host.removeAttribute("role");
- host.removeAttribute("aria-modal");
- host.setAttribute("data-environment-inline", "true");
+ host.setAttribute("data-surface-mode", "tools-pinned");
}
flushSync(() => {
root.render(
,
);
@@ -2606,17 +2506,20 @@ test("mounted live announcer stays singular and active in compact and inline sur
/0 active subagents; 1 completed successfully\./u.test(message),
);
assert.equal(regions.length, 1);
+ const originalRegion = regions[0];
let ancestor: HTMLElement | null = regions[0];
- let modalAncestor: HTMLElement | null = null;
while (ancestor) {
assert.notEqual(ancestor.getAttribute("aria-hidden"), "true");
assert.equal(ancestor.hasAttribute("inert"), false);
- if (ancestor.getAttribute("role") === "dialog") modalAncestor = ancestor;
+ assert.notEqual(ancestor.getAttribute("role"), "dialog");
+ assert.equal(ancestor.hasAttribute("aria-modal"), false);
ancestor = ancestor.parentNode instanceof HTMLElement ? ancestor.parentNode : null;
}
- assert.ok(modalAncestor, "the sole compact region is inside the active modal subtree");
+ assert.equal(host.getAttribute("data-surface-mode"), "tools-floating");
assert.match(regions[0].textContent ?? "", /0 active subagents; 1 completed successfully\./u);
+ host.setAttribute("inert", "");
+ host.setAttribute("aria-hidden", "true");
renderHarness(true, {
id: 1,
ownerKey: subagentPanelOwnerKey("chat-1", "workspace-1"),
@@ -2637,11 +2540,8 @@ test("mounted live announcer stays singular and active in compact and inline sur
(message) => message === "Loading saved activity for Code scout.",
);
assert.equal(regions.length, 1);
- assert.ok(
- regions[0].parentNode instanceof HTMLElement &&
- regions[0].parentNode.hasAttribute("data-environment-inline"),
- "the same region moves into the active inline Environment subtree",
- );
+ assert.equal(regions[0], originalRegion, "panel changes preserve the live DOM node");
+ assert.equal(regions[0].parentNode, mounted.container, "announcements stay outside the covered panel");
assert.equal(regions[0].textContent, "Loading saved activity for Code scout.");
} finally {
flushSync(() => root.unmount());
diff --git a/renderer/components/ui.tsx b/renderer/components/ui.tsx
index 85ee08f9..eb6b1029 100644
--- a/renderer/components/ui.tsx
+++ b/renderer/components/ui.tsx
@@ -554,7 +554,10 @@ function SplitViewRoot({
return (
-
+
{compactOpen ? (
{children}
diff --git a/renderer/lib/assistant-motion-contract.test.ts b/renderer/lib/assistant-motion-contract.test.ts
index ec67bd45..8f0bf9f8 100644
--- a/renderer/lib/assistant-motion-contract.test.ts
+++ b/renderer/lib/assistant-motion-contract.test.ts
@@ -140,7 +140,8 @@ test("the hotkey waits for the central command listener and uses the dock comman
const readySignal = commands.indexOf("appApi.rendererReady()");
const readinessWait = main.indexOf("await rendererReadiness.wait()");
const assistantCommand = main.indexOf('commandId: "assistant.open"');
- assert.match(dock, /useCommandHandler\("assistant\.open", openPanel, !interactionBlocked\)/u);
+ assert.match(dock, /useCommandHandler\("assistant\.open", openPanel\)/u);
+ assert.doesNotMatch(dock, /interactionBlocked/u);
assert.ok(listener >= 0 && readySignal > listener);
assert.ok(readinessWait >= 0 && assistantCommand > readinessWait);
});
diff --git a/renderer/lib/dialog-motion-contract.test.ts b/renderer/lib/dialog-motion-contract.test.ts
index 56055b3a..c3633ca9 100644
--- a/renderer/lib/dialog-motion-contract.test.ts
+++ b/renderer/lib/dialog-motion-contract.test.ts
@@ -57,7 +57,7 @@ test("every application-modal overlay stays transparent and unblurred", () => {
}
});
-test("strong elevation stays modal-only while Environment keeps the original dialog shadow", () => {
+test("strong modal elevation stays modal-only while floating tools use dialog elevation", () => {
const styles = source("../styles.css");
const sharedUi = source("../components/ui.tsx");
const commandPalette = source("../components/command-palette.tsx");
@@ -85,8 +85,8 @@ test("strong elevation stays modal-only while Environment keeps the original dia
);
assert.equal(sharedUi.match(/shadow-modal/gu)?.length, 2);
assert.equal(commandPalette.match(/shadow-modal/gu)?.length, 1);
- assert.match(environment, /environment-summary-card[\s\S]{0,300}shadow-dialog/u);
- assert.doesNotMatch(environment, /environment-summary-card[\s\S]{0,300}shadow-modal/u);
+ assert.match(environment, /quick-view-card[\s\S]{0,300}shadow-dialog/u);
+ assert.doesNotMatch(environment, /quick-view-card[\s\S]{0,300}shadow-modal/u);
assert.match(
styles,
/:root\[data-reduce-motion="false"\] \[data-slot="dialog-content"\]\[data-state="open"\]/u,
diff --git a/renderer/lib/environment-panel-layout.test.ts b/renderer/lib/environment-panel-layout.test.ts
index f4f9c15f..561dc630 100644
--- a/renderer/lib/environment-panel-layout.test.ts
+++ b/renderer/lib/environment-panel-layout.test.ts
@@ -9,6 +9,8 @@ import {
PANEL_EDGE_GUTTER,
clampEnvironmentPanelWidth,
resolveEnvironmentPanelLayout,
+ resolveEnvironmentPanelResizeBounds,
+ resolveQuickViewLayout,
} from "./environment-panel-layout.js";
const COMPACT_TABS_BREAKPOINT = 520;
@@ -29,8 +31,10 @@ test("clamps panel width into the saved range and container gutter", () => {
test("resolves the exact narrow overlay matrix", () => {
const cases = [
{ containerWidth: 320, expectedWidth: 276 },
+ { containerWidth: 390, expectedWidth: 346 },
{ containerWidth: 400, expectedWidth: 356 },
{ containerWidth: 500, expectedWidth: 456 },
+ { containerWidth: 520, expectedWidth: 476 },
];
for (const { containerWidth, expectedWidth } of cases) {
@@ -103,6 +107,10 @@ test("overlays only when even the minimum panel cannot leave a usable chat colum
width: MIN_PANEL_WIDTH,
inline: false,
});
+ assert.deepEqual(resolveEnvironmentPanelLayout(DEFAULT_PANEL_WIDTH, 1039), {
+ width: DEFAULT_PANEL_WIDTH,
+ inline: false,
+ });
assert.deepEqual(resolveEnvironmentPanelLayout(MIN_PANEL_WIDTH, INLINE_MIN_CONTAINER_WIDTH), {
width: MIN_PANEL_WIDTH,
inline: true,
@@ -115,3 +123,35 @@ test("shrinks exactly to the minimum panel at the inline threshold", () => {
inline: true,
});
});
+
+test("places Quick View beside Environment when the measured workbench fits both", () => {
+ assert.deepEqual(resolveQuickViewLayout(1200, true, 560, true), {
+ width: 380,
+ right: 572,
+ alongsideTools: true,
+ });
+ assert.deepEqual(resolveQuickViewLayout(1040, true, 480, true), {
+ width: 380,
+ right: 492,
+ alongsideTools: true,
+ });
+});
+
+test("preserves detached Quick View geometry for automatic narrow stacking", () => {
+ assert.deepEqual(resolveQuickViewLayout(700, true, 560, false), {
+ width: 380,
+ right: 12,
+ alongsideTools: false,
+ });
+ assert.deepEqual(resolveQuickViewLayout(390, true, 346, false), {
+ width: 366,
+ right: 12,
+ alongsideTools: false,
+ });
+});
+
+test("reports only achievable keyboard resize bounds", () => {
+ assert.deepEqual(resolveEnvironmentPanelResizeBounds(1040, true), { min: 480, max: 480 });
+ assert.deepEqual(resolveEnvironmentPanelResizeBounds(1200, true), { min: 480, max: 640 });
+ assert.deepEqual(resolveEnvironmentPanelResizeBounds(700, false), { min: 480, max: 656 });
+});
diff --git a/renderer/lib/environment-panel-layout.ts b/renderer/lib/environment-panel-layout.ts
index a8bf9e7e..3f333baf 100644
--- a/renderer/lib/environment-panel-layout.ts
+++ b/renderer/lib/environment-panel-layout.ts
@@ -6,13 +6,16 @@ export const MIN_PANEL_WIDTH = 480;
export const MAX_PANEL_WIDTH = 720;
/** Conversation column that must remain usable beside an inline Environment surface. */
export const MIN_CONVERSATION_WIDTH = 560;
-/** Overlay sheet keeps a thin uncovered strip so the dimmed thread stays visible. */
+/** Floating tools keep a thin uncovered strip so the thread remains visible and interactive. */
export const PANEL_EDGE_GUTTER = 44;
+export const QUICK_VIEW_WIDTH = 380;
+export const QUICK_VIEW_MIN_WIDTH = 300;
+export const SURFACE_GAP = 12;
/**
* Side-by-side needs at least this much workbench width. Below that, Review/Files
- * becomes an overlay. This is independent of SplitView's 700px sidebar chrome
- * breakpoint — overlay is fit-based, not a fixed window-width trigger.
+ * becomes a floating surface. This is independent of SplitView's 700px sidebar
+ * chrome breakpoint — floating mode is fit-based, not a fixed window-width trigger.
*/
export const INLINE_MIN_CONTAINER_WIDTH = MIN_PANEL_WIDTH + MIN_CONVERSATION_WIDTH;
@@ -28,8 +31,8 @@ export function clampEnvironmentPanelWidth(value: number, containerWidth: number
*
* Prefer shrinking the saved width down to {@link MIN_PANEL_WIDTH} so side-by-side
* remains available whenever the minimum panel still leaves a usable conversation
- * column. Only overlay when even that minimum cannot fit — closing the gap where
- * a wide preferred width would otherwise force overlay while a narrower panel
+ * column. Only float when even that minimum cannot fit — closing the gap where
+ * a wide preferred width would otherwise force floating mode while a narrower panel
* would still fit.
*/
export function resolveEnvironmentPanelLayout(
@@ -54,3 +57,46 @@ export function resolveEnvironmentPanelLayout(
inline: true,
};
}
+
+export function resolveEnvironmentPanelResizeBounds(
+ containerWidth: number,
+ inline: boolean,
+): { min: number; max: number } {
+ const availableMaximum = inline
+ ? containerWidth - MIN_CONVERSATION_WIDTH
+ : containerWidth - PANEL_EDGE_GUTTER;
+ const max = Math.max(0, Math.min(MAX_PANEL_WIDTH, availableMaximum));
+ return { min: Math.min(MIN_PANEL_WIDTH, max), max };
+}
+
+export interface QuickViewLayout {
+ width: number;
+ right: number;
+ alongsideTools: boolean;
+}
+
+/**
+ * Keep Quick View beside Environment when the measured workbench can fit both.
+ * On smaller allocations the surfaces share the right edge and the provider's
+ * foreground state decides which one is presented; neither open state is lost.
+ */
+export function resolveQuickViewLayout(
+ containerWidth: number,
+ toolsOpen: boolean,
+ toolsWidth: number,
+ toolsInline: boolean,
+): QuickViewLayout {
+ const detachedWidth = Math.max(0, Math.min(QUICK_VIEW_WIDTH, containerWidth - 24));
+ if (!toolsOpen) return { width: detachedWidth, right: 12, alongsideTools: false };
+
+ const right = toolsWidth + (toolsInline ? SURFACE_GAP : SURFACE_GAP * 2);
+ const availableWidth = containerWidth - right - 12;
+ if (availableWidth >= QUICK_VIEW_MIN_WIDTH) {
+ return {
+ width: Math.min(QUICK_VIEW_WIDTH, availableWidth),
+ right,
+ alongsideTools: true,
+ };
+ }
+ return { width: detachedWidth, right: 12, alongsideTools: false };
+}
diff --git a/renderer/lib/environment-panel-state.ts b/renderer/lib/environment-panel-state.ts
index 0445c26a..fcd25bf1 100644
--- a/renderer/lib/environment-panel-state.ts
+++ b/renderer/lib/environment-panel-state.ts
@@ -1,4 +1,23 @@
-export type EnvironmentPanelTab = "overview" | "review" | "subagents" | "files";
+export type EnvironmentPanelTab = "review" | "subagents" | "files";
+export type EnvironmentSurface = "quick-view" | "tools";
+export type EnvironmentSurfaceMode = "closed" | "tools-pinned" | "tools-floating";
+
+export interface EnvironmentSurfaceState {
+ quickViewOpen: boolean;
+ toolsOpen: boolean;
+ toolsTab: EnvironmentPanelTab;
+ frontSurface: EnvironmentSurface | null;
+}
+
+export type EnvironmentSurfaceAction =
+ | { type: "toggle-quick-view" }
+ | { type: "show-quick-view" }
+ | { type: "close-quick-view" }
+ | { type: "toggle-tools"; tab: EnvironmentPanelTab }
+ | { type: "show-tools"; tab?: EnvironmentPanelTab }
+ | { type: "close-tools" }
+ | { type: "activate"; surface: EnvironmentSurface }
+ | { type: "close-all" };
export const ENVIRONMENT_PANEL_TABS = ["review", "subagents", "files"] as const;
const DISABLED_ENVIRONMENT_PANEL_TABS = ["review", "files"] as const;
@@ -8,99 +27,20 @@ interface EnvironmentPanelStorage {
setItem(key: string, value: string): void;
}
-export interface EnvironmentFocusTarget {
- isConnected: boolean;
- focus(): void;
-}
-
-export interface EnvironmentFocusBoundary extends EnvironmentFocusTarget {
- contains(target: Node | null): boolean;
-}
-
-export interface EnvironmentSurfaceMode {
- fullOpen: boolean;
- compactModal: boolean;
-}
-
-export const ENVIRONMENT_COMPACT_MODAL_FOCUSABLE_SELECTOR = [
- "a[href]",
- "button:not(:disabled)",
- "input:not(:disabled)",
- "textarea:not(:disabled)",
- "select:not(:disabled)",
- "summary:not([tabindex='-1'])",
- "[contenteditable='true']",
- "[tabindex]:not([tabindex='-1'])",
-].join(",");
-
-/**
- * Native disclosure summaries are keyboard-focusable without an explicit
- * tabIndex. Keep them in the compact modal's boundary so pointer focus on a
- * Subagents detail disclosure cannot be mistaken for focus outside the modal.
- */
-export function environmentCompactModalFocusableTargets(
- surface: ParentNode | null,
-): HTMLElement[] {
- return Array.from(
- surface?.querySelectorAll(
- ENVIRONMENT_COMPACT_MODAL_FOCUSABLE_SELECTOR,
- ) ?? [],
- ).filter((element) => element.offsetParent !== null && !element.closest("[inert]"));
-}
-
-/**
- * Return only an explicit wrap target. A focusable control in the middle of
- * the modal, including a native summary selected with the pointer, keeps the
- * browser's normal Tab order.
- */
-export function environmentCompactModalTabWrapTarget(
- focusable: readonly HTMLElement[],
- activeElement: Element | null,
- shiftKey: boolean,
-): HTMLElement | null {
- if (focusable.length === 0) return null;
- const first = focusable[0]!;
- const last = focusable[focusable.length - 1]!;
- if (shiftKey && (activeElement === first || !focusable.includes(activeElement as HTMLElement))) {
- return last;
- }
- if (!shiftKey && (activeElement === last || !focusable.includes(activeElement as HTMLElement))) {
- return first;
- }
- return null;
+interface EnvironmentClosestTarget {
+ closest(selector: string): unknown;
}
-/**
- * When a mounted inline surface becomes modal, focus must cross the new modal
- * boundary before paint. Initial opens retain their existing focus path, and a
- * focus already inside the surface is never moved.
- */
-export function focusEnvironmentCompactModalTransition(
- previous: EnvironmentSurfaceMode,
- next: EnvironmentSurfaceMode,
- surface: EnvironmentFocusBoundary | null,
- activeElement: Node | null,
- preferredTarget: EnvironmentFocusTarget | null,
+export function shouldRestoreEnvironmentFocus(
+ activeElement: EnvironmentClosestTarget | null,
+ surface: EnvironmentSurface,
): boolean {
- if (
- !previous.fullOpen ||
- previous.compactModal ||
- !next.fullOpen ||
- !next.compactModal ||
- !surface ||
- surface.contains(activeElement)
- ) {
- return false;
- }
- const target = preferredTarget?.isConnected ? preferredTarget : surface;
- if (!target.isConnected) return false;
- target.focus();
- return true;
+ return Boolean(activeElement?.closest(`[data-environment-surface="${surface}"]`));
}
export function availableEnvironmentPanelTabs(
subagentsEnabled: boolean,
-): readonly Exclude[] {
+): readonly EnvironmentPanelTab[] {
return subagentsEnabled ? ENVIRONMENT_PANEL_TABS : DISABLED_ENVIRONMENT_PANEL_TABS;
}
@@ -108,7 +48,7 @@ export function normalizeEnvironmentPanelTab(
tab: EnvironmentPanelTab,
subagentsEnabled: boolean,
): EnvironmentPanelTab {
- return tab === "subagents" && !subagentsEnabled ? "overview" : tab;
+ return tab === "subagents" && !subagentsEnabled ? "review" : tab;
}
export function storedEnvironmentPanelTab(
@@ -118,10 +58,61 @@ export function storedEnvironmentPanelTab(
): EnvironmentPanelTab {
const stored = storage.getItem(key);
const parsed: EnvironmentPanelTab =
- stored === "review" || stored === "subagents" || stored === "files" || stored === "overview"
- ? stored
- : "overview";
- const resolved = normalizeEnvironmentPanelTab(parsed, subagentsEnabled);
- if (resolved !== parsed) storage.setItem(key, resolved);
- return resolved;
+ stored === "review" || stored === "subagents" || stored === "files" ? stored : "review";
+ // Capability bootstrap starts fail-closed and can become authoritative later.
+ // Preserve the raw destination instead of destructively repairing storage.
+ return normalizeEnvironmentPanelTab(parsed, subagentsEnabled);
+}
+
+export function reduceEnvironmentSurfaceState(
+ state: EnvironmentSurfaceState,
+ action: EnvironmentSurfaceAction,
+): EnvironmentSurfaceState {
+ switch (action.type) {
+ case "toggle-quick-view":
+ return state.quickViewOpen
+ ? {
+ ...state,
+ quickViewOpen: false,
+ frontSurface: state.toolsOpen ? "tools" : null,
+ }
+ : { ...state, quickViewOpen: true, frontSurface: "quick-view" };
+ case "show-quick-view":
+ return { ...state, quickViewOpen: true, frontSurface: "quick-view" };
+ case "close-quick-view":
+ return {
+ ...state,
+ quickViewOpen: false,
+ frontSurface: state.toolsOpen ? "tools" : null,
+ };
+ case "toggle-tools":
+ return state.toolsOpen
+ ? {
+ ...state,
+ toolsOpen: false,
+ frontSurface: state.quickViewOpen ? "quick-view" : null,
+ }
+ : { ...state, toolsOpen: true, toolsTab: action.tab, frontSurface: "tools" };
+ case "show-tools":
+ return {
+ ...state,
+ toolsOpen: true,
+ toolsTab: action.tab ?? state.toolsTab,
+ frontSurface: "tools",
+ };
+ case "close-tools":
+ return {
+ ...state,
+ toolsOpen: false,
+ frontSurface: state.quickViewOpen ? "quick-view" : null,
+ };
+ case "activate":
+ if (action.surface === "tools" && !state.toolsOpen) return state;
+ if (action.surface === "quick-view" && !state.quickViewOpen) return state;
+ return state.frontSurface === action.surface
+ ? state
+ : { ...state, frontSurface: action.surface };
+ case "close-all":
+ return { ...state, quickViewOpen: false, toolsOpen: false, frontSurface: null };
+ }
}
diff --git a/renderer/lib/slash-command-actions.test.ts b/renderer/lib/slash-command-actions.test.ts
index b49981d6..d2b476de 100644
--- a/renderer/lib/slash-command-actions.test.ts
+++ b/renderer/lib/slash-command-actions.test.ts
@@ -78,6 +78,13 @@ test("slash availability combines the dispatcher with composer-specific state",
}).reason ?? "",
/git is busy/iu,
);
+ assert.match(
+ slashCommandAvailability(command("quick-view"), {
+ ...context,
+ environmentBlockedReason: "Git is busy.",
+ }).reason ?? "",
+ /git is busy/iu,
+ );
assert.match(
slashCommandAvailability(command("terminal"), {
...context,
diff --git a/renderer/lib/slash-command-actions.ts b/renderer/lib/slash-command-actions.ts
index ae2ef9d8..05a38868 100644
--- a/renderer/lib/slash-command-actions.ts
+++ b/renderer/lib/slash-command-actions.ts
@@ -161,7 +161,9 @@ export function slashCommandAvailability(
}
if (
(command.action.kind === "environment" ||
- (command.action.kind === "command" && command.action.commandId === "environment.toggle")) &&
+ (command.action.kind === "command" &&
+ (command.action.commandId === "environment.toggle" ||
+ command.action.commandId === "quick-view.toggle"))) &&
context.environmentBlockedReason
) {
return unavailable(context.environmentBlockedReason);
diff --git a/renderer/main/chat-layout.tsx b/renderer/main/chat-layout.tsx
index 15092049..e25e894f 100644
--- a/renderer/main/chat-layout.tsx
+++ b/renderer/main/chat-layout.tsx
@@ -16,10 +16,7 @@ import {
import { queryKeys, useChats } from "../lib/queries";
import { useActiveWorkspace } from "../lib/workspace-context";
import { TerminalDrawer } from "../components/terminal-drawer";
-import {
- EnvironmentWorkbench,
- useEnvironmentPanel,
-} from "../components/environment-panel";
+import { EnvironmentWorkbench } from "../components/environment-panel";
import type { Chat, ChatMetadataUpdated, ChatMeta } from "../lib/types";
import { useAppendReconciliationRequired } from "../lib/append-reconciliation";
@@ -27,7 +24,6 @@ export function ChatLayout() {
const params = useParams({ strict: false }) as { chatId?: string };
const pathname = useRouterState({ select: (state) => state.location.pathname });
const qc = useQueryClient();
- const environmentPanel = useEnvironmentPanel();
const [titleReveal, setTitleReveal] = React.useState(null);
React.useEffect(() => {
@@ -75,14 +71,17 @@ export function ChatLayout() {
storageKey="aiden-agent"
sidebar={ }
sidebarSize={{ default: 272, min: 236, max: 340 }}
- contentModalOpen={environmentPanel.compactModalOpen}
>
- {pathname === "/profile" || pathname === "/scheduled" || (pathname.startsWith("/bots") && !params.chatId) ? null :
}
+ {pathname === "/profile" ||
+ pathname === "/scheduled" ||
+ (pathname.startsWith("/bots") && !params.chatId) ? null : (
+
+ )}
@@ -126,7 +125,15 @@ export function ChatIndex() {
toast.error(error instanceof Error ? error.message : "Aiden could not create a chat.");
});
}
- }, [appendReconciliationRequired, isLoading, activeId, chats.isLoading, chats.data, navigate, chats]);
+ }, [
+ appendReconciliationRequired,
+ isLoading,
+ activeId,
+ chats.isLoading,
+ chats.data,
+ navigate,
+ chats,
+ ]);
return appendReconciliationRequired ? (
diff --git a/renderer/main/chat-pane.tsx b/renderer/main/chat-pane.tsx
index 7692b483..0e2a9b39 100644
--- a/renderer/main/chat-pane.tsx
+++ b/renderer/main/chat-pane.tsx
@@ -60,7 +60,11 @@ import {
} from "../lib/use-model-selection";
import { useActiveWorkspace } from "../lib/workspace-context";
import { useWorkspaceTerminal } from "../components/terminal-drawer";
-import { EnvironmentPanelToggle, useEnvironmentPanel } from "../components/environment-panel";
+import {
+ EnvironmentPanelToggle,
+ QuickViewToggle,
+ useEnvironmentPanel,
+} from "../components/environment-panel";
import { EventPresence } from "../components/event-presence";
import {
OPENAI_CODEX_PROVIDER_ID,
@@ -369,8 +373,8 @@ export function ChatPane({ chatId }: { chatId: string }) {
React.useEffect(() => {
if (!chat.data || effectiveWorkspace) return;
if (terminal.open) terminal.toggle();
- environmentPanel.close();
- }, [chat.data, effectiveWorkspace, environmentPanel.close, terminal.open, terminal.toggle]);
+ environmentPanel.closeAll();
+ }, [chat.data, effectiveWorkspace, environmentPanel.closeAll, terminal.open, terminal.toggle]);
const [streamingText, setStreamingText] = React.useState
(null);
const [streamingReasoning, setStreamingReasoning] = React.useState(null);
@@ -1851,6 +1855,7 @@ export function ChatPane({ chatId }: { chatId: string }) {
folderPath={effectiveWorkspace?.folderPath}
/>
+
-
+
-
+
);
}
-function EnvironmentCommandSystemProvider({ children }: React.PropsWithChildren) {
- const { compactModalOpen } = useEnvironmentPanel();
- return (
- {children}
- );
-}
-
function RootContent() {
const navigate = useNavigate();
const queryClient = useQueryClient();
@@ -115,7 +108,18 @@ function RootContent() {
toast.info("Wait for the current Git operation to finish before changing panels.");
return;
}
- environmentPanel.toggle("overview");
+ environmentPanel.toggleTools();
+ },
+ workspaceCommands.environment,
+ );
+ useCommandHandler(
+ "quick-view.toggle",
+ () => {
+ if (environmentPanel.gitOperationBusy) {
+ toast.info("Wait for the current Git operation to finish before changing panels.");
+ return;
+ }
+ environmentPanel.toggleQuickView();
},
workspaceCommands.environment,
);
@@ -297,7 +301,7 @@ function RootContent() {
);
diff --git a/renderer/shared/keybindings.test.ts b/renderer/shared/keybindings.test.ts
index c7b58c99..b696ff23 100644
--- a/renderer/shared/keybindings.test.ts
+++ b/renderer/shared/keybindings.test.ts
@@ -14,8 +14,36 @@ import {
repairKeybindingOverrides,
shouldPersistCanonicalKeybindings,
validateEffectiveBindings,
+ COMMAND_BY_ID,
} from "./keybindings";
+test("Environment keeps its command id and shortcut while Quick View has a separate command", () => {
+ assert.deepEqual(
+ {
+ id: COMMAND_BY_ID["environment.toggle"]?.id,
+ title: COMMAND_BY_ID["environment.toggle"]?.title,
+ binding: COMMAND_BY_ID["environment.toggle"]?.defaultBinding,
+ },
+ {
+ id: "environment.toggle",
+ title: "Toggle Environment",
+ binding: "Command+Shift+E",
+ },
+ );
+ assert.deepEqual(
+ {
+ id: COMMAND_BY_ID["quick-view.toggle"]?.id,
+ title: COMMAND_BY_ID["quick-view.toggle"]?.title,
+ binding: COMMAND_BY_ID["quick-view.toggle"]?.defaultBinding,
+ },
+ {
+ id: "quick-view.toggle",
+ title: "Toggle Quick View",
+ binding: null,
+ },
+ );
+});
+
test("future keybinding documents are used defensively without being downgraded", () => {
const future = { version: 2, commands: { "chat.new": { binding: "Command+J" } } };
const canonical = migrateLegacyKeybindings(future, {
diff --git a/renderer/shared/keybindings.ts b/renderer/shared/keybindings.ts
index 6d99aea8..800d4995 100644
--- a/renderer/shared/keybindings.ts
+++ b/renderer/shared/keybindings.ts
@@ -28,6 +28,7 @@ export const COMMAND_IDS = [
"sidebar.toggle",
"terminal.toggle",
"environment.toggle",
+ "quick-view.toggle",
"file.save",
] as const;
@@ -261,16 +262,28 @@ export const COMMANDS = [
}),
command({
id: "environment.toggle",
- title: "Toggle environment panel",
- description: "Show or hide files and Git tools.",
+ title: "Toggle Environment",
+ description: "Show or hide Review, Subagents, and Files.",
category: "Tools",
- keywords: ["files", "git", "changes"],
+ keywords: ["review", "subagents", "files", "git", "changes"],
defaultBinding: "Command+Shift+E",
scope: "app",
global: false,
showInPalette: true,
showInSettings: true,
}),
+ command({
+ id: "quick-view.toggle",
+ title: "Toggle Quick View",
+ description: "Show or hide the compact workspace summary.",
+ category: "Tools",
+ keywords: ["summary", "preview", "status", "git", "changes"],
+ defaultBinding: null,
+ scope: "app",
+ global: false,
+ showInPalette: true,
+ showInSettings: true,
+ }),
command({
id: "file.save",
title: "Save file",
diff --git a/renderer/shared/slash-commands.test.ts b/renderer/shared/slash-commands.test.ts
index e89d3c64..250f1669 100644
--- a/renderer/shared/slash-commands.test.ts
+++ b/renderer/shared/slash-commands.test.ts
@@ -16,7 +16,7 @@ const source = fs.readFileSync(new URL("./slash-commands.ts", import.meta.url),
const invocationId = (character = "a") => `sk1_${character.repeat(43)}`;
test("curated slash catalog freezes unique command names, aliases, and required adapters", () => {
- assert.equal(SLASH_COMMANDS.length, 28);
+ assert.equal(SLASH_COMMANDS.length, 29);
const tokens = SLASH_COMMANDS.flatMap((command) => [command.name, ...command.aliases]);
assert.equal(new Set(tokens).size, tokens.length);
assert.deepEqual(
@@ -40,6 +40,7 @@ test("curated slash catalog freezes unique command names, aliases, and required
"assistant",
"terminal",
"environment",
+ "quick-view",
"review",
"sidebar",
"editor",
@@ -95,6 +96,35 @@ test("curated slash catalog freezes unique command names, aliases, and required
]);
assert.deepEqual(SLASH_COMMANDS.find((command) => command.name === "mcp")?.aliases, ["plugins"]);
assert.equal(SLASH_COMMANDS.find((command) => command.name === "mcp")?.action.kind, "settings");
+ assert.deepEqual(
+ SLASH_COMMANDS.find((command) => command.name === "environment"),
+ {
+ name: "environment",
+ aliases: [],
+ title: "Toggle Environment",
+ description: "Show or hide Review, Subagents, and Files.",
+ keywords: ["review", "subagents", "files", "git"],
+ icon: "environment",
+ action: { kind: "command", commandId: "environment.toggle" },
+ behavior: "immediate",
+ availability: "workspace-environment",
+ argument: "none",
+ draftPolicy: "preserve",
+ },
+ );
+ assert.deepEqual(SLASH_COMMANDS.find((command) => command.name === "quick-view"), {
+ name: "quick-view",
+ aliases: [],
+ title: "Toggle Quick View",
+ description: "Show or hide the compact workspace summary.",
+ keywords: ["summary", "preview", "status", "git"],
+ icon: "environment",
+ action: { kind: "command", commandId: "quick-view.toggle" },
+ behavior: "immediate",
+ availability: "workspace-environment",
+ argument: "none",
+ draftPolicy: "preserve",
+ });
assert.equal(SLASH_LIMITS.queryCharacters, 256);
assert.equal(SLASH_LIMITS.catalogEntries, 500);
diff --git a/renderer/shared/slash-commands.ts b/renderer/shared/slash-commands.ts
index fcb05386..c715a2ba 100644
--- a/renderer/shared/slash-commands.ts
+++ b/renderer/shared/slash-commands.ts
@@ -356,9 +356,9 @@ export const SLASH_COMMANDS = Object.freeze([
define({
name: "environment",
aliases: [],
- title: "Toggle environment",
- description: "Show or hide files and Git tools.",
- keywords: ["files", "git"],
+ title: "Toggle Environment",
+ description: "Show or hide Review, Subagents, and Files.",
+ keywords: ["review", "subagents", "files", "git"],
icon: "environment",
action: { kind: "command", commandId: "environment.toggle" },
behavior: "immediate",
@@ -366,6 +366,19 @@ export const SLASH_COMMANDS = Object.freeze([
argument: "none",
draftPolicy: "preserve",
}),
+ define({
+ name: "quick-view",
+ aliases: [],
+ title: "Toggle Quick View",
+ description: "Show or hide the compact workspace summary.",
+ keywords: ["summary", "preview", "status", "git"],
+ icon: "environment",
+ action: { kind: "command", commandId: "quick-view.toggle" },
+ behavior: "immediate",
+ availability: "workspace-environment",
+ argument: "none",
+ draftPolicy: "preserve",
+ }),
define({
name: "review",
aliases: ["code-review"],
diff --git a/renderer/styles.css b/renderer/styles.css
index 3d0d11ad..7fca3a79 100644
--- a/renderer/styles.css
+++ b/renderer/styles.css
@@ -1062,6 +1062,12 @@ textarea {
}
}
+@container chat-toolbar (max-width: 460px) {
+ .open-in-editor-picker {
+ display: none;
+ }
+}
+
.usage-profile-content {
container: usage-profile / inline-size;
}
@@ -1464,10 +1470,10 @@ textarea {
:root[data-reduce-motion="false"] .chat-title-reveal-character {
animation: aiden-chat-title-character-in 160ms cubic-bezier(0.19, 1, 0.22, 1) both;
}
-:root[data-reduce-motion="false"] .environment-summary-card[data-state="open"] {
+:root[data-reduce-motion="false"] .quick-view-card[data-state="open"] {
animation: aiden-environment-summary-in 180ms cubic-bezier(0, 0, 0.2, 1) both;
}
-:root[data-reduce-motion="false"] .environment-summary-card[data-state="closed"] {
+:root[data-reduce-motion="false"] .quick-view-card[data-state="closed"] {
animation: aiden-environment-summary-out 120ms ease-in both;
}
:root[data-reduce-motion="false"] .app-update-banner[data-state="open"] {
diff --git a/tests/e2e/chat-shell-interactions.spec.ts b/tests/e2e/chat-shell-interactions.spec.ts
index fcbc49c6..16f34f4e 100644
--- a/tests/e2e/chat-shell-interactions.spec.ts
+++ b/tests/e2e/chat-shell-interactions.spec.ts
@@ -53,26 +53,73 @@ test("chat shell keeps local interactions isolated and keyboard-accessible", asy
await expect(skills).toBeHidden();
await composer.fill("");
- const environment = page.getByRole("button", { name: "Show environment" });
- const environmentSummary = page.getByRole("complementary", {
- name: "Environment summary",
+ const announcer = page.locator('[data-subagent-live-announcer="true"]');
+ await expect(announcer).toHaveCount(1);
+ const originalAnnouncer = await announcer.elementHandle();
+ const assertAccessibleAnnouncer = async () => {
+ await expect(announcer).toHaveCount(1);
+ expect(await announcer.evaluate(el => el.closest('[inert], [aria-hidden="true"]') === null)).toBe(true);
+ expect(await announcer.evaluate((el, original) => el === original, originalAnnouncer)).toBe(true);
+ };
+ await assertAccessibleAnnouncer();
+ const environment = page.getByRole("button", { name: "Show Environment" });
+ const quickViewToggle = page.locator("[data-quick-view-toggle]");
+ const environmentSurface = page.getByRole("complementary", {
+ name: "Environment work surface",
});
+ const quickView = page.getByRole("complementary", { name: "Quick View" });
await expect(environment).toHaveAttribute("aria-pressed", "false");
+ await expect(quickViewToggle).toHaveAttribute("aria-pressed", "false");
await environment.click();
- await expect(environmentSummary).toBeVisible();
- await expect(environmentSummary.getByText("No workspace folder", { exact: true })).toBeVisible();
+ await expect(environmentSurface).toBeVisible();
+ await expect(environmentSurface.getByRole("tab", { name: "Review" })).toHaveAttribute(
+ "aria-selected",
+ "true",
+ );
+ const reviewPanel = environmentSurface.getByRole("tabpanel", { name: "Review" });
+ await expect(reviewPanel.getByText("No workspace folder", { exact: true })).toBeVisible();
+ await expect(
+ reviewPanel.getByText(
+ "Choose a local workspace to review file changes beside the conversation.",
+ { exact: true },
+ ),
+ ).toBeVisible();
+ await expect(page.getByRole("button", { name: "Hide Environment" })).toHaveAttribute(
+ "aria-pressed",
+ "true",
+ );
+
+ await environmentSurface.getByRole("button", { name: "Show Quick View" }).click();
+ await expect(quickView).toBeVisible();
+ await expect(quickView.getByText("No workspace folder", { exact: true })).toBeVisible();
await expect(
- environmentSummary.getByText(
+ quickView.getByText(
"Choose a local workspace to see its environment, changes, and branch.",
{ exact: true },
),
).toBeVisible();
- await expect(page.getByRole("button", { name: "Hide environment" })).toHaveAttribute(
+ await aiden.app.evaluate(({ BrowserWindow }) => {
+ BrowserWindow.getAllWindows()[0].setSize(900, 720);
+ });
+ await expect(page.locator('[data-environment-stacked="true"]')).toHaveCount(1);
+ await expect(page.locator('[data-environment-surface="tools"]')).toHaveAttribute('inert', '');
+ await assertAccessibleAnnouncer();
+ await expect(quickViewToggle).toHaveAttribute("aria-label", "Hide Quick View");
+ await expect(quickViewToggle).toHaveAttribute("aria-pressed", "true");
+ await page.keyboard.press("Escape");
+ await expect(quickView).toBeHidden();
+ await expect(environmentSurface).toBeVisible();
+ await expect(page.getByRole("button", { name: "Hide Environment" })).toHaveAttribute(
"aria-pressed",
"true",
);
- await page.getByRole("button", { name: "Hide environment" }).click();
- await expect(environmentSummary).toBeHidden();
+ await environmentSurface.getByRole("button", { name: "Close environment panel" }).click();
+ await expect(environmentSurface).toBeHidden();
+ await expect(environment).toHaveAttribute("aria-pressed", "false");
+ await assertAccessibleAnnouncer();
+ await aiden.app.evaluate(({ BrowserWindow }) => {
+ BrowserWindow.getAllWindows()[0].setSize(1280, 800);
+ });
const terminal = page.getByRole("button", { name: "Show terminal" });
await expect(terminal).toBeDisabled();