Merge pull request #8748 from ttmouse/pr/chat-panel-resize-min-400

feat(desktop): refine workspace dock sizing and state / 优化工作区面板宽度与状态管理
This commit is contained in:
SivanCola authored and GitHub committed 2026-08-20 16:23:06 +08:00
commit 3bc93c07a3
31 files changed
+2607 -829

No files matched your search

+1 -1
View File
@@ -17,7 +17,7 @@
"test:terminal": "tsx src/__tests__/terminal-events.test.ts && tsx src/__tests__/terminal-store.test.ts && tsx src/__tests__/terminal-output.test.ts && tsx src/__tests__/terminal-theme.test.ts && tsx src/__tests__/terminal-selection.test.ts && tsx src/__tests__/workspace-layout.test.ts",
"test:usage-stats": "tsx src/__tests__/usage-stats-format.test.ts && tsx src/__tests__/usage-stats-panel.test.tsx",
"test:task-monitor": "tsx src/components/TaskMonitorPanel.test.tsx && tsx src/__tests__/task-monitor-navigation.test.ts",
"test:workspace": "tsx src/__tests__/keyed-resource.test.ts && tsx src/__tests__/workspace-tree-memory.test.ts && tsx src/__tests__/workspace-refresh-store.test.ts && tsx src/__tests__/workspace-changes-errors.test.tsx && tsx src/__tests__/workspace-turn-verification.test.tsx && tsx src/__tests__/workspace-preview-css.test.ts && tsx src/__tests__/workspace-context-menu.test.tsx && tsx src/__tests__/workspace-panel-swr-contract.test.ts",
"test:workspace": "tsx src/__tests__/keyed-resource.test.ts && tsx src/__tests__/workspace-tree-memory.test.ts && tsx src/__tests__/workspace-refresh-store.test.ts && tsx src/__tests__/workspace-selection-isolation.test.tsx && tsx src/__tests__/workspace-changes-errors.test.tsx && tsx src/__tests__/workspace-turn-verification.test.tsx && tsx src/__tests__/workspace-preview-css.test.ts && tsx src/__tests__/workspace-context-menu.test.tsx && tsx src/__tests__/workspace-resize-interaction.test.tsx && tsx src/__tests__/workspace-panel-swr-contract.test.ts",
"test:settings-responsive": "tsx src/__tests__/settings-responsive-layout.test.ts",
"test:stream": "tsx src/__tests__/raf-batch.test.ts && tsx src/__tests__/stream-delta-batch.test.ts && tsx src/__tests__/use-controller-stream-progress.test.ts",
"test:motion": "node scripts/check-motion-ci-contract.mjs && node scripts/check-waapi-contract.mjs --self-test && tsx src/__tests__/native-motion.test.tsx && tsx src/__tests__/approval-animation.test.tsx",
@@ -121,7 +121,11 @@ const rawInitialBytes = [...initialJS, ...initialCSS, ...appShellCSS]
// its combined diagnostic wiring adds 2.2 KiB (0.094%) to the test channel.
// DingTalk startup wiring moves current-base production from 2341.0 to 2343.6
// KiB and test from 2346.2 to 2348.8 KiB; the pinned heading adds 0.5 KiB raw
// (0.021%). Preserve its scoped UI contract and 0.1 KiB build-SHA headroom.
const rawInitialBudgetKiB = process.env.REASONIX_CHANNEL === "test" ? 2_349.4 : 2_344.2;
// (0.021%). The workspace panel rework (change-row hover/revert, status badges,
// More menu, completion summary) makes the latest-base merge 2353.1 KiB in
// production and 2358.3 KiB in test: about 9.0 KiB (0.38%) over main-v2's
// channel gates. Retain that attributable UI capacity with 0.1 KiB of build-SHA
// headroom without widening the gzip or largest-chunk exceptions.
const rawInitialBudgetKiB = process.env.REASONIX_CHANNEL === "test" ? 2_358.4 : 2_353.2;
assertBudget("initial raw JavaScript and CSS", rawInitialBytes, rawInitialBudgetKiB * 1024);
assertBudget("largest initial JavaScript chunk raw", largestInitialJSRaw, 1_000 * 1024);
+2
View File
@@ -23,9 +23,11 @@ const OWNED_ELSEWHERE = new Map(Object.entries({
"terminal-theme.test.ts": "test:terminal",
"task-monitor-navigation.test.ts": "test:task-monitor",
"workspace-refresh-store.test.ts": "test:workspace",
"workspace-selection-isolation.test.tsx": "test:workspace",
"workspace-changes-errors.test.tsx": "test:workspace",
"workspace-preview-css.test.ts": "test:workspace",
"workspace-context-menu.test.tsx": "test:workspace",
"workspace-resize-interaction.test.tsx": "test:workspace",
"rich-composer-selection.test.tsx": "pretest",
"context-center-contract.test.ts": "pretest",
"provider-model-cache.test.ts": "pretest",
+110 -194
View File
@@ -5,7 +5,6 @@ import {
Activity,
Command,
Copy as RestoreIcon,
Download,
Minus,
Search,
Server,
@@ -13,14 +12,10 @@ import {
SquarePen,
PanelLeft,
PanelRight,
FileDown,
FileImage,
FileText,
FileJson,
GitBranch,
MessageSquare,
Settings as SettingsIcon,
Pencil,
RotateCw,
Trash2,
AlarmClock,
@@ -75,6 +70,7 @@ import { HeartbeatPanel } from "./custom/features/heartbeat/HeartbeatPanel";
import "./custom/features/heartbeat/heartbeat.css";
import { CopyButton } from "./components/CopyButton";
import { ExternalOpener, shouldMountExternalOpener } from "./components/ExternalOpener";
import { TopicbarMoreMenu } from "./components/TopicbarMoreMenu";
import { startTerminalEventBridge } from "./lib/terminalEvents";
import { applyTerminalThemePreference } from "./lib/terminalTheme";
import { formatTerminalOutputForComposer } from "./lib/terminalOutput";
@@ -145,11 +141,7 @@ import {
CREATION_RIGHT_DOCK_MIN_RENDER_WIDTH,
CREATION_RIGHT_DOCK_TREE_MIN_WIDTH,
CREATION_SIDEBAR_MIN_WIDTH,
RIGHT_DOCK_MAX_WIDTH,
RIGHT_DOCK_MIN_RENDER_WIDTH,
RIGHT_DOCK_PREVIEW_DEFAULT_WIDTH,
RIGHT_DOCK_PREVIEW_MIN_WIDTH,
RIGHT_DOCK_TREE_MAX_WIDTH,
RIGHT_DOCK_TREE_MIN_WIDTH,
type RightDockMode,
SIDEBAR_MAX_WIDTH,
@@ -159,7 +151,6 @@ import {
applyLayoutStyleDefaults,
clampCreationRightDockTreeWidth,
clampCreationSidebarWidth,
clampRightDockPreviewWidth,
clampRightDockTreeWidth,
clampSidebarWidth,
clampTerminalHeight,
@@ -167,7 +158,6 @@ import {
defaultCreationSidebarWidth,
defaultRightDockTreeWidth,
defaultSidebarWidth,
saveRightDockPreviewWidth,
saveRightDockTreeWidth,
saveSidebarCollapsed,
saveSidebarWidth,
@@ -175,6 +165,7 @@ import {
saveTerminalPanelOpen,
terminalMaxHeight,
saveWorkspacePanelOpen,
loadWorkspacePanelOpen,
useLayoutStore,
} from "./store/layout";
import { useOverlayStore } from "./store/overlays";
@@ -199,9 +190,9 @@ import { applyConfiguredBaseAppearance, applyThemePack, applyThemeScene, clearTh
import { ThemeBackground } from "./components/ThemeBackground";
import { applyTextSize, DEFAULT_TEXT_SIZE, getTextSize, nextTextSize } from "./lib/textSize";
import { useViewportHeightVar, useWindowStatePersistence } from "./lib/windowState";
import { resolveLiveWorkspacePanelWidth, resolveWorkspacePanelPlacement, workspacePanelAriaMinWidth } from "./lib/workspaceLayout";
import { availableWorkspacePanelWidth, resolveLiveWorkspacePanelWidth, resolveWorkspacePanelPlacement, workspacePanelAriaMinWidth } from "./lib/workspaceLayout";
import { createPointerResizeLifecycle, createRafResizeUpdater } from "./lib/resizeDrag";
import { useGlobalShortcut } from "./lib/keyboardShortcuts";
import { formatShortcutCombo, resolvedShortcutCombo, useGlobalShortcut } from "./lib/keyboardShortcuts";
import { useWarmTerminalPanel } from "./lib/useWarmTerminalPanel";
import { topicShortcutIndexFromEvent, useTopicShortcuts, type TopicShortcutEntry } from "./lib/topicShortcuts";
import { composerDraftKeyForTab } from "./lib/composerDraftKey";
@@ -300,7 +291,6 @@ const WorkspacePanel = lazy(async () => {
});
const CHAT_MIN_WIDTH = 400;
const CHAT_COMFORT_MIN_WIDTH = 560;
const WORKSPACE_RESIZER_WIDTH = 8;
function stripLegacyGoalBudgetFlags(arg: string): string {
@@ -1204,7 +1194,6 @@ export default function App() {
const rightDockTreeWidth = useLayoutStore((s) => s.rightDockTreeWidth);
const setRightDockTreeWidth = useLayoutStore((s) => s.setRightDockTreeWidth);
const rightDockPreviewWidth = useLayoutStore((s) => s.rightDockPreviewWidth);
const setRightDockPreviewWidth = useLayoutStore((s) => s.setRightDockPreviewWidth);
const workspacePreviewActive = useLayoutStore((s) => s.workspacePreviewActive);
const setWorkspacePreviewActive = useLayoutStore((s) => s.setWorkspacePreviewActive);
const attentionChimeEvents = useRef(new Set<string>());
@@ -1297,7 +1286,6 @@ export default function App() {
const sidebarSearchFocusSignal = useOverlayStore((s) => s.sidebarSearchFocusSignal);
const setSidebarSearchFocusSignal = useOverlayStore((s) => s.setSidebarSearchFocusSignal);
const [sidebarTogglePressed, setSidebarTogglePressed] = useState(false);
const [workspaceTogglePressed, setWorkspaceTogglePressed] = useState(false);
const [clearContextPending, setClearContextPending] = useState(false);
const [backgroundRuntimes, setBackgroundRuntimes] = useState<BackgroundRuntimeView[]>([]);
const [workspaceConflict, setWorkspaceConflict] = useState<WorkspaceConflictView | null>(null);
@@ -1310,7 +1298,6 @@ export default function App() {
const layoutRef = useRef<HTMLDivElement>(null);
const workspacePanelResizeFinishRef = useRef<(() => void) | null>(null);
const sidebarTogglePressTimerRef = useRef<number | null>(null);
const workspaceTogglePressTimerRef = useRef<number | null>(null);
// Persist window geometry across launches.
useWindowStatePersistence();
@@ -1410,18 +1397,6 @@ export default function App() {
}, 260);
}, []);
const pulseWorkspaceToggle = useCallback(() => {
if (typeof window === "undefined") return;
if (workspaceTogglePressTimerRef.current !== null) {
window.clearTimeout(workspaceTogglePressTimerRef.current);
}
setWorkspaceTogglePressed(true);
workspaceTogglePressTimerRef.current = window.setTimeout(() => {
workspaceTogglePressTimerRef.current = null;
setWorkspaceTogglePressed(false);
}, 260);
}, []);
const anchorAppScrollToChat = useCallback(() => {
if (typeof window === "undefined") return;
const el = appRef.current;
@@ -1439,9 +1414,6 @@ export default function App() {
if (sidebarTogglePressTimerRef.current !== null) {
window.clearTimeout(sidebarTogglePressTimerRef.current);
}
if (workspaceTogglePressTimerRef.current !== null) {
window.clearTimeout(workspaceTogglePressTimerRef.current);
}
};
}, []);
@@ -1592,14 +1564,24 @@ export default function App() {
});
}, []);
const rightDockDetailActive = rightDockMode !== "context" && workspacePreviewActive;
const preferredWorkspacePanelWidth = rightDockDetailActive ? rightDockPreviewWidth : rightDockTreeWidth;
// The dock keeps one width across tab switches (context/files/changed):
// the tree width is the single source so toggling tabs never resizes the
// sidebar. Preview detail stays inside the dock without widening it.
const preferredWorkspacePanelWidth = rightDockTreeWidth;
const rightDockTreeMinWidth = desktopLayoutStyle === "creation" ? CREATION_RIGHT_DOCK_TREE_MIN_WIDTH : RIGHT_DOCK_TREE_MIN_WIDTH;
const rightDockTreeWidthClamp = desktopLayoutStyle === "creation" ? clampCreationRightDockTreeWidth : clampRightDockTreeWidth;
const rightDockMinRenderWidth = desktopLayoutStyle === "creation" && !rightDockDetailActive
? CREATION_RIGHT_DOCK_MIN_RENDER_WIDTH
: RIGHT_DOCK_MIN_RENDER_WIDTH;
const workspacePanelMinWidth = rightDockDetailActive ? RIGHT_DOCK_PREVIEW_MIN_WIDTH : rightDockTreeMinWidth;
const chatReservedWidth = workspacePanelOpen && !workspacePanelMaximized ? CHAT_COMFORT_MIN_WIDTH : CHAT_MIN_WIDTH;
const workspacePanelMinWidth = rightDockTreeMinWidth;
const chatReservedWidth = CHAT_MIN_WIDTH;
const workspacePanelAvailableWidth = availableWorkspacePanelWidth({
viewportWidth,
sidebarCollapsed,
sidebarWidth,
chatMinWidth: chatReservedWidth,
resizerWidth: WORKSPACE_RESIZER_WIDTH,
});
const {
renderWidth: workspacePanelRenderWidth,
overlay: workspacePanelOverlay,
@@ -1667,10 +1649,12 @@ export default function App() {
activeTab?.scope ?? "",
activeTab?.workspaceRoot ?? state.meta?.cwd ?? "",
].join("\u0000");
const restoreWorkspaceDockWidths = useCallback((treeWidth: number, previewWidth: number) => {
setRightDockTreeWidth(rightDockTreeWidthClamp(treeWidth));
setRightDockPreviewWidth(clampRightDockPreviewWidth(previewWidth));
}, [rightDockTreeWidthClamp]);
const restoreWorkspaceDockWidths = useCallback((treeWidth: number, _previewWidth: number) => {
// Single-width dock: only the tree width is meaningful; clamp it to the
// dynamic available width (chat keeps its 400px floor), never a fixed
// 560 ceiling, so the user's remembered width is preserved when reopened.
setRightDockTreeWidth(rightDockTreeWidthClamp(treeWidth, workspacePanelAvailableWidth));
}, [rightDockTreeWidthClamp, workspacePanelAvailableWidth]);
const sidebarImDetailConnection = useMemo(
() => sidebarImConnections.find((connection) => connection.id === sidebarImDetailConnectionId) ?? null,
[sidebarImConnections, sidebarImDetailConnectionId],
@@ -2717,28 +2701,22 @@ export default function App() {
const setSavedWorkspacePanelWidth = useCallback(
(width: number) => {
closeTransientOverlays();
if (rightDockDetailActive) {
const next = clampRightDockPreviewWidth(width);
setRightDockPreviewWidth(next);
saveRightDockPreviewWidth(next);
return;
}
const next = rightDockTreeWidthClamp(width);
const next = rightDockTreeWidthClamp(width, workspacePanelAvailableWidth);
setRightDockTreeWidth(next);
saveRightDockTreeWidth(next);
},
[closeTransientOverlays, rightDockDetailActive, rightDockTreeWidthClamp],
[closeTransientOverlays, rightDockTreeWidthClamp, workspacePanelAvailableWidth],
);
const ensureWorkspacePanelWidth = useCallback(
(width: number) => {
closeTransientOverlays();
if (rightDockMode === "context") return;
const next = clampRightDockPreviewWidth(width);
setRightDockPreviewWidth(next);
saveRightDockPreviewWidth(next);
const next = rightDockTreeWidthClamp(width, workspacePanelAvailableWidth);
setRightDockTreeWidth(next);
saveRightDockTreeWidth(next);
},
[closeTransientOverlays, rightDockMode],
[closeTransientOverlays, rightDockTreeWidthClamp, workspacePanelAvailableWidth],
);
const startWorkspacePanelResize = useCallback(
@@ -2764,11 +2742,7 @@ export default function App() {
const onMove = (moveEvent: PointerEvent) => {
const delta = moveEvent.clientX - startX;
nextDockWidth = startDockWidth - delta;
if (rightDockDetailActive) {
nextDockWidth = clampRightDockPreviewWidth(nextDockWidth);
} else {
nextDockWidth = rightDockTreeWidthClamp(nextDockWidth);
}
nextDockWidth = rightDockTreeWidthClamp(nextDockWidth, workspacePanelAvailableWidth);
liveResize.schedule(resolveLiveWorkspacePanelRenderWidth(nextDockWidth));
};
const lifecycle = createPointerResizeLifecycle({
@@ -2789,7 +2763,7 @@ export default function App() {
document.body.style.cursor = "col-resize";
document.body.style.userSelect = "none";
},
[closeTransientOverlays, resolveLiveWorkspacePanelRenderWidth, rightDockDetailActive, rightDockTreeWidthClamp, setSavedWorkspacePanelWidth, workspacePanelOpen, workspacePanelRenderWidth],
[closeTransientOverlays, resolveLiveWorkspacePanelRenderWidth, rightDockDetailActive, rightDockTreeWidthClamp, setSavedWorkspacePanelWidth, workspacePanelAvailableWidth, workspacePanelOpen, workspacePanelRenderWidth],
);
const resizeWorkspacePanelWithKeyboard = useCallback(
@@ -2799,13 +2773,13 @@ export default function App() {
setSavedWorkspacePanelWidth(workspacePanelRenderWidth + (event.key === "ArrowLeft" ? 16 : -16));
} else if (event.key === "Home") {
event.preventDefault();
setSavedWorkspacePanelWidth(rightDockDetailActive ? RIGHT_DOCK_PREVIEW_MIN_WIDTH : rightDockTreeMinWidth);
setSavedWorkspacePanelWidth(rightDockTreeMinWidth);
} else if (event.key === "End") {
event.preventDefault();
setSavedWorkspacePanelWidth(rightDockDetailActive ? RIGHT_DOCK_MAX_WIDTH : RIGHT_DOCK_TREE_MAX_WIDTH);
setSavedWorkspacePanelWidth(workspacePanelAvailableWidth);
}
},
[rightDockDetailActive, rightDockTreeMinWidth, setSavedWorkspacePanelWidth, workspacePanelRenderWidth],
[rightDockDetailActive, rightDockTreeMinWidth, setSavedWorkspacePanelWidth, workspacePanelAvailableWidth, workspacePanelRenderWidth],
);
const terminalRenderHeight = clampTerminalHeight(terminalHeight, viewportHeight);
@@ -2876,6 +2850,8 @@ export default function App() {
[setSavedTerminalHeight, terminalPanelOpen, terminalRenderHeight, terminalResizeMaxHeight],
);
const activeWorkspaceRoot = activeTab?.workspaceRoot ?? state.meta?.cwd ?? "";
const openWorkspacePanel = useCallback(
(mode: RightDockMode = rightDockMode) => {
closeTransientOverlays();
@@ -2897,9 +2873,9 @@ export default function App() {
return;
}
setWorkspacePanelOpen(true);
saveWorkspacePanelOpen(true);
saveWorkspacePanelOpen(true, activeWorkspaceRoot);
},
[closeTransientOverlays, rightDockMode, workspacePanelMaximized, workspacePanelOpen],
[activeWorkspaceRoot, closeTransientOverlays, rightDockMode, workspacePanelMaximized, workspacePanelOpen],
);
const closeWorkspacePanel = useCallback(() => {
@@ -2910,11 +2886,18 @@ export default function App() {
setLiveWorkspacePanelRenderWidth(null);
setWorkspacePanelMaximized(false);
setWorkspacePanelOpen(false);
saveWorkspacePanelOpen(false);
}, [closeTransientOverlays, workspacePanelOpen]);
saveWorkspacePanelOpen(false, activeWorkspaceRoot);
}, [activeWorkspaceRoot, closeTransientOverlays, workspacePanelOpen]);
// Restore the right dock's open/closed state per project: switching to a
// different workspace root (or a global session) restores that scope's own
// preference instead of carrying the previous project's state over.
useEffect(() => {
setWorkspacePanelOpen(loadWorkspacePanelOpen(activeWorkspaceRoot));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeWorkspaceRoot]);
const toggleWorkspacePanel = useCallback(() => {
pulseWorkspaceToggle();
if (workspacePanelRenderable) {
closeWorkspacePanel();
return;
@@ -2925,8 +2908,10 @@ export default function App() {
openWorkspacePanel(rightDockMode === "changed" ? "changed" : "files");
return;
}
openWorkspacePanel("context");
}, [closeWorkspacePanel, desktopLayoutStyle, openWorkspacePanel, pulseWorkspaceToggle, rightDockMode, workspacePanelRenderable]);
// Reopen with the previously active tab (rightDockMode is kept in the
// store across close/open) instead of forcing "context".
openWorkspacePanel();
}, [closeWorkspacePanel, desktopLayoutStyle, openWorkspacePanel, rightDockMode, workspacePanelRenderable]);
const openRightDockMode = useCallback(
(mode: RightDockMode) => {
@@ -4221,14 +4206,36 @@ export default function App() {
const browserPreviewChrome = typeof window !== "undefined" && !window.runtime;
const browserMockScenario = browserPreviewChrome ? browserMockScenarioParam() : "";
const guidanceQueueMockItems = isGuidanceMockScenario(browserMockScenario) ? GUIDANCE_QUEUE_MOCK_ITEMS : undefined;
const workspacePanelResetWidth = rightDockDetailActive
? RIGHT_DOCK_PREVIEW_DEFAULT_WIDTH
: desktopLayoutStyle === "creation"
? defaultCreationRightDockTreeWidth()
: defaultRightDockTreeWidth();
const workspacePanelResetWidth = desktopLayoutStyle === "creation"
? defaultCreationRightDockTreeWidth()
: defaultRightDockTreeWidth();
const workspacePanelResizeMinWidth = workspacePanelAriaMinWidth(workspacePanelMinWidth, workspacePanelRenderWidth);
const workspacePanelMaxWidth = rightDockDetailActive ? RIGHT_DOCK_MAX_WIDTH : RIGHT_DOCK_TREE_MAX_WIDTH;
const workspacePanelResizeMaxWidth = workspacePanelAvailableWidth;
const sidebarCreation = desktopLayoutStyle === "creation";
// Command palette shortcut label (⌘K / Ctrl+K), platform-aware.
const commandPaletteShortcut = formatShortcutCombo(
resolvedShortcutCombo("commandPalette.open", desktopPlatform),
desktopPlatform,
);
// Dock collapse/expand toggle. Rendered in the dock's own tools row when the
// dock is open (its top-right corner), and in the topic bar when closed.
const dockToggleButton = (
<Tooltip label={workspacePanelRenderable ? t("rightDock.collapse") : t("rightDock.expand")}>
<button
className={[
"topicbar__chrome-btn",
"topicbar__chrome-btn--workspace",
workspacePanelRenderable ? "topicbar__chrome-btn--active" : "",
].filter(Boolean).join(" ")}
type="button"
onClick={toggleWorkspacePanel}
aria-label={workspacePanelRenderable ? t("rightDock.collapse") : t("rightDock.expand")}
aria-pressed={workspacePanelRenderable}
>
<PanelRight size={15} />
</button>
</Tooltip>
);
const topicbarTitle = sidebarImDetailConnection ? t("botDetail.title", { name: sidebarImDetailConnection.title }) : topicDisplayTitle(activeTab);
const topicbarWorkspaceLabel = sidebarImDetailConnection ? t("botDetail.subtitle") : activeTab ? tabWorkspaceTitle(activeTab) : "";
const topicbarWorkspacePath = activeTab?.scope === "project" ? activeTab.workspaceRoot || state.meta?.cwd : "";
@@ -4237,7 +4244,7 @@ export default function App() {
? sidebarImDetailConnection.platformLabel
: topicbarImSource ? t("msg.fromIm", { source: topicbarImSource.label }) : "";
const topicbarImSourcePlatform = sidebarImDetailConnection?.platform ?? topicbarImSource?.platform;
const topicbarSubtitleVisible = !sidebarCreation && Boolean(topicbarWorkspaceLabel || topicbarImSourceLabel);
const topicbarSubtitleVisible = !sidebarCreation && Boolean(activeTab?.isolatedWorktree || topicbarImSourceLabel);
const topicbarSubtitleTitle = sidebarImDetailConnection
? [topicbarWorkspaceLabel, topicbarImSourceLabel, sidebarImScopeLabel(sidebarImDetailConnection, t)].filter(Boolean).join(" · ")
: [topicbarWorkspacePath || topicbarWorkspaceLabel, topicbarImSourceLabel].filter(Boolean).join(" · ");
@@ -4289,6 +4296,7 @@ export default function App() {
].filter(Boolean).join(" ")}
>
<ThemeBackground />
{sidebarWorkbench && <div className="app__dock-toggle">{dockToggleButton}</div>}
<div
ref={layoutRef}
className={[
@@ -4326,7 +4334,6 @@ export default function App() {
sidebarToggleTitle={sidebarToggleTitle}
workspacePanelMaximized={workspacePanelMaximized}
workspacePanelRenderable={workspacePanelRenderable}
workspaceTogglePressed={workspaceTogglePressed}
workspacePanelLabel={workspacePanelRenderable ? t("rightDock.collapse") : t("rightDock.expand")}
onToggleSidebar={toggleSidebar}
onToggleWorkspacePanel={toggleWorkspacePanel}
@@ -4609,6 +4616,7 @@ export default function App() {
size={sidebarCreation ? topicbarTitleEditSize : undefined}
value={topicTitleDraft}
onChange={(event) => setTopicTitleDraft(event.target.value)}
onFocus={(event) => event.currentTarget.select()}
onKeyDown={(event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === "Enter") {
event.preventDefault();
@@ -4622,7 +4630,7 @@ export default function App() {
onBlur={() => void commitActiveTopicRename()}
/>
</div>
) : sidebarCreation && topicbarCanRename ? (
) : topicbarCanRename ? (
<h1 title={topicTitle(activeTab)}>
<button
className="topicbar__title-button"
@@ -4636,23 +4644,14 @@ export default function App() {
) : (
<h1 title={sidebarImDetailConnection ? topicbarTitle : topicTitle(activeTab)}>{topicbarTitle}</h1>
)}
{!sidebarCreation && (
<Tooltip label={t("topicBar.renameSession")}>
<button
className="topicbar__icon-btn"
type="button"
disabled={!topicbarCanRename || topicbarEditing}
onClick={startActiveTopicRename}
aria-label={t("topicBar.renameSession")}
>
<Pencil size={14} />
</button>
</Tooltip>
{topicbarWorkspaceLabel && (
<span className="topicbar__workspace-label" title={topicbarWorkspaceLabel}>
{topicbarWorkspaceLabel}
</span>
)}
</div>
{topicbarSubtitleVisible && (
<div className="topicbar__subtitle" title={topicbarSubtitleTitle}>
{topicbarWorkspaceLabel && <span>{topicbarWorkspaceLabel}</span>}
{activeTab?.isolatedWorktree && <WorktreeBadge size={11} />}
{topicbarImSourcePlatform && (
<span className={`topicbar__source-chip topicbar__source-chip--${topicbarImSourcePlatform}`}>
@@ -4664,116 +4663,32 @@ export default function App() {
</div>
<div className="topicbar__spacer" />
<div className="topicbar__actions">
{sidebarCreation && shouldMountExternalOpener(activeTab, Boolean(sidebarImDetailConnection)) && activeTab && (
<ExternalOpener key={activeTab.id} tabId={activeTab.id} dismissSignal={transientOverlayDismissSignal} />
)}
{!sidebarImDetailConnection && (
<>
<Tooltip label={t("topicBar.copyAll")}>
<CopyButton
getText={getSessionMarkdown}
label={t("topicBar.copyAll")}
className="topicbar__action-btn topicbar__action-btn--icon topicbar__action-btn--utility"
showInlineLabel={false}
/>
</Tooltip>
<div className={`topicbar__export${topicExportOpen ? " topicbar__export--open" : ""}`}>
<Tooltip label={t("topicBar.export")}>
<button
className="topicbar__action-btn topicbar__action-btn--icon topicbar__action-btn--utility"
type="button"
disabled={!sessionHasContent}
aria-label={t("topicBar.export")}
aria-haspopup="menu"
aria-expanded={topicExportOpen}
onClick={() => setTopicExportOpen((open) => !open)}
>
<Download size={14} />
</button>
</Tooltip>
{topicExportOpen && (
<div className="topicbar__export-menu" role="menu">
<button type="button" role="menuitem" onClick={() => void exportSession("markdown")}>
<FileText size={13} />
<span>{t("topicBar.exportMarkdown")}</span>
</button>
<button type="button" role="menuitem" onClick={() => void exportSession("json")}>
<FileJson size={13} />
<span>{t("topicBar.exportJson")}</span>
</button>
<button type="button" role="menuitem" onClick={() => void exportSession("pdf")}>
<FileDown size={13} />
<span>{t("topicBar.exportPdf")}</span>
</button>
<button type="button" role="menuitem" onClick={() => void exportSession("image")}>
<FileImage size={13} />
<span>{t("topicBar.exportImage")}</span>
</button>
</div>
)}
</div>
</>
)}
{!sidebarCreation && (
<Tooltip label={t("workspace.changedTab")}>
<button
className="topicbar__action-btn topicbar__action-btn--label"
type="button"
aria-label={t("workspace.changedTab")}
aria-pressed={workspacePanelRenderable && rightDockMode === "changed"}
onClick={() => openRightDockMode("changed")}
>
<GitBranch size={14} />
<span>{t("workspace.changedTab")}</span>
</button>
</Tooltip>
)}
{!sidebarImDetailConnection && (
<Tooltip label={t("rightDock.terminal")}>
<button
className="topicbar__action-btn topicbar__action-btn--icon topicbar__action-btn--utility"
type="button"
aria-label={t("rightDock.terminal")}
aria-pressed={terminalPanelOpen}
onPointerEnter={prefetchTerminalPanel} onFocus={prefetchTerminalPanel}
onClick={toggleTerminalPanel}
>
<TerminalSquare size={14} />
</button>
</Tooltip>
)}
{!sidebarCreation && shouldMountExternalOpener(activeTab, Boolean(sidebarImDetailConnection)) && activeTab && (
<ExternalOpener key={activeTab.id} tabId={activeTab.id} dismissSignal={transientOverlayDismissSignal} />
)}
<Tooltip label={t("summary.session")}>
<Tooltip label={`${t("shortcuts.action.commandPalette")} ${commandPaletteShortcut}`}>
<button
className={`topicbar__action-btn topicbar__action-btn--icon topicbar__action-btn--utility${tasksOpen ? " topicbar__action-btn--active" : ""}`}
className="topicbar__action-btn topicbar__action-btn--icon topicbar__action-btn--utility"
type="button"
aria-label={t("summary.session")}
aria-expanded={Boolean(tasksOpen)}
onClick={() => setTasksOpen((open) => open ? false : "session")}
aria-label={t("shortcuts.action.commandPalette")}
onClick={() => void openPalette()}
>
<Activity size={14} />
<Search size={15} />
</button>
</Tooltip>
{(sidebarCreation || workbenchChromeHidden) && (
<Tooltip label={workspacePanelRenderable ? t("rightDock.collapse") : t("rightDock.expand")}>
<button
className={[
"topicbar__chrome-btn",
"topicbar__chrome-btn--workspace",
workspacePanelRenderable ? "topicbar__chrome-btn--active" : "",
workspaceTogglePressed ? "topicbar__chrome-btn--pressed" : "",
].filter(Boolean).join(" ")}
type="button"
onClick={toggleWorkspacePanel}
aria-label={workspacePanelRenderable ? t("rightDock.collapse") : t("rightDock.expand")}
aria-pressed={workspacePanelRenderable}
>
<PanelRight size={15} />
</button>
</Tooltip>
{shouldMountExternalOpener(activeTab, Boolean(sidebarImDetailConnection)) && activeTab && (
<ExternalOpener key={activeTab.id} tabId={activeTab.id} dismissSignal={transientOverlayDismissSignal} />
)}
{!sidebarImDetailConnection && (
<TopicbarMoreMenu
sessionHasContent={sessionHasContent}
getSessionMarkdown={getSessionMarkdown}
exportSession={(format) => void exportSession(format)}
openChangedDock={() => openRightDockMode("changed")}
toggleTerminal={toggleTerminalPanel}
prefetchTerminal={prefetchTerminalPanel}
openSessionSummary={() => setTasksOpen((open) => open ? false : "session")}
tasksOpen={Boolean(tasksOpen)}
/>
)}
{sidebarCreation && dockToggleButton}
{tasksOpen && (
<div className="taskmonitor-popover" role="dialog" aria-label={t("summary.session")}>
<Suspense fallback={null}>
@@ -5162,7 +5077,7 @@ export default function App() {
aria-orientation="vertical"
aria-label={t("rightDock.resize")}
aria-valuemin={workspacePanelResizeMinWidth}
aria-valuemax={Math.max(workspacePanelMaxWidth, workspacePanelRenderWidth)}
aria-valuemax={Math.max(workspacePanelResizeMaxWidth, workspacePanelRenderWidth)}
aria-valuenow={workspacePanelRenderWidth}
onPointerDown={startWorkspacePanelResize}
onKeyDown={resizeWorkspacePanelWithKeyboard}
@@ -5254,6 +5169,7 @@ export default function App() {
) : (
<Suspense fallback={null}>
<WorkspacePanel
key={workspaceTreeMemoryKey}
open={workspacePanelRenderable}
tabId={activeTabId}
cwd={state.meta?.cwd}
@@ -279,10 +279,10 @@ ok(
ok(
/const WORKSPACE_PANEL_DEFAULT_OPEN = true;/.test(layoutStoreSource) &&
/workspacePanelOpen:\s*loadWorkspacePanelOpen\(\)/.test(layoutStoreSource) &&
/export function saveWorkspacePanelOpen\(open: boolean\)/.test(layoutStoreSource) &&
/workspacePanelOpen:\s*loadWorkspacePanelOpen\(""\)/.test(layoutStoreSource) &&
/export function saveWorkspacePanelOpen\(open: boolean, workspaceRoot = ""\)/.test(layoutStoreSource) &&
/reasonix\.workspacePanel\.open/.test(layoutStoreSource),
"right dock open state is restored from localStorage with expanded first-launch default",
"right dock open state is restored from per-project localStorage with expanded first-launch default",
);
ok(
@@ -502,7 +502,7 @@ ok(
ok(
/const transcriptHydrating = state\.hydrating && !state\.hydrateHistoryLoaded;/.test(appSource) &&
/hydrating=\{transcriptHydrating\}/.test(appSource),
/hydrating=\{runtimeTransitioning \|\| transcriptHydrating\}/.test(appSource),
"Welcome is suppressed only until transcript history has loaded",
);
@@ -689,8 +689,8 @@ ok(
);
ok(
finalDeclaration(":root[data-theme-style] .workbench-dock__tab--active::after", "bottom") === "1px",
"active dock underline stays inside the visible dock edge",
finalDeclaration(":root[data-theme-style] .workbench-dock__tab--active::after", "display") === "none",
"active dock tab underline is removed in favor of the rounded-rect selected state",
);
for (const selector of [
@@ -7,13 +7,17 @@ import { fileURLToPath } from "node:url";
const testDir = dirname(fileURLToPath(import.meta.url));
const appSource = readFileSync(resolve(testDir, "../App.tsx"), "utf8");
const moreMenuSource = [
readFileSync(resolve(testDir, "../components/TopicbarMoreMenu.tsx"), "utf8"),
readFileSync(resolve(testDir, "../components/TopicbarMoreMenuContent.tsx"), "utf8"),
].join("\n");
assert.doesNotMatch(appSource, /t\("shortcuts\.cheatsheetTitle"\)|t\("topicBar\.command"\)/);
const taskSummaryControlIndex = appSource.indexOf('<Tooltip label={t("summary.session")}>');
const taskSummaryControlIndex = moreMenuSource.indexOf('t("summary.session")');
const workspaceToggleIndex = appSource.indexOf('<Tooltip label={workspacePanelRenderable ? t("rightDock.collapse") : t("rightDock.expand")}>');
assert.ok(taskSummaryControlIndex >= 0, "topic bar renders the localized Session summary control");
assert.ok(workspaceToggleIndex > taskSummaryControlIndex, "Session summary precedes the right-edge workspace toggle");
assert.ok(!appSource.includes('aria-label="Session summary"'), "Session summary does not use a hard-coded English label");
assert.ok(workspaceToggleIndex >= 0, "topic bar keeps the right-edge workspace toggle");
assert.ok(!moreMenuSource.includes('aria-label="Session summary"'), "Session summary does not use a hard-coded English label");
process.stdout.write("topicbar controls: 2 contracts passed\n");
@@ -0,0 +1,170 @@
// Run: tsx src/__tests__/topicbar-more-menu.test.tsx
import { JSDOM } from "jsdom";
import { registerHooks } from "node:module";
import React from "react";
import { act } from "react";
import { createRoot } from "react-dom/client";
import { TopicbarMoreMenu } from "../components/TopicbarMoreMenu";
import { LocaleProvider } from "../lib/i18n";
registerHooks({
resolve(specifier, context, nextResolve) {
if (specifier.endsWith(".css")) {
return nextResolve("./asset-stub-for-tests.ts", { ...context, parentURL: import.meta.url });
}
return nextResolve(specifier, context);
},
});
let passed = 0;
let failed = 0;
function ok(value: boolean, label: string) {
if (value) {
process.stdout.write(` PASS ${label}\n`);
passed += 1;
} else {
process.stdout.write(` FAIL ${label}\n`);
failed += 1;
}
}
function flushTimers(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 0));
}
async function waitFor(label: string, predicate: () => boolean) {
for (let attempt = 0; attempt < 30; attempt += 1) {
await act(async () => {
await flushTimers();
});
if (predicate()) return;
}
throw new Error(`timed out waiting for ${label}`);
}
const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
pretendToBeVisual: true,
url: "http://localhost/",
});
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
globalThis.window = dom.window as unknown as Window & typeof globalThis;
globalThis.document = dom.window.document;
Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
globalThis.Node = dom.window.Node;
globalThis.HTMLElement = dom.window.HTMLElement;
globalThis.Event = dom.window.Event;
globalThis.KeyboardEvent = dom.window.KeyboardEvent;
globalThis.MouseEvent = dom.window.MouseEvent;
globalThis.PointerEvent = dom.window.MouseEvent as unknown as typeof PointerEvent;
globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
function press(target: Element, key: string) {
target.dispatchEvent(new dom.window.KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }));
}
function directMenuItems(menu: HTMLElement): HTMLButtonElement[] {
return Array.from(menu.querySelectorAll<HTMLButtonElement>('[role="menuitem"]'))
.filter((item) => item.closest('[role="menu"]') === menu);
}
console.log("\ntopicbar more menu keyboard navigation");
const rootElement = document.getElementById("root");
if (!rootElement) throw new Error("missing root");
const root = createRoot(rootElement);
await act(async () => {
root.render(
<LocaleProvider>
<TopicbarMoreMenu
sessionHasContent
getSessionMarkdown={() => "# Session"}
exportSession={() => {}}
openChangedDock={() => {}}
toggleTerminal={() => {}}
openSessionSummary={() => {}}
tasksOpen={false}
/>
</LocaleProvider>,
);
await flushTimers();
});
const trigger = rootElement.querySelector<HTMLButtonElement>('button[aria-haspopup="menu"]');
if (!trigger) throw new Error("missing more-menu trigger");
await act(async () => {
trigger.click();
await flushTimers();
});
await waitFor("lazy menu content", () => rootElement.querySelector('[role="menu"]') !== null);
let menu = rootElement.querySelector<HTMLElement>('.topicbar__more-menu[role="menu"]');
if (!menu) throw new Error("missing more menu");
let items = directMenuItems(menu);
ok(document.activeElement === items[0], "opening the menu moves focus to the first action");
await act(async () => {
press(items[0]!, "ArrowDown");
});
ok(document.activeElement === items[1], "ArrowDown advances focus to the next action");
await act(async () => {
press(items[1]!, "End");
});
ok(document.activeElement === items[items.length - 1], "End moves focus to the final action");
await act(async () => {
press(items[items.length - 1]!, "Home");
});
ok(document.activeElement === items[0], "Home returns focus to the first action");
await act(async () => {
items[1]?.focus();
press(items[1]!, "ArrowRight");
await flushTimers();
});
const exportMenu = rootElement.querySelector<HTMLElement>('.topicbar__more-export-menu[role="menu"]');
if (!exportMenu) throw new Error("missing export submenu");
const exportItems = directMenuItems(exportMenu);
ok(document.activeElement === exportItems[0], "ArrowRight opens the export submenu and focuses its first action");
await act(async () => {
press(exportItems[0]!, "ArrowDown");
});
ok(document.activeElement === exportItems[1], "submenu ArrowDown stays within the submenu");
await act(async () => {
press(exportItems[1]!, "ArrowLeft");
await flushTimers();
});
ok(rootElement.querySelector(".topicbar__more-export-menu") === null, "ArrowLeft closes the export submenu");
ok(document.activeElement === items[1], "closing the export submenu restores focus to its trigger");
await act(async () => {
press(items[1]!, "Escape");
await flushTimers();
});
ok(rootElement.querySelector(".topicbar__more-menu") === null, "Escape closes the main menu");
ok(document.activeElement === trigger, "closing the main menu restores focus to the More trigger");
await act(async () => {
press(trigger, "ArrowUp");
await flushTimers();
});
await waitFor("keyboard-opened menu", () => rootElement.querySelector(".topicbar__more-menu") !== null);
menu = rootElement.querySelector<HTMLElement>('.topicbar__more-menu[role="menu"]');
if (!menu) throw new Error("missing keyboard-opened menu");
items = directMenuItems(menu);
ok(document.activeElement === items[items.length - 1], "ArrowUp on the trigger opens the menu at its final action");
await act(async () => {
root.unmount();
await flushTimers();
});
console.log(`\ntopicbar more menu: ${passed} passed, ${failed} failed`);
if (failed > 0) process.exit(1);
@@ -1,28 +1,9 @@
// Run: tsx src/__tests__/workspace-changes-errors.test.tsx
import { JSDOM } from "jsdom";
import { registerHooks } from "node:module";
import React from "react";
import { act } from "react";
import { createRoot } from "react-dom/client";
import { workspaceFileIcon } from "../components/WorkspaceFileIcon";
import { WorkspacePanel } from "../components/WorkspacePanel";
import { LocaleProvider } from "../lib/i18n";
import { resetWorkspaceTreeMemoryForTests } from "../lib/workspaceTreeMemory";
import type { AppBindings } from "../lib/bridge";
import type { DirEntry, FilePreview, GitCommitView, WireCompletionSummary, WorkspaceChangeDetailView, WorkspaceChangesView } from "../lib/types";
// Markdown previews lazy-load MarkdownRenderer, whose KaTeX stylesheet belongs
// to the same production chunk. Node's tsx loader has no CSS module support,
// so map stylesheet imports to the existing empty asset stub in this DOM test.
registerHooks({
resolve(specifier, context, nextResolve) {
if (specifier.endsWith(".css")) {
return nextResolve("./asset-stub-for-tests.ts", { ...context, parentURL: import.meta.url });
}
return nextResolve(specifier, context);
},
});
import type { DirEntry, FilePreview, WorkspaceChangeDetailView, WorkspaceChangesView } from "../lib/types";
import { flushPromises, renderFilesWorkspace, renderWorkspace, waitFor } from "./workspace-panel-test-harness";
let passed = 0;
let failed = 0;
@@ -37,169 +18,6 @@ function ok(value: boolean, label: string) {
}
}
function flushPromises(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 0));
}
async function waitFor(label: string, predicate: () => boolean) {
for (let attempt = 0; attempt < 20; attempt += 1) {
await act(async () => {
await flushPromises();
});
if (predicate()) return;
}
throw new Error(`timed out waiting for ${label}`);
}
class TestResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
}
function installDom() {
const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
pretendToBeVisual: true,
url: "http://localhost/",
});
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
globalThis.window = dom.window as unknown as Window & typeof globalThis;
globalThis.document = dom.window.document;
Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
globalThis.Node = dom.window.Node;
globalThis.Element = dom.window.Element;
globalThis.HTMLElement = dom.window.HTMLElement;
globalThis.Event = dom.window.Event;
globalThis.CustomEvent = dom.window.CustomEvent;
globalThis.KeyboardEvent = dom.window.KeyboardEvent;
globalThis.MouseEvent = dom.window.MouseEvent;
globalThis.PointerEvent = dom.window.MouseEvent as unknown as typeof PointerEvent;
globalThis.MutationObserver = dom.window.MutationObserver;
globalThis.ResizeObserver = TestResizeObserver;
dom.window.ResizeObserver = TestResizeObserver;
globalThis.localStorage = dom.window.localStorage;
globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
(dom.window.HTMLElement.prototype as unknown as { attachEvent: () => void }).attachEvent = () => {};
(dom.window.HTMLElement.prototype as unknown as { detachEvent: () => void }).detachEvent = () => {};
Object.defineProperty(dom.window.HTMLElement.prototype, "scrollIntoView", { configurable: true, value: () => {} });
Object.defineProperty(dom.window.HTMLElement.prototype, "offsetWidth", {
configurable: true,
get: () => 320,
});
Object.defineProperty(dom.window.HTMLElement.prototype, "offsetHeight", {
configurable: true,
get: function offsetHeight(this: HTMLElement) {
return this.classList.contains("workspace-tree") ? 300 : this.dataset.index ? 24 : 0;
},
});
Object.defineProperty(dom.window.HTMLElement.prototype, "getBoundingClientRect", {
configurable: true,
value: function getBoundingClientRect(this: HTMLElement) {
const width = 320;
const height = this.classList.contains("workspace-tree") ? 300 : this.dataset.index ? 24 : 0;
return {
x: 0,
y: 0,
top: 0,
left: 0,
right: width,
bottom: height,
width,
height,
toJSON: () => ({}),
} as DOMRect;
},
});
return dom;
}
async function renderWorkspace(
changes: WorkspaceChangesView,
options: { creationMode?: boolean; history?: GitCommitView[]; detail?: WorkspaceChangeDetailView; completionSummary?: WireCompletionSummary } = {},
) {
resetWorkspaceTreeMemoryForTests();
const dom = installDom();
window.go = {
main: {
App: {
ListDirForTab: async () => [],
WorkspaceGitHistory: async () => options.history ?? [],
WorkspaceChanges: async () => changes,
WorkspaceChangeDetail: async () => options.detail ?? {},
ReadFileForTab: async (_tabID, path) => ({ path, body: "", size: 0, truncated: false, binary: false }),
} as Partial<AppBindings> as AppBindings,
},
};
const rootEl = document.getElementById("root");
if (!rootEl) throw new Error("missing root");
const root = createRoot(rootEl);
await act(async () => {
root.render(
<LocaleProvider>
<WorkspacePanel
open
tabId="tab-a"
cwd="/repo"
maximized={false}
initialViewMode="changed"
creationMode={options.creationMode}
completionSummary={options.completionSummary}
onClose={() => {}}
onToggleMaximized={() => {}}
/>
</LocaleProvider>,
);
await flushPromises();
});
await waitFor("workspace changes", () => Boolean(document.querySelector(".workspace-preview__body")));
return { dom, root };
}
async function renderFilesWorkspace(methods: Partial<AppBindings>, props: Partial<Parameters<typeof WorkspacePanel>[0]> = {}) {
resetWorkspaceTreeMemoryForTests();
const dom = installDom();
window.go = {
main: {
App: {
ListDirForTab: async () => [],
SearchFileRefsForTab: async () => [],
WorkspaceGitHistory: async () => [],
WorkspaceChanges: async () => ({ files: [], gitAvailable: true }),
WorkspaceChangeDetail: async () => ({}),
ReadFileForTab: async (_tabID, path) => ({ path, body: "", size: 0, truncated: false, binary: false }),
...methods,
} as Partial<AppBindings> as AppBindings,
},
};
const rootEl = document.getElementById("root");
if (!rootEl) throw new Error("missing root");
const root = createRoot(rootEl);
let currentProps: Parameters<typeof WorkspacePanel>[0] = {
open: true,
tabId: "tab-a",
cwd: "/repo",
maximized: false,
initialViewMode: "files",
onClose: () => {},
onToggleMaximized: () => {},
...props,
};
const rerender = async (nextProps: Partial<Parameters<typeof WorkspacePanel>[0]> = {}) => {
currentProps = { ...currentProps, ...nextProps };
await act(async () => {
root.render(
<LocaleProvider>
<WorkspacePanel {...currentProps} />
</LocaleProvider>,
);
await flushPromises();
});
};
await rerender();
return { dom, root, rerender };
}
console.log("\nworkspace changes git errors");
{
@@ -19,6 +19,10 @@ let failed = 0;
const testDir = dirname(fileURLToPath(import.meta.url));
const appSource = readFileSync(resolve(testDir, "../App.tsx"), "utf8");
const stylesSource = readFileSync(resolve(testDir, "../styles.css"), "utf8");
const moreMenuSource = [
readFileSync(resolve(testDir, "../components/TopicbarMoreMenu.tsx"), "utf8"),
readFileSync(resolve(testDir, "../components/TopicbarMoreMenuContent.tsx"), "utf8"),
].join("\n");
const terminalPanelSource = readFileSync(resolve(testDir, "../components/TerminalPanel.tsx"), "utf8");
const terminalViewSource = readFileSync(resolve(testDir, "../components/TerminalView.tsx"), "utf8");
const terminalRailSource = readFileSync(resolve(testDir, "../components/TerminalSessionRail.tsx"), "utf8");
@@ -160,7 +164,7 @@ eq(terminalMaxHeight(180), 120, "terminal maximum never falls below the accessib
eq(clampTerminalHeight(680, 480), 240, "restored terminal height clamps after the window shrinks");
eq(clampTerminalHeight(80, 720), 120, "terminal height clamps to its minimum");
eq(
/const closeWorkspacePanel = useCallback\(\(\) => \{[\s\S]*?setLiveWorkspacePanelRenderWidth\(null\);[\s\S]*?setWorkspacePanelOpen\(false\);[\s\S]*?saveWorkspacePanelOpen\(false\);/.test(appSource),
/const closeWorkspacePanel = useCallback\(\(\) => \{[\s\S]*?setLiveWorkspacePanelRenderWidth\(null\);[\s\S]*?setWorkspacePanelOpen\(false\);[\s\S]*?saveWorkspacePanelOpen\(false, activeWorkspaceRoot\);/.test(appSource),
true,
"closing the dock clears the transient render width, hides the panel, and persists the collapsed preference",
);
@@ -177,7 +181,7 @@ eq(
"workspace resize has one guarded finish path for capture loss, blur, cancellation, and unmount",
);
eq(
/setWorkspacePanelOpen\(true\);[\s\S]*?saveWorkspacePanelOpen\(true\);/.test(appSource),
/setWorkspacePanelOpen\(true\);[\s\S]*?saveWorkspacePanelOpen\(true, activeWorkspaceRoot\);/.test(appSource),
true,
"opening the dock persists the expanded preference for the next launch",
);
@@ -253,7 +257,8 @@ const workspaceDockTabsSource = appSource.match(/<div className="workbench-dock_
eq(
workspaceDockTabsSource.length > 0
&& !/rightDock\.terminal|terminalPanelOpen|toggleTerminalPanel/.test(workspaceDockTabsSource)
&& /className="topicbar__action-btn topicbar__action-btn--icon topicbar__action-btn--utility"[\s\S]*?aria-label=\{t\("rightDock\.terminal"\)\}[\s\S]*?onClick=\{toggleTerminalPanel\}/.test(appSource),
&& /<TopicbarMoreMenu[\s\S]*?toggleTerminal=\{toggleTerminalPanel\}/.test(appSource)
&& /className="topicbar__menu-item"[\s\S]*?closeAndRun\(toggleTerminal\)[\s\S]*?t\("rightDock\.terminal"\)/.test(moreMenuSource),
true,
"workspace dock omits the terminal view while the topic bar keeps the terminal drawer action",
);
@@ -324,11 +329,11 @@ eq(
"terminal and xterm remain in a lazy chunk",
);
eq(
/onPointerEnter=\{prefetchTerminalPanel\}/.test(appSource)
&& /onFocus=\{prefetchTerminalPanel\}/.test(appSource)
/onPointerEnter=\{prefetchTerminal\}/.test(moreMenuSource)
&& /onFocus=\{prefetchTerminal\}/.test(moreMenuSource)
&& /void import\("\.\.\/components\/TerminalPanel"\)/.test(terminalLifecycleSource),
true,
"pointer and keyboard intent prefetch the terminal chunk before opening",
"pointer and keyboard intent prefetch the terminal chunk before opening (More menu)",
);
eq(
/useWarmTerminalPanel\(terminalPanelOpen, terminalResizing\)/.test(appSource)
@@ -370,5 +375,45 @@ eq(
"closing a warm terminal removes portaled selection controls",
);
// C1: the chat pane keeps its 400px floor no matter how wide the dock is
// dragged — the dock's available width is viewport minus sidebar minus the
// 400px chat minimum minus the resizer, so chat can never be squeezed below it.
const chatFloorDock = availableWorkspacePanelWidth({
viewportWidth: 1000,
sidebarCollapsed: false,
sidebarWidth: SIDEBAR_WIDTH,
chatMinWidth: CHAT_MIN_WIDTH,
resizerWidth: RESIZER_WIDTH,
});
eq(
chatFloorDock + SIDEBAR_WIDTH + CHAT_MIN_WIDTH + RESIZER_WIDTH <= 1000,
true,
"C1: dock width never consumes the chat 400px floor (chat stays readable)",
);
// Sanity: with a wide viewport the dock gets more room, but the chat floor is
// still reserved — chat is never the thing that shrinks.
const wideDock = availableWorkspacePanelWidth({
viewportWidth: 1600,
sidebarCollapsed: false,
sidebarWidth: SIDEBAR_WIDTH,
chatMinWidth: CHAT_MIN_WIDTH,
resizerWidth: RESIZER_WIDTH,
});
eq(wideDock > chatFloorDock, true, "C1: wider viewport gives the dock more room, chat floor untouched");
// C3: switching dock tabs (context/files/changed) must never resize the dock —
// the preferred width is a single source (rightDockTreeWidth), not a
// detail-dependent ternary that would jump the sidebar per tab.
eq(
/const preferredWorkspacePanelWidth = rightDockTreeWidth;/.test(appSource),
true,
"C3: preferredWorkspacePanelWidth is the single tree width (no detail ternary)",
);
eq(
/const preferredWorkspacePanelWidth = rightDockDetailActive \? rightDockPreviewWidth : rightDockTreeWidth;/.test(appSource),
false,
"C3: no preview-width dual system that would resize the sidebar on tab switch",
);
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
if (failed > 0) process.exit(1);
@@ -0,0 +1,65 @@
// Run: tsx src/__tests__/workspace-panel-open-migration.test.ts
// zk-ge C14 兼容性:项目级 dock 开合 key 缺失时回退旧全局 key,升级不翻转用户选择
import { JSDOM } from "jsdom";
let passed = 0;
let failed = 0;
function ok(value: boolean, label: string) {
if (value) {
process.stdout.write(` PASS ${label}\n`);
passed += 1;
} else {
process.stdout.write(` FAIL ${label}\n`);
failed += 1;
}
}
function installLocalStorage(seed: Record<string, string>) {
const dom = new JSDOM("<!doctype html><html><body></body></html>", { url: "http://localhost/" });
globalThis.window = dom.window;
globalThis.localStorage = dom.window.localStorage;
for (const [key, value] of Object.entries(seed)) {
dom.window.localStorage.setItem(key, value);
}
return dom;
}
// 动态 import(依赖 window/localStorage 已就绪)
async function loadModule() {
return await import("../store/layout");
}
console.log("\nworkspace dock-open legacy key migration (CLAIM.PERSIST.014)");
{
// 场景 1:项目 key 缺失 + 旧全局 key = 关闭("0") → 首次打开项目应保持关闭
installLocalStorage({ "reasonix.workspacePanel.open": "0" });
const { loadWorkspacePanelOpen } = await loadModule();
ok(loadWorkspacePanelOpen("project-a") === false, "project key missing + legacy global closed => stays closed");
}
{
// 场景 2:项目 key 缺失 + 旧全局 key = 打开("1") → 首次打开项目应打开
installLocalStorage({ "reasonix.workspacePanel.open": "1" });
const { loadWorkspacePanelOpen } = await loadModule();
ok(loadWorkspacePanelOpen("project-b") === true, "project key missing + legacy global open => stays open");
}
{
// 场景 3:项目 key 存在(优先于旧全局 key)
installLocalStorage({ "reasonix.workspacePanel.open": "0", "reasonix.workspacePanel.open.project-c": "1" });
const { loadWorkspacePanelOpen } = await loadModule();
ok(loadWorkspacePanelOpen("project-c") === true, "project key present wins over legacy global");
}
{
// 场景 4:项目 key 和旧全局 key 都缺失 → 默认打开
installLocalStorage({});
const { loadWorkspacePanelOpen } = await loadModule();
ok(loadWorkspacePanelOpen("project-d") === true, "neither key present => default open");
}
console.log(`\nworkspace dock-open migration: ${passed} passed, ${failed} failed`);
if (failed > 0) process.exit(1);
@@ -0,0 +1,181 @@
import { JSDOM } from "jsdom";
import { registerHooks } from "node:module";
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { WorkspacePanel } from "../components/WorkspacePanel";
import type { AppBindings } from "../lib/bridge";
import { LocaleProvider } from "../lib/i18n";
import type { GitCommitView, WireCompletionSummary, WorkspaceChangeDetailView, WorkspaceChangesView } from "../lib/types";
import { resetWorkspaceTreeMemoryForTests } from "../lib/workspaceTreeMemory";
registerHooks({
resolve(specifier, context, nextResolve) {
if (specifier.endsWith(".css")) {
return nextResolve("./asset-stub-for-tests.ts", { ...context, parentURL: import.meta.url });
}
return nextResolve(specifier, context);
},
});
class TestResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
}
export function flushPromises(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 0));
}
export async function waitFor(label: string, predicate: () => boolean) {
for (let attempt = 0; attempt < 20; attempt += 1) {
await act(async () => {
await flushPromises();
});
if (predicate()) return;
}
throw new Error(`timed out waiting for ${label}`);
}
function installDom() {
const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
pretendToBeVisual: true,
url: "http://localhost/",
});
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
globalThis.window = dom.window as unknown as Window & typeof globalThis;
globalThis.document = dom.window.document;
Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
globalThis.Node = dom.window.Node;
globalThis.Element = dom.window.Element;
globalThis.HTMLElement = dom.window.HTMLElement;
globalThis.Event = dom.window.Event;
globalThis.CustomEvent = dom.window.CustomEvent;
globalThis.KeyboardEvent = dom.window.KeyboardEvent;
globalThis.MouseEvent = dom.window.MouseEvent;
globalThis.PointerEvent = dom.window.MouseEvent as unknown as typeof PointerEvent;
globalThis.MutationObserver = dom.window.MutationObserver;
globalThis.ResizeObserver = TestResizeObserver;
dom.window.ResizeObserver = TestResizeObserver;
globalThis.localStorage = dom.window.localStorage;
globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
(dom.window.HTMLElement.prototype as unknown as { attachEvent: () => void }).attachEvent = () => {};
(dom.window.HTMLElement.prototype as unknown as { detachEvent: () => void }).detachEvent = () => {};
Object.defineProperty(dom.window.HTMLElement.prototype, "scrollIntoView", { configurable: true, value: () => {} });
Object.defineProperty(dom.window.HTMLElement.prototype, "offsetWidth", {
configurable: true,
get: () => 320,
});
Object.defineProperty(dom.window.HTMLElement.prototype, "offsetHeight", {
configurable: true,
get: function offsetHeight(this: HTMLElement) {
return this.classList.contains("workspace-tree") ? 300 : this.dataset.index ? 24 : 0;
},
});
Object.defineProperty(dom.window.HTMLElement.prototype, "getBoundingClientRect", {
configurable: true,
value: function getBoundingClientRect(this: HTMLElement) {
const width = 320;
const height = this.classList.contains("workspace-tree") ? 300 : this.dataset.index ? 24 : 0;
return {
x: 0,
y: 0,
top: 0,
left: 0,
right: width,
bottom: height,
width,
height,
toJSON: () => ({}),
} as DOMRect;
},
});
return dom;
}
export async function renderWorkspace(
changes: WorkspaceChangesView,
options: { creationMode?: boolean; history?: GitCommitView[]; detail?: WorkspaceChangeDetailView; completionSummary?: WireCompletionSummary } = {},
) {
resetWorkspaceTreeMemoryForTests();
const dom = installDom();
window.go = {
main: {
App: {
ListDirForTab: async () => [],
WorkspaceGitHistory: async () => options.history ?? [],
WorkspaceChanges: async () => changes,
WorkspaceChangeDetail: async () => options.detail ?? {},
ReadFileForTab: async (_tabID, path) => ({ path, body: "", size: 0, truncated: false, binary: false }),
} as Partial<AppBindings> as AppBindings,
},
};
const rootEl = document.getElementById("root");
if (!rootEl) throw new Error("missing root");
const root = createRoot(rootEl);
await act(async () => {
root.render(
<LocaleProvider>
<WorkspacePanel
open
tabId="tab-a"
cwd="/repo"
maximized={false}
initialViewMode="changed"
creationMode={options.creationMode}
completionSummary={options.completionSummary}
onClose={() => {}}
onToggleMaximized={() => {}}
/>
</LocaleProvider>,
);
await flushPromises();
});
await waitFor("workspace changes", () => Boolean(document.querySelector(".workspace-preview__body")));
return { dom, root };
}
export async function renderFilesWorkspace(methods: Partial<AppBindings>, props: Partial<Parameters<typeof WorkspacePanel>[0]> = {}) {
resetWorkspaceTreeMemoryForTests();
const dom = installDom();
window.go = {
main: {
App: {
ListDirForTab: async () => [],
SearchFileRefsForTab: async () => [],
WorkspaceGitHistory: async () => [],
WorkspaceChanges: async () => ({ files: [], gitAvailable: true }),
WorkspaceChangeDetail: async () => ({}),
ReadFileForTab: async (_tabID, path) => ({ path, body: "", size: 0, truncated: false, binary: false }),
...methods,
} as Partial<AppBindings> as AppBindings,
},
};
const rootEl = document.getElementById("root");
if (!rootEl) throw new Error("missing root");
const root = createRoot(rootEl);
let currentProps: Parameters<typeof WorkspacePanel>[0] = {
open: true,
tabId: "tab-a",
cwd: "/repo",
maximized: false,
initialViewMode: "files",
onClose: () => {},
onToggleMaximized: () => {},
...props,
};
const rerender = async (nextProps: Partial<Parameters<typeof WorkspacePanel>[0]> = {}) => {
currentProps = { ...currentProps, ...nextProps };
await act(async () => {
root.render(
<LocaleProvider>
<WorkspacePanel {...currentProps} />
</LocaleProvider>,
);
await flushPromises();
});
};
await rerender();
return { dom, root, rerender };
}
@@ -119,22 +119,22 @@ eq(
);
eq(
finalDeclaration(".workspace-panel--with-tree-rail:not(.workspace-panel--tree-hidden)", "grid-template-columns"),
"var(--workspace-tree-rail-width) var(--workspace-tree-width) minmax(var(--workspace-preview-min-width), 1fr)",
"split mode keeps a narrow tree toggle rail beside the file tree",
"var(--workspace-tree-rail-width) minmax(var(--workspace-preview-min-width), 1fr) var(--workspace-tree-width)",
"split mode keeps a narrow tree toggle rail beside the file tree (preview left, tree right per user preference)",
);
eq(finalDeclaration(".workspace-panel--with-tree-rail:not(.workspace-panel--tree-hidden) .workspace-files", "grid-column"), "2", "file tree sits beside the rail");
eq(finalDeclaration(".workspace-panel--with-tree-rail:not(.workspace-panel--tree-hidden) .workspace-preview", "grid-column"), "3", "preview sits after rail and tree");
eq(finalDeclaration(".workspace-panel--with-tree-rail:not(.workspace-panel--tree-hidden) .workspace-files", "grid-column"), "3", "file tree sits at the far right after the preview");
eq(finalDeclaration(".workspace-panel--with-tree-rail:not(.workspace-panel--tree-hidden) .workspace-preview", "grid-column"), "2", "preview sits after the rail, before the tree");
eq(
finalDeclaration(".workspace-panel--with-tree-rail .workspace-tree-resizer", "left"),
"calc(var(--workspace-tree-rail-width) + var(--workspace-tree-width) - 4px)",
"tree resizer accounts for the persistent rail",
"calc(100% - var(--workspace-tree-width) - 4px)",
"tree resizer sits at the tree's left edge (tree is on the right)",
);
eq(
finalDeclaration(".workspace-panel--tree-hidden", "grid-template-columns"),
"var(--workspace-tree-rail-width) minmax(0, 1fr)",
"preview-only mode keeps a narrow tree toggle rail",
"minmax(0, 1fr)",
"preview-only mode drops the tree rail",
);
eq(finalDeclaration(".workspace-panel--tree-hidden .workspace-preview", "grid-column"), "2", "preview sits beside the rail");
eq(finalDeclaration(".workspace-panel--tree-hidden .workspace-preview", "grid-column"), "1", "preview fills the panel when the tree is hidden");
eq(
/const filePreviewLimit = 2 \* 1024 \* 1024/.test(appGo),
true,
@@ -0,0 +1,165 @@
// Run: tsx src/__tests__/workspace-resize-interaction.test.tsx
import { JSDOM } from "jsdom";
import React from "react";
import { act } from "react";
import { createRoot } from "react-dom/client";
import { WorkspacePanel } from "../components/WorkspacePanel";
import type { AppBindings } from "../lib/bridge";
import { LocaleProvider } from "../lib/i18n";
import { resetWorkspaceTreeMemoryForTests } from "../lib/workspaceTreeMemory";
let passed = 0;
let failed = 0;
function eq<T>(actual: T, expected: T, label: string) {
if (Object.is(actual, expected)) {
process.stdout.write(` PASS ${label}\n`);
passed += 1;
} else {
process.stdout.write(` FAIL ${label}: expected ${String(expected)}, got ${String(actual)}\n`);
failed += 1;
}
}
function flushTimers(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 0));
}
async function waitFor(label: string, predicate: () => boolean) {
for (let attempt = 0; attempt < 30; attempt += 1) {
await act(async () => {
await flushTimers();
});
if (predicate()) return;
}
throw new Error(`timed out waiting for ${label}`);
}
class TestResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
}
const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
pretendToBeVisual: true,
url: "http://localhost/",
});
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
globalThis.window = dom.window as unknown as Window & typeof globalThis;
globalThis.document = dom.window.document;
Object.defineProperty(dom.window.navigator, "language", { configurable: true, value: "en-US" });
Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
globalThis.Node = dom.window.Node;
globalThis.Element = dom.window.Element;
globalThis.HTMLElement = dom.window.HTMLElement;
globalThis.Event = dom.window.Event;
globalThis.CustomEvent = dom.window.CustomEvent;
globalThis.KeyboardEvent = dom.window.KeyboardEvent;
globalThis.MouseEvent = dom.window.MouseEvent;
globalThis.PointerEvent = dom.window.MouseEvent as unknown as typeof PointerEvent;
globalThis.MutationObserver = dom.window.MutationObserver;
globalThis.ResizeObserver = TestResizeObserver;
dom.window.ResizeObserver = TestResizeObserver;
globalThis.localStorage = dom.window.localStorage;
globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
Object.defineProperty(dom.window.HTMLElement.prototype, "scrollIntoView", { configurable: true, value: () => {} });
Object.defineProperty(dom.window.HTMLElement.prototype, "offsetWidth", { configurable: true, get: () => 800 });
Object.defineProperty(dom.window.HTMLElement.prototype, "offsetHeight", {
configurable: true,
get: function offsetHeight(this: HTMLElement) {
return this.classList.contains("workspace-tree") ? 300 : this.dataset.index ? 24 : 0;
},
});
Object.defineProperty(dom.window.HTMLElement.prototype, "getBoundingClientRect", {
configurable: true,
value: function getBoundingClientRect(this: HTMLElement) {
const width = 800;
const height = this.classList.contains("workspace-tree") ? 300 : this.dataset.index ? 24 : 0;
return { x: 100, y: 0, top: 0, left: 100, right: 900, bottom: height, width, height, toJSON: () => ({}) } as DOMRect;
},
});
console.log("\nworkspace right-side tree resize interaction");
resetWorkspaceTreeMemoryForTests();
window.go = {
main: {
App: {
ListDirForTab: async (_tabId, dir) => dir === "" ? [{ name: "app.ts", isDir: false }] : [],
SearchFileRefsForTab: async () => [],
WorkspaceGitHistory: async () => [],
WorkspaceChanges: async () => ({ files: [], gitAvailable: true }),
WorkspaceChangeDetail: async () => ({}),
ReadFileForTab: async (_tabId, path) => ({ path, body: "const value = 1;", size: 16, truncated: false, binary: false }),
} as Partial<AppBindings> as AppBindings,
},
};
const rootElement = document.getElementById("root");
if (!rootElement) throw new Error("missing root");
const root = createRoot(rootElement);
await act(async () => {
root.render(
<LocaleProvider>
<WorkspacePanel
open
tabId="resize-tab"
cwd="/repo"
maximized={false}
panelWidth={800}
initialViewMode="files"
onClose={() => {}}
onToggleMaximized={() => {}}
/>
</LocaleProvider>,
);
await flushTimers();
});
await waitFor("workspace file", () => document.querySelector('[data-workspace-path="app.ts"]') !== null);
await act(async () => {
document.querySelector<HTMLButtonElement>('[data-workspace-path="app.ts"]')?.click();
await flushTimers();
});
await waitFor("tree separator", () => document.querySelector(".workspace-tree-resizer") !== null);
const separator = document.querySelector<HTMLButtonElement>(".workspace-tree-resizer");
if (!separator) throw new Error("missing tree separator");
const initialWidth = Number(separator.getAttribute("aria-valuenow"));
await act(async () => {
separator.dispatchEvent(new window.KeyboardEvent("keydown", { bubbles: true, cancelable: true, key: "ArrowRight" }));
await flushTimers();
});
eq(Number(separator.getAttribute("aria-valuenow")), initialWidth - 16, "ArrowRight moves the separator right and narrows the right-side tree");
await act(async () => {
separator.dispatchEvent(new window.KeyboardEvent("keydown", { bubbles: true, cancelable: true, key: "ArrowLeft" }));
await flushTimers();
});
eq(Number(separator.getAttribute("aria-valuenow")), initialWidth, "ArrowLeft moves the separator left and widens the right-side tree");
await act(async () => {
separator.dispatchEvent(new window.MouseEvent("pointerdown", { bubbles: true, cancelable: true, clientX: 600 }));
window.dispatchEvent(new window.MouseEvent("pointermove", { bubbles: true, clientX: 650 }));
window.dispatchEvent(new window.MouseEvent("pointerup", { bubbles: true, clientX: 650 }));
await flushTimers();
});
eq(Number(separator.getAttribute("aria-valuenow")), 250, "pointer position measures tree width from the panel's right edge");
await act(async () => {
separator.dispatchEvent(new window.KeyboardEvent("keydown", { bubbles: true, cancelable: true, key: "End" }));
await flushTimers();
});
eq(Number(separator.getAttribute("aria-valuenow")), 616, "End preserves minimum space for the rail and preview");
await act(async () => {
root.unmount();
});
dom.window.close();
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
if (failed > 0) process.exit(1);
@@ -0,0 +1,91 @@
// Run: tsx src/__tests__/workspace-selection-isolation.test.tsx
import { act } from "react";
import { flushPromises, renderFilesWorkspace, waitFor } from "./workspace-panel-test-harness";
let passed = 0;
let failed = 0;
function ok(value: boolean, label: string) {
if (value) {
process.stdout.write(` PASS ${label}\n`);
passed += 1;
} else {
process.stdout.write(` FAIL ${label}\n`);
failed += 1;
}
}
console.log("\nworkspace selection isolation");
const changeDetailPaths: string[] = [];
const { dom, root, rerender } = await renderFilesWorkspace({
ListDirForTab: async (_tabId, dir) => dir === "" ? [{ name: "notes.txt", isDir: false }] : [],
WorkspaceChanges: async () => ({
files: [{ path: "src/changed.ts", sources: ["git"], gitStatus: "M" }],
gitAvailable: true,
}),
WorkspaceChangeDetail: async (_tabId, path) => {
changeDetailPaths.push(path);
return { source: "git", diff: "changed diff" };
},
ReadFileForTab: async (_tabId, path) => ({ path, body: "file preview", size: 12, truncated: false, binary: false }),
});
await waitFor("files view entry", () => document.body.textContent?.includes("notes.txt") === true);
await act(async () => {
document.querySelector<HTMLButtonElement>('[data-workspace-path="notes.txt"]')
?.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
await flushPromises();
});
await waitFor("selected file preview", () => document.body.textContent?.includes("file preview") === true);
await rerender({ initialViewMode: "changed" });
await waitFor("changes overview", () => document.body.textContent?.includes("changed.ts") === true);
ok(changeDetailPaths.length === 0, "entering Changes does not request diff detail for the Files selection");
ok(document.querySelector(".workspace-panel--changed-overview") !== null, "entering Changes opens its overview instead of leaking the Files selection");
await act(async () => {
document.querySelector<HTMLButtonElement>(".workspace-change")
?.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
await flushPromises();
});
await waitFor("selected change detail", () => changeDetailPaths.includes("src/changed.ts"));
await rerender({ initialViewMode: "files" });
await waitFor("restored file preview", () => document.body.textContent?.includes("file preview") === true);
ok(changeDetailPaths.every((path) => path === "src/changed.ts"), "Changes requests only its own selected path");
ok(document.body.textContent?.includes("notes.txt") === true, "returning to Files restores the prior file selection after viewing a change");
await rerender({
initialViewMode: "changed",
changeListRequest: {
id: 1,
changes: [{ key: "scoped-change", path: "src/changed.ts", meta: "M", time: "", detail: "changed" }],
},
});
await waitFor("scoped Changes list", () => document.body.textContent?.includes("Session changes") === true);
await rerender({ initialViewMode: "files", changeListRequest: null });
await waitFor("file preview after scoped Changes", () => document.body.textContent?.includes("file preview") === true);
ok(document.body.textContent?.includes("notes.txt") === true, "scoped Changes requests preserve the Files preview state");
await rerender({ initialViewMode: "changed" });
await waitFor("change before scope switch", () => document.body.textContent?.includes("changed.ts") === true);
await act(async () => {
document.querySelector<HTMLButtonElement>(".workspace-change")
?.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
await flushPromises();
});
await waitFor("change selected before scope switch", () => changeDetailPaths.includes("src/changed.ts"));
const callsBeforeScopeSwitch = changeDetailPaths.length;
await rerender({ workspaceScopeKey: "other-session" });
await waitFor("new scope Changes overview", () => document.querySelector(".workspace-panel--changed-overview") !== null);
ok(changeDetailPaths.length === callsBeforeScopeSwitch, "workspace scope changes do not request the previous scope's selected change");
await act(async () => {
root.unmount();
});
dom.window.close();
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
if (failed > 0) process.exit(1);
@@ -81,6 +81,34 @@ eq(
"tree resize pointer clamps against the preview minimum after reserving the rail",
);
eq(
workspaceSplitTreeWidthFromPointer({
clientX: 650,
panelLeft: 100,
panelWidth: 800,
railWidth: TREE_RAIL_WIDTH,
treeMinWidth: TREE_MIN_WIDTH,
previewMinWidth: PREVIEW_MIN_WIDTH,
treeOnRight: true,
}),
250,
"right-side tree width is measured from the pointer to the panel's right edge",
);
eq(
workspaceSplitTreeWidthFromPointer({
clientX: 100,
panelLeft: 100,
panelWidth: 800,
railWidth: TREE_RAIL_WIDTH,
treeMinWidth: TREE_MIN_WIDTH,
previewMinWidth: PREVIEW_MIN_WIDTH,
treeOnRight: true,
}),
616,
"right-side tree maximum keeps space for the rail and preview",
);
eq(
resolveWorkspaceSplitTreeWidth({
mode: "even",
@@ -0,0 +1,250 @@
// Run: tsx src/__tests__/workspace-tree-scroll-restore.test.tsx
// zk-ge CLAIM.TREE.008: 切换 dock 标签(组件 unmount/remount)后文件树滚动位置恢复
import { JSDOM } from "jsdom";
import React from "react";
import { act } from "react";
import { createRoot } from "react-dom/client";
import type { Root } from "react-dom/client";
import { WorkspacePanel } from "../components/WorkspacePanel";
import type { AppBindings } from "../lib/bridge";
import { LocaleProvider } from "../lib/i18n";
import { resetWorkspaceTreeMemoryForTests } from "../lib/workspaceTreeMemory";
let passed = 0;
let failed = 0;
function ok(value: boolean, label: string) {
if (value) {
process.stdout.write(` PASS ${label}\n`);
passed += 1;
} else {
process.stdout.write(` FAIL ${label}\n`);
failed += 1;
}
}
function flushTimers(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 0));
}
async function waitFor(label: string, predicate: () => boolean) {
for (let attempt = 0; attempt < 30; attempt += 1) {
await act(async () => {
await flushTimers();
});
if (predicate()) return;
}
throw new Error(`timed out waiting for ${label}`);
}
class TestResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
}
function installDom() {
const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
pretendToBeVisual: true,
url: "http://localhost/",
});
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
globalThis.window = dom.window as unknown as Window & typeof globalThis;
globalThis.document = dom.window.document;
Object.defineProperty(dom.window.navigator, "language", { configurable: true, value: "en-US" });
Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
globalThis.Node = dom.window.Node;
globalThis.Element = dom.window.Element;
globalThis.HTMLElement = dom.window.HTMLElement;
globalThis.Event = dom.window.Event;
globalThis.CustomEvent = dom.window.CustomEvent;
globalThis.KeyboardEvent = dom.window.KeyboardEvent;
globalThis.MouseEvent = dom.window.MouseEvent;
globalThis.PointerEvent = dom.window.MouseEvent as unknown as typeof PointerEvent;
globalThis.MutationObserver = dom.window.MutationObserver;
globalThis.ResizeObserver = TestResizeObserver;
dom.window.ResizeObserver = TestResizeObserver;
globalThis.localStorage = dom.window.localStorage;
globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
Object.defineProperty(dom.window.HTMLElement.prototype, "scrollIntoView", { configurable: true, value: () => {} });
Object.defineProperty(dom.window.HTMLElement.prototype, "offsetWidth", { configurable: true, get: () => 320 });
Object.defineProperty(dom.window.HTMLElement.prototype, "clientHeight", {
configurable: true,
get: function clientHeight(this: HTMLElement) {
return this.classList.contains("workspace-tree") ? 300 : 0;
},
});
Object.defineProperty(dom.window.HTMLElement.prototype, "scrollHeight", {
configurable: true,
get: function scrollHeight(this: HTMLElement) {
return this.classList.contains("workspace-tree") ? 40 * 24 : 0;
},
});
Object.defineProperty(dom.window.HTMLElement.prototype, "offsetHeight", {
configurable: true,
get: function offsetHeight(this: HTMLElement) {
return this.classList.contains("workspace-tree") ? 300 : this.dataset.index ? 24 : 0;
},
});
Object.defineProperty(dom.window.HTMLElement.prototype, "getBoundingClientRect", {
configurable: true,
value: function getBoundingClientRect(this: HTMLElement) {
const width = 320;
const height = this.classList.contains("workspace-tree") ? 300 : this.dataset.index ? 24 : 0;
return { x: 0, y: 0, top: 0, left: 0, right: width, bottom: height, width, height, toJSON: () => ({}) } as DOMRect;
},
});
Object.defineProperty(dom.window.HTMLElement.prototype, "scrollTo", {
configurable: true,
value: function scrollTo(this: HTMLElement, options?: ScrollToOptions | number) {
const requested = typeof options === "number" ? options : options?.top ?? 0;
const maximum = Math.max(0, this.scrollHeight - this.clientHeight);
this.scrollTop = Math.max(0, Math.min(maximum, requested));
},
});
return dom;
}
const MEMORY_KEY = "scroll-test\u0000/repo";
function renderPanel(root: Root) {
return act(async () => {
root.render(
<LocaleProvider>
<WorkspacePanel
open
tabId="scroll-tab"
cwd="/repo"
workspaceMemoryKey={MEMORY_KEY}
maximized={false}
initialViewMode="files"
onClose={() => {}}
onToggleMaximized={() => {}}
onOpenInTerminal={() => {}}
/>
</LocaleProvider>,
);
await flushTimers();
});
}
console.log("\nworkspace tree scroll restore (CLAIM.TREE.008)");
resetWorkspaceTreeMemoryForTests();
const dom = installDom();
dom.window.localStorage.clear();
const RESTORED_SCROLL_TOP = 200;
dom.window.localStorage.setItem("reasonix.workspaceState.v2", JSON.stringify({
version: 2,
projects: [{
key: MEMORY_KEY,
state: {
openDirs: [""],
scrollTop: RESTORED_SCROLL_TOP,
updatedAt: Date.now(),
},
}],
}));
window.go = {
main: {
App: {
ListDirForTab: async (_tabId, dir) => {
if (dir === "") {
return Array.from({ length: 40 }, (_, i) => ({ name: `file-${i}.ts`, isDir: false }));
}
return [];
},
SearchFileRefsForTab: async () => [],
WorkspaceGitHistory: async () => [],
WorkspaceChanges: async () => ({ files: [], gitAvailable: true }),
WorkspaceChangeDetail: async () => ({}),
ReadFileForTab: async (_tabId, path) => ({ path, body: "", size: 0, truncated: false, binary: false }),
ResolveWorkspacePathForTab: async (_tabId, path) => `/repo/${path}`,
RevealWorkspacePathForTab: async () => {},
OpenWorkspacePathForTab: async () => {},
} as Partial<AppBindings> as AppBindings,
},
};
const rootElement = document.getElementById("root");
if (!rootElement) throw new Error("missing root");
const root = createRoot(rootElement);
// Phase 1: mount from a persisted deep offset and verify the virtualizer writes
// that offset into a genuinely scrollable DOM fixture.
await renderPanel(root);
await waitFor("workspace rows render", () => document.querySelectorAll(".workspace-tree__row").length > 0);
const treeEl = document.querySelector<HTMLElement>(".workspace-tree");
if (!treeEl) throw new Error("missing workspace-tree element");
ok(treeEl.scrollHeight > treeEl.clientHeight, "fixture has a real scrollable extent");
await waitFor("persisted tree offset restore", () => treeEl.scrollTop === RESTORED_SCROLL_TOP);
ok(treeEl.scrollTop === RESTORED_SCROLL_TOP, "initial mount restores the persisted DOM scroll offset");
// Scroll position should have been persisted to the upstream per-project
// workspace state envelope (reasonix.workspaceState.v2).
function persistedScrollTop(): number | null {
const raw = dom.window.localStorage.getItem("reasonix.workspaceState.v2");
if (!raw) return null;
try {
const parsed = JSON.parse(raw) as { projects?: Array<{ key: string; state: { scrollTop?: number } }> };
const project = parsed.projects?.find((entry) => entry.key === MEMORY_KEY);
return typeof project?.state?.scrollTop === "number" ? project.state.scrollTop : null;
} catch {
return null;
}
}
ok(persistedScrollTop() === RESTORED_SCROLL_TOP, "restoration preserves the persisted localStorage offset");
// Phase 2: unmount (simulates switching from 文件 to 概览 dock tab)
await act(async () => {
root.render(<></>);
await flushTimers();
});
// Phase 3: remount (simulates switching back to 文件)
await renderPanel(root);
await waitFor("workspace rows re-render", () => document.querySelectorAll(".workspace-tree__row").length > 0);
const treeEl2 = document.querySelector<HTMLElement>(".workspace-tree");
if (!treeEl2) throw new Error("missing workspace-tree element after remount");
// The persisted value must survive the remount — verify the workspace memory
// still holds it (this isolates persistence loss from virtualizer restore).
ok(persistedScrollTop() === RESTORED_SCROLL_TOP, "persisted scroll offset survives remount in localStorage");
// Remount must re-render the tree rows (otherwise the restore effect's
// treeRows.length guard never passes).
const rowsAfterRemount = document.querySelectorAll(".workspace-tree__row").length;
ok(rowsAfterRemount > 0, `tree rows re-render after remount (got ${rowsAfterRemount})`);
await waitFor("remounted tree DOM restore", () => treeEl2.scrollTop === RESTORED_SCROLL_TOP);
ok(treeEl2.scrollTop === RESTORED_SCROLL_TOP, "dock-tab remount restores the actual DOM scroll offset");
// Phase 4: switch to the "changed" view via the tab button, then back to
// "files". The pending scroll restore must be reset on viewMode change — the
// saved offset belongs to the previous mode's tree and re-applying it to a
// shorter list (files -> changed) would scroll past the end and leave a blank
// band at the top. We assert the panel still renders rows after the round
// trip; the reset itself is what makes that render coherent.
const tabs = document.querySelectorAll(".workspace-files__tab");
ok(tabs.length >= 2, "files and changed view tabs render in the fixture");
if (tabs.length >= 2) {
await act(async () => {
(tabs[1] as HTMLElement).click();
await flushTimers();
});
await act(async () => {
(tabs[0] as HTMLElement).click();
await flushTimers();
});
await waitFor("files rows re-render after view-mode round trip", () => document.querySelectorAll(".workspace-tree__row").length > 0);
ok(document.querySelectorAll(".workspace-tree__row").length > 0, "tree rows re-render after files<->changed view switch");
}
console.log(`\nworkspace tree scroll restore: ${passed} passed, ${failed} failed`);
if (failed > 0) process.exit(1);
@@ -19,7 +19,6 @@ interface AppChromeProps {
sidebarToggleTitle: string;
workspacePanelMaximized: boolean;
workspacePanelRenderable: boolean;
workspaceTogglePressed: boolean;
workspacePanelLabel: string;
onToggleSidebar: () => void;
onToggleWorkspacePanel: () => void;
@@ -45,7 +44,6 @@ export function AppChrome({
sidebarToggleTitle,
workspacePanelMaximized,
workspacePanelRenderable,
workspaceTogglePressed,
workspacePanelLabel,
onToggleSidebar,
onToggleWorkspacePanel,
@@ -129,7 +127,6 @@ export function AppChrome({
className={[
"app-chrome__tools",
"app-chrome__tools--fixed",
workspaceTogglePressed ? "app-chrome__tools--workspace-pressed" : "",
].filter(Boolean).join(" ")}
aria-label={t("tabBar.commandSearch")}
>
@@ -156,7 +153,6 @@ export function AppChrome({
<div
className={[
"app-chrome__tools",
workspaceTogglePressed ? "app-chrome__tools--workspace-pressed" : "",
].filter(Boolean).join(" ")}
aria-label={t("tabBar.commandSearch")}
>
@@ -183,7 +179,6 @@ export function AppChrome({
"app-chrome__panel-toggle",
"app-chrome__panel-toggle--right",
workspacePanelRenderable ? "app-chrome__panel-toggle--active" : "",
workspaceTogglePressed ? "app-chrome__panel-toggle--pressed" : "",
].filter(Boolean).join(" ")}
type="button"
onClick={onToggleWorkspacePanel}
@@ -20,6 +20,9 @@ export function PromptShelf({
role = "dialog",
decision = false,
actionsRole = "listbox",
cardCollapsible = false,
collapsed = false,
onToggleCollapse,
}: {
className?: string;
cardClassName?: string;
@@ -45,6 +48,11 @@ export function PromptShelf({
// Select-then-confirm surfaces use listbox. Immediate actions are a group of
// buttons so assistive technology does not announce them as pending choices.
actionsRole?: "listbox" | "group";
// Whole-card click-to-collapse (used by the todo shelf): clicking anywhere
// on the card toggles collapsed; interactive children stop propagation.
cardCollapsible?: boolean;
collapsed?: boolean;
onToggleCollapse?: () => void;
}) {
return (
<div
@@ -59,22 +67,40 @@ export function PromptShelf({
>
<div
ref={barRef}
className={["prompt-shelf__card", cardClassName ?? ""].filter(Boolean).join(" ")}
className={[
"prompt-shelf__card",
cardCollapsible ? "prompt-shelf__card--collapsible" : "",
cardCollapsible && collapsed ? "prompt-shelf__card--collapsed" : "",
cardClassName ?? "",
]
.filter(Boolean)
.join(" ")}
role={role}
aria-modal={role === "dialog" ? "false" : undefined}
aria-labelledby={titleId}
tabIndex={-1}
tabIndex={cardCollapsible ? 0 : -1}
onClick={cardCollapsible ? onToggleCollapse : undefined}
onKeyDown={cardCollapsible && onToggleCollapse ? (event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
onToggleCollapse();
}
} : undefined}
>
<div className="prompt-shelf__content">
<div className="prompt-shelf__header">
<div className="prompt-shelf__copy">
<div id={titleId} className="prompt-shelf__title">
<span className="prompt-shelf__heading">{title}</span>
{meta && <span className="prompt-shelf__meta">{meta}</span>}
{badges && <span className="prompt-shelf__badges">{badges}</span>}
</div>
{meta && <div className="prompt-shelf__meta">{meta}</div>}
</div>
{headerActions && <div className="prompt-shelf__header-actions">{headerActions}</div>}
{headerActions && (
<div className="prompt-shelf__header-actions" onClick={(event) => event.stopPropagation()}>
{headerActions}
</div>
)}
</div>
{crumbs}
{children && <div className="prompt-shelf__body">{children}</div>}
+12 -15
View File
@@ -85,23 +85,20 @@ export function TodoPanel({
badges={<PromptBadge>{done}/{todos.length}</PromptBadge>}
meta={summary}
role="region"
cardClassName="prompt-shelf--todo"
cardCollapsible
collapsed={!open}
onToggleCollapse={() => setOpen((value) => {
const next = !value;
saveOpenState(stateKey, next);
return next;
})}
headerActions={
<>
<PromptHeaderAction
onClick={() => setOpen((value) => {
const next = !value;
saveOpenState(stateKey, next);
return next;
})}
>
{open ? t("common.collapse") : t("common.expand")}
allDone && (
<PromptHeaderAction onClick={onDismiss}>
{t("common.close")}
</PromptHeaderAction>
{allDone && (
<PromptHeaderAction onClick={onDismiss}>
{t("common.close")}
</PromptHeaderAction>
)}
</>
)
}
>
{open && (
@@ -0,0 +1,97 @@
import { lazy, Suspense, useEffect, useRef, useState } from "react";
import { MoreHorizontal } from "lucide-react";
import { Tooltip } from "./Tooltip";
import type { TopicbarMoreMenuContentProps } from "./TopicbarMoreMenuContent";
import { t } from "../lib/i18n";
const loadMenuContent = () => import("./TopicbarMoreMenuContent");
const TopicbarMoreMenuContent = lazy(async () => ({
default: (await loadMenuContent()).TopicbarMoreMenuContent,
}));
type TopicbarMoreMenuProps = Omit<TopicbarMoreMenuContentProps, "copied" | "initialFocus" | "onClose" | "onCopied">;
/** Keeps the topic bar compact while loading overflow actions only on demand. */
export function TopicbarMoreMenu(props: TopicbarMoreMenuProps) {
const rootRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const [menuOpen, setMenuOpen] = useState(false);
const [initialFocus, setInitialFocus] = useState<"first" | "last">("first");
const [copied, setCopied] = useState(false);
const copiedTimerRef = useRef<number | null>(null);
const closeMenu = (restoreFocus: boolean) => {
setMenuOpen(false);
if (restoreFocus) triggerRef.current?.focus();
};
useEffect(() => {
if (!menuOpen) return;
const onPointerDown = (event: MouseEvent) => {
if (!rootRef.current?.contains(event.target as Node)) closeMenu(false);
};
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") closeMenu(true);
};
document.addEventListener("mousedown", onPointerDown);
document.addEventListener("keydown", onKeyDown);
return () => {
document.removeEventListener("mousedown", onPointerDown);
document.removeEventListener("keydown", onKeyDown);
};
}, [menuOpen]);
useEffect(() => () => {
if (copiedTimerRef.current != null) window.clearTimeout(copiedTimerRef.current);
}, []);
const markCopied = () => {
setCopied(true);
if (copiedTimerRef.current != null) window.clearTimeout(copiedTimerRef.current);
copiedTimerRef.current = window.setTimeout(() => setCopied(false), 1200);
};
const openFromKeyboard = (event: React.KeyboardEvent<HTMLButtonElement>) => {
if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return;
event.preventDefault();
setInitialFocus(event.key === "ArrowUp" ? "last" : "first");
setMenuOpen(true);
};
return (
<div ref={rootRef} className={`topicbar__more${menuOpen ? " topicbar__more--open" : ""}`}>
<Tooltip label={t("topicBar.more")}>
<button
ref={triggerRef}
className="topicbar__action-btn topicbar__action-btn--icon topicbar__action-btn--utility"
type="button"
aria-label={t("topicBar.more")}
aria-haspopup="menu"
aria-expanded={menuOpen}
onPointerEnter={() => { void loadMenuContent(); }}
onFocus={() => { void loadMenuContent(); }}
onKeyDown={openFromKeyboard}
onClick={() => {
setInitialFocus("first");
setMenuOpen((open) => !open);
}}
>
<MoreHorizontal size={15} />
</button>
</Tooltip>
{menuOpen && (
<Suspense fallback={null}>
<TopicbarMoreMenuContent
{...props}
copied={copied}
initialFocus={initialFocus}
onClose={closeMenu}
onCopied={markCopied}
/>
</Suspense>
)}
</div>
);
}
@@ -0,0 +1,83 @@
.topicbar__more-menu {
position: absolute;
right: 0;
top: calc(100% + 6px);
z-index: var(--z-topicbar-menu);
min-width: 168px;
padding: 5px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg-elev);
box-shadow: 0 16px 36px rgba(0, 0, 0, 0.36);
--wails-draggable: no-drag;
}
.topicbar__menu-item {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 8px 9px;
border: none;
border-radius: 6px;
background: transparent;
color: var(--fg-dim);
font: inherit;
font-size: 12.5px;
text-align: left;
}
.topicbar__menu-item:hover,
.topicbar__menu-item:focus-visible {
color: var(--fg);
background: var(--button-bg);
}
.topicbar__menu-item:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.topicbar__menu-item--active {
color: var(--accent);
}
.topicbar__more-export {
position: relative;
}
.topicbar__more-export-menu {
position: absolute;
/* The primary menu sits at the far right, so the submenu opens left. */
right: calc(100% - 8px);
top: -5px;
z-index: var(--z-topicbar-menu);
min-width: 168px;
padding: 5px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg-elev);
box-shadow: 0 16px 36px rgba(0, 0, 0, 0.36);
--wails-draggable: no-drag;
}
.topicbar__more-export-menu button {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 8px 9px;
border: none;
border-radius: 6px;
background: transparent;
color: var(--fg-dim);
font: inherit;
font-size: 12.5px;
text-align: left;
}
.topicbar__more-export-menu button:hover,
.topicbar__more-export-menu button:focus-visible {
color: var(--fg);
background: var(--button-bg);
}
@@ -0,0 +1,195 @@
import { useEffect, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from "react";
import {
Activity,
Check,
Copy,
Download,
FileDown,
FileImage,
FileJson,
FileText,
GitBranch,
TerminalSquare,
} from "lucide-react";
import "./TopicbarMoreMenuContent.css";
import { writeClipboardText } from "../lib/clipboard";
import { t } from "../lib/i18n";
export interface TopicbarMoreMenuContentProps {
sessionHasContent: boolean;
getSessionMarkdown: () => string | Promise<string>;
exportSession: (format: "markdown" | "json" | "pdf" | "image") => void;
openChangedDock: () => void;
toggleTerminal: () => void;
prefetchTerminal?: () => void;
openSessionSummary: () => void;
tasksOpen: boolean;
copied: boolean;
initialFocus: "first" | "last";
onClose: (restoreFocus: boolean) => void;
onCopied: () => void;
}
function enabledItems(menu: HTMLElement): HTMLButtonElement[] {
return Array.from(menu.querySelectorAll<HTMLButtonElement>('[role="menuitem"]'))
.filter((item) => !item.disabled && item.closest('[role="menu"]') === menu);
}
function moveMenuFocus(event: ReactKeyboardEvent<HTMLDivElement>) {
if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) return;
event.preventDefault();
event.stopPropagation();
const items = enabledItems(event.currentTarget);
if (items.length === 0) return;
const current = items.indexOf(document.activeElement as HTMLButtonElement);
const next = event.key === "Home" ? 0
: event.key === "End" ? items.length - 1
: event.key === "ArrowDown" ? (current + 1 + items.length) % items.length
: (current - 1 + items.length) % items.length;
items[next]?.focus();
}
export function TopicbarMoreMenuContent({
sessionHasContent,
getSessionMarkdown,
exportSession,
openChangedDock,
toggleTerminal,
prefetchTerminal,
openSessionSummary,
tasksOpen,
copied,
initialFocus,
onClose,
onCopied,
}: TopicbarMoreMenuContentProps) {
const menuRef = useRef<HTMLDivElement>(null);
const exportTriggerRef = useRef<HTMLButtonElement>(null);
const exportMenuRef = useRef<HTMLDivElement>(null);
const [exportOpen, setExportOpen] = useState(false);
useEffect(() => {
const items = menuRef.current ? enabledItems(menuRef.current) : [];
(initialFocus === "last" ? items[items.length - 1] : items[0])?.focus();
}, [initialFocus]);
useEffect(() => {
if (!exportOpen || !exportMenuRef.current) return;
enabledItems(exportMenuRef.current)[0]?.focus();
}, [exportOpen]);
const closeAndRun = (action: () => void) => {
onClose(true);
action();
};
const copySession = async () => {
try {
await writeClipboardText(await getSessionMarkdown());
} catch {
/* clipboard unavailable */
}
onCopied();
};
const onMainMenuKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => {
if (event.key === "Escape") {
event.preventDefault();
event.stopPropagation();
onClose(true);
return;
}
moveMenuFocus(event);
};
const onExportMenuKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => {
if (event.key === "ArrowLeft" || event.key === "Escape") {
event.preventDefault();
event.stopPropagation();
setExportOpen(false);
exportTriggerRef.current?.focus();
return;
}
moveMenuFocus(event);
};
return (
<div ref={menuRef} className="topicbar__more-menu" role="menu" aria-label={t("topicBar.more")} onKeyDown={onMainMenuKeyDown}>
<button tabIndex={-1} className="topicbar__menu-item" type="button" role="menuitem" onClick={() => closeAndRun(() => { void copySession(); })}>
{copied ? <Check size={14} /> : <Copy size={14} />}
<span>{copied ? t("msg.copied") : t("topicBar.copyAll")}</span>
</button>
<div className={`topicbar__more-export${exportOpen ? " topicbar__more-export--open" : ""}`}>
<button
ref={exportTriggerRef}
tabIndex={-1}
className="topicbar__menu-item"
type="button"
role="menuitem"
aria-haspopup="menu"
aria-expanded={exportOpen}
disabled={!sessionHasContent}
onKeyDown={(event) => {
if (event.key !== "ArrowRight") return;
event.preventDefault();
event.stopPropagation();
setExportOpen(true);
}}
onClick={() => setExportOpen((open) => !open)}
>
<Download size={14} />
<span>{t("topicBar.export")}</span>
</button>
{exportOpen && (
<div ref={exportMenuRef} className="topicbar__more-export-menu" role="menu" aria-label={t("topicBar.export")} onKeyDown={onExportMenuKeyDown}>
<button tabIndex={-1} type="button" role="menuitem" onClick={() => closeAndRun(() => exportSession("markdown"))}>
<FileText size={13} />
<span>{t("topicBar.exportMarkdown")}</span>
</button>
<button tabIndex={-1} type="button" role="menuitem" onClick={() => closeAndRun(() => exportSession("json"))}>
<FileJson size={13} />
<span>{t("topicBar.exportJson")}</span>
</button>
<button tabIndex={-1} type="button" role="menuitem" onClick={() => closeAndRun(() => exportSession("pdf"))}>
<FileDown size={13} />
<span>{t("topicBar.exportPdf")}</span>
</button>
<button tabIndex={-1} type="button" role="menuitem" onClick={() => closeAndRun(() => exportSession("image"))}>
<FileImage size={13} />
<span>{t("topicBar.exportImage")}</span>
</button>
</div>
)}
</div>
<button tabIndex={-1} className="topicbar__menu-item" type="button" role="menuitem" onClick={() => closeAndRun(openChangedDock)}>
<GitBranch size={14} />
<span>{t("workspace.changedTab")}</span>
</button>
<button
tabIndex={-1}
className="topicbar__menu-item"
type="button"
role="menuitem"
onPointerEnter={prefetchTerminal}
onFocus={prefetchTerminal}
onClick={() => closeAndRun(toggleTerminal)}
>
<TerminalSquare size={14} />
<span>{t("rightDock.terminal")}</span>
</button>
<button
tabIndex={-1}
className={`topicbar__menu-item${tasksOpen ? " topicbar__menu-item--active" : ""}`}
type="button"
role="menuitem"
aria-pressed={tasksOpen}
onClick={() => closeAndRun(openSessionSummary)}
>
<Activity size={14} />
<span>{t("summary.session")}</span>
</button>
</div>
);
}
+133 -143
View File
@@ -11,7 +11,6 @@ import {
ChevronDown,
ChevronRight,
FileText,
Folder,
FolderTree,
FolderX,
GitBranch,
@@ -39,8 +38,15 @@ import { closeWorkspacePreviewTab } from "../lib/workspacePreviewTabs";
import { useWorkspaceRefresh } from "../lib/workspaceRefreshStore";
import { useWorkspaceRefreshInvalidation, workspaceRefreshFallbackSequence } from "../lib/workspaceRefreshInvalidation";
import { createWorkspaceRefreshScheduler } from "../lib/workspaceRefreshScheduler";
import {
beginKeyedResourceRequest,
emptyKeyedResource,
rejectKeyedResourceRequest,
resolveKeyedResourceRequest,
} from "../lib/keyedResource";
import { shouldScrollWorkspaceTreeSelection } from "../lib/workspaceTreeReveal";
import { mergeWorkspaceSearchResults } from "../lib/workspaceTreeSearch";
import { useWorkspaceTreeScrollPersistence } from "../lib/useWorkspaceTreeScrollPersistence";
import {
readWorkspaceTreeMemory,
rememberWorkspaceTreeOpenDirs,
@@ -48,7 +54,6 @@ import {
touchWorkspaceTreeVisit,
workspaceTreeVisitId,
} from "../lib/workspaceTreeMemory";
import { useWorkspaceTreeScrollPersistence } from "../lib/useWorkspaceTreeScrollPersistence";
import { loadLayoutSize, loadOptionalLayoutSize } from "../lib/layoutPreferences";
import {
RIGHT_DOCK_PREVIEW_DEFAULT_WIDTH,
@@ -76,8 +81,9 @@ import { Markdown } from "./Markdown";
import { Tooltip } from "./Tooltip";
import { AnchoredPopover } from "./AnchoredPopover";
import { MarkdownImageTabContext } from "./MarkdownImageContext";
import { WorkspaceFileIcon } from "./WorkspaceFileIcon";
import { WorkspaceMediaPreview } from "./WorkspaceMediaPreview";
import { buildWorkspacePathBreadcrumbs, WorkspacePathBreadcrumbs } from "./WorkspacePathBreadcrumbs";
import { WorkspaceTreeRow, type WorkspaceTreeRowData } from "./WorkspaceTreeRow";
import { WorkspaceTreeMenu } from "./WorkspaceTreeMenu";
import { WORKSPACE_TURN_VERIFICATION_ID, WorkspaceTurnVerification } from "./WorkspaceTurnVerification";
import { useWorkspaceChangesResource } from "../lib/useWorkspaceChangesResource";
@@ -87,12 +93,6 @@ import {
workspaceParentDirs as parentDirs, workspaceParentPath as parentPath,
workspaceShortCwd as shortCwd, workspaceTopLevelDirPath as topLevelDirPath,
} from "../lib/workspacePanelFormat";
import {
beginKeyedResourceRequest,
emptyKeyedResource,
rejectKeyedResourceRequest,
resolveKeyedResourceRequest,
} from "../lib/keyedResource";
const WORKSPACE_TREE_MIN_WIDTH = 140;
const WORKSPACE_TREE_DEFAULT_WIDTH = 300;
@@ -120,18 +120,6 @@ function clampWorkspaceTreeWidth(width: number, panelWidth?: number): number {
});
}
interface TreeRow {
key: string;
path: string;
depth: number;
entry: DirEntry;
active: boolean;
isOpen?: boolean;
isSearch?: boolean;
compactPaths?: string[];
displayName?: string;
}
export function WorkspacePanel({
open,
tabId,
@@ -204,6 +192,8 @@ export function WorkspacePanel({
&& verificationRevealRequest.currentSummary === completionSummary ? verificationRevealRequest : null;
const visibleCompletionSummary = activeVerificationRevealRequest?.summary ?? completionSummary;
const workspaceScopeKey = workspaceScopeKeyProp ?? `${workspaceTabId}\u0000${cwd ?? ""}`;
const lastWorkspaceScopeKeyRef = useRef(workspaceScopeKey);
const scopeSwitchPendingRef = useRef(false);
const workspaceMemoryKey = workspaceMemoryKeyProp ?? workspaceScopeKey;
const workspaceMemoryVisitId = workspaceMemoryVisitIdProp ?? workspaceTreeVisitId(workspaceMemoryKey);
const workspaceRefresh = useWorkspaceRefresh(workspaceTabId, workspaceScopeKey, open);
@@ -231,6 +221,12 @@ export function WorkspacePanel({
() => initialWorkspaceMemory?.selectedChangePath ?? null,
);
const [openTabs, setOpenTabs] = useState<string[]>([]);
// Independent "recently opened" history: survives closing all preview tabs
// (openTabs is the live preview state) and app restarts, so the recent-files
// menu keeps the user's file history even after the previews are dismissed.
const [recentPaths, setRecentPaths] = useState<string[]>(() =>
(initialWorkspaceMemory?.recentPaths ?? []).slice(0, WORKSPACE_MAX_PREVIEW_TABS),
);
const [previewResource, setPreviewResource] = useState(() => emptyKeyedResource<FilePreview>());
const [viewMode, setViewMode] = useState<"files" | "changed">(initialViewMode);
const selectedPath = viewMode === "changed" ? selectedChangePath : selectedFilePath;
@@ -273,7 +269,6 @@ export function WorkspacePanel({
const lastChangeListRequestIdRef = useRef<number | null>(null);
const dismissedChangeListRequestIdRef = useRef<number | null>(null);
const currentWorkspaceScopeKeyRef = useRef(workspaceScopeKey);
const lastWorkspaceScopeKeyRef = useRef(workspaceScopeKey);
const changeDetailRequestIdRef = useRef(0);
const gitHistoryRequestIdRef = useRef(0);
const previewRequestIdRef = useRef(0);
@@ -371,6 +366,11 @@ export function WorkspacePanel({
rememberWorkspaceTreeState(workspaceMemoryKey, { treeWidth, treeWidthMode });
}, [treeWidth, treeWidthMode, workspaceMemoryKey]);
useEffect(() => {
if (memoryRestorePendingRef.current) return;
rememberWorkspaceTreeState(workspaceMemoryKey, { recentPaths });
}, [recentPaths, workspaceMemoryKey]);
useEffect(() => {
if (memoryRestorePendingRef.current || dockTreeWidth == null || dockPreviewWidth == null) return;
rememberWorkspaceTreeState(workspaceMemoryKey, { dockTreeWidth, dockPreviewWidth });
@@ -423,6 +423,10 @@ export function WorkspacePanel({
setChangeDetailResource(emptyKeyedResource());
return;
}
// Scope switch must not request the previous scope's selected change:
// the selection is preserved for the original scope, but a request must
// not fire for the just-switched scope before the scope effect settles.
if (scopeSwitchPendingRef.current) return;
const requestKey = `${requestScopeKey}\u0000change\u0000${requestPath}`;
setChangeDetailResource((current) => beginKeyedResourceRequest(current, requestKey, requestId, workspaceRefresh.revisions.content));
try {
@@ -489,7 +493,11 @@ export function WorkspacePanel({
setTreeWidth(initialWorkspaceSplitTreeWidth({
panelWidth,
railWidth: WORKSPACE_TREE_RAIL_WIDTH,
savedTreeWidth: null,
// Preserve a user-resized (manual) tree width: only first-time
// splits (no saved width yet) default to an even 50/50 division.
// Reopening a file after closing its preview must keep the
// remembered width, not snap back to the initial split.
savedTreeWidth: treeWidthMode === "manual" ? treeWidth : null,
treeMinWidth: WORKSPACE_TREE_MIN_WIDTH,
previewMinWidth: WORKSPACE_PREVIEW_MIN_WIDTH,
}));
@@ -508,6 +516,7 @@ export function WorkspacePanel({
});
setFilter("");
setOpenTabs((tabs) => [...tabs.filter((tab) => tab !== path), path].slice(-WORKSPACE_MAX_PREVIEW_TABS));
setRecentPaths((paths) => [...paths.filter((p) => p !== path), path].slice(-WORKSPACE_MAX_PREVIEW_TABS));
const dirs = parentDirs(path);
updateOpenDirs((prev) => new Set([...Array.from(prev), ...dirs]));
dirs.forEach((dir) => void loadDir(dir));
@@ -541,6 +550,7 @@ export function WorkspacePanel({
if (!open) return;
if (lastWorkspaceScopeKeyRef.current === workspaceScopeKey) return;
lastWorkspaceScopeKeyRef.current = workspaceScopeKey;
scopeSwitchPendingRef.current = true;
workingTreeRefreshSchedulerRef.current?.cancel();
gitMetaRefreshSchedulerRef.current?.cancel();
changeDetailRequestIdRef.current += 1;
@@ -755,7 +765,11 @@ export function WorkspacePanel({
if (!open) return;
if (viewMode === "changed") {
void loadWorkspaceChanges();
if (selectedPath) void loadChangeDetail();
// A scope switch just happened in this render batch: the previous
// scope's selected change must not be re-requested, but the selection
// itself is preserved for when the user returns to that scope.
if (selectedPath && !scopeSwitchPendingRef.current) void loadChangeDetail();
scopeSwitchPendingRef.current = false;
if (commitHistoryOpen) void loadGitHistory();
} else {
changeDetailRequestIdRef.current += 1;
@@ -980,14 +994,18 @@ export function WorkspacePanel({
const changedMode = viewMode === "changed";
const currentFileName = selectedPath ? basename(selectedPath) : t("workspace.noFile");
const currentFileDir = selectedPath ? parentPath(selectedPath) : "";
const previewTitle = changedMode && !selectedPath
? scopedChangeRows ? t("context.sessionChanges") : t("workspace.changedTab")
: currentFileName;
const previewSubtitle = changedMode && !selectedPath
? scopedChangeRows ? t("context.changedMeta", { count: scopedChangeRows.length }) : shortCwd(cwd) || t("workspace.title")
: currentFileDir;
const recentFiles = useMemo(() => [...openTabs].reverse(), [openTabs]);
const activeSelectedPath = selectedPath;
// Changed overview shows just the project name (matching the file-preview
// breadcrumb); hovering reveals the full absolute workspace root.
const previewSubtitleCrumbs = changedMode && !selectedChangePath && !scopedChangeRows
? (cwd ? [{ label: basename(cwd), full: cwd }] : [])
: [];
const previewFullPath = activeSelectedPath || "";
const previewCrumbs = buildWorkspacePathBreadcrumbs(cwd, previewFullPath);
const recentFiles = useMemo(() => [...recentPaths].reverse(), [recentPaths]);
const workspaceSearchFallbackSequence = workspaceRefreshFallbackSequence(workspaceRefresh);
@@ -1026,7 +1044,7 @@ export function WorkspacePanel({
.sort((a, b) => a.path.localeCompare(b.path));
}, [entriesByDir, filter, scopedFilePaths, searchResults]);
const treeRows = useMemo<TreeRow[]>(() => {
const treeRows = useMemo<WorkspaceTreeRowData[]>(() => {
if (flattened) {
return flattened.map(({ path, entry }) => ({
key: path,
@@ -1037,7 +1055,7 @@ export function WorkspacePanel({
isSearch: true,
}));
}
const acc: TreeRow[] = [];
const acc: WorkspaceTreeRowData[] = [];
const build = (dir: string, depth: number) => {
const entries = entriesByDir[dir] ?? [];
for (const entry of entries) {
@@ -1097,10 +1115,48 @@ export function WorkspacePanel({
overscan: 10,
directDomUpdates: true,
});
// Restore the persisted scroll position once the tree has grown tall enough
// to actually reach it. The tree loads asynchronously layer by layer: the
// first render usually has only the top-level rows, so scrolling then would
// clamp the saved offset to the (tiny) current scrollable range and lose the
// position forever. Keep a pending target and retry on every tree change
// until the tree's total height exceeds it, then scroll once.
const pendingScrollRestoreRef = useRef<number | null>(null);
// Reset the pending restore whenever the view mode changes: the saved offset
// belongs to the previous mode's tree, and re-applying it to a shorter list
// (e.g. files -> changed) scrolls past the end and leaves a blank band at
// the top. Each mode restores its own offset on its own data.
const previousViewModeRef = useRef(viewMode);
if (previousViewModeRef.current !== viewMode) {
previousViewModeRef.current = viewMode;
pendingScrollRestoreRef.current = null;
}
useEffect(() => {
if (!open || treeRows.length === 0) return;
if (pendingScrollRestoreRef.current == null) {
const saved = readWorkspaceTreeMemory(workspaceMemoryKey)?.scrollTop;
if (saved == null || !Number.isFinite(saved) || saved <= 0) return;
pendingScrollRestoreRef.current = saved;
}
const target = pendingScrollRestoreRef.current;
// A saved offset is only reachable once the tree's total height exceeds
// the offset by at least one viewport: the maximum scroll position is
// totalSize - viewportHeight. Using totalSize < target would clear the
// pending restore once content just barely exceeds the offset, but before
// it provides enough scrollable space — the browser then clamps the scroll
// and the deep-scroll restore is silently lost. Use the virtualizer's own
// viewport rect (reliable after remount) rather than reading clientHeight
// at restore time, which can be 0 while the tree is still settling.
const viewportHeight = virtualizer.scrollRect?.height ?? treeRef.current?.clientHeight ?? 0;
if (virtualizer.getTotalSize() - viewportHeight < target) return; // not enough scrollable space yet
virtualizer.scrollToOffset(target, { align: "start" });
pendingScrollRestoreRef.current = null;
}, [open, treeRows.length, virtualizer.getTotalSize(), virtualizer.scrollRect?.height, workspaceMemoryKey, virtualizer]);
const virtualTreeItems = virtualizer.getVirtualItems();
const compactProbePaths = virtualTreeItems
.map((item) => treeRows[item.index])
.filter((row): row is TreeRow => Boolean(row?.entry.isDir && entriesByDir[row.path] === undefined))
.filter((row): row is WorkspaceTreeRowData => Boolean(row?.entry.isDir && entriesByDir[row.path] === undefined))
.map((row) => row.path);
const compactProbeKey = compactProbePaths.join("\u0000");
@@ -1265,6 +1321,7 @@ export function WorkspacePanel({
railWidth: WORKSPACE_TREE_RAIL_WIDTH,
treeMinWidth: WORKSPACE_TREE_MIN_WIDTH,
previewMinWidth: WORKSPACE_PREVIEW_MIN_WIDTH,
treeOnRight: true,
});
liveResize.schedule(nextWidth);
};
@@ -1291,7 +1348,7 @@ export function WorkspacePanel({
(event: KeyboardEvent<HTMLButtonElement>) => {
if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
event.preventDefault();
setSavedTreeWidth(effectiveTreeWidth + (event.key === "ArrowRight" ? 16 : -16));
setSavedTreeWidth(effectiveTreeWidth + (event.key === "ArrowLeft" ? 16 : -16));
} else if (event.key === "Home") {
event.preventDefault();
setSavedTreeWidth(WORKSPACE_TREE_MIN_WIDTH);
@@ -1407,96 +1464,14 @@ export function WorkspacePanel({
}
};
const renderNormalRow = (row: TreeRow) => {
const { path, depth, entry, isOpen, active, compactPaths = [path], displayName = entry.name } = row;
return (
<button
key={path}
className={`workspace-tree__row${active ? " workspace-tree__row--active" : ""}`}
data-workspace-path={path}
draggable
onDragStart={(event) => startTreeDrag(event, path, entry.isDir)}
onClick={() => {
if (entry.isDir) {
toggleDir(path, compactPaths);
} else {
if (selectedPath === path) {
setSelectedFilePath(null);
} else {
selectFile(path);
}
}
}}
onContextMenu={(event) => openTreeMenu(event, path, entry.isDir)}
style={{ paddingLeft: 8 + depth * 14 }}
>
{depth > 0 && (
<span className="workspace-tree__guides" aria-hidden="true">
{Array.from({ length: depth }, (_, index) => (
<span
className="workspace-tree__guide"
key={index}
style={{ left: 14 + index * 14 }}
/>
))}
</span>
)}
{entry.isDir ? (
<ChevronRight
size={13}
className={`workspace-tree__chev ${isOpen ? "workspace-tree__chev--open" : ""}`}
style={{
transition: "transform 0.15s ease",
transform: isOpen ? "rotate(90deg)" : "rotate(0deg)",
}}
/>
) : (
<span className="workspace-tree__chev" />
)}
{entry.isDir ? (
<Folder size={14} className="workspace-tree__icon workspace-tree__icon--dir" />
) : (
<WorkspaceFileIcon fileName={entry.name} />
)}
<span className="workspace-tree__name">{displayName}</span>
</button>
);
};
const renderSearchRow = (row: TreeRow) => {
const { path, entry, active } = row;
const dir = parentPath(path);
return (
<button
key={path}
className={`workspace-tree__row workspace-tree__row--search${active ? " workspace-tree__row--active" : ""}`}
data-workspace-path={path}
draggable
onDragStart={(event) => startTreeDrag(event, path, entry.isDir)}
onClick={() => {
if (entry.isDir) {
toggleDir(path);
} else {
if (selectedPath === path) {
setSelectedFilePath(null);
} else {
selectFile(path);
}
}
}}
onContextMenu={(event) => openTreeMenu(event, path, entry.isDir)}
>
{entry.isDir ? (
<Folder size={14} className="workspace-tree__icon workspace-tree__icon--dir" />
) : (
<WorkspaceFileIcon fileName={entry.name} />
)}
<span className="workspace-tree__result">
<span className="workspace-tree__result-name">{basename(path)}</span>
{dir && <span className="workspace-tree__result-dir">{dir}</span>}
</span>
</button>
);
const activateTreeRow = (row: WorkspaceTreeRowData) => {
if (row.entry.isDir) {
toggleDir(row.path, row.compactPaths ?? [row.path]);
} else if (selectedPath === row.path) {
setSelectedFilePath(null);
} else {
selectFile(row.path);
}
};
const isMarkdown = selectedPath?.toLowerCase().endsWith(".md") ?? false;
@@ -1511,6 +1486,18 @@ export function WorkspacePanel({
!preview.binary &&
!isMarkdown,
);
// The preview body must keep its flex-column layout while a code file is
// loading. Switching it to the padded block layout mid-load and back again
// makes WebKit leave stale gaps between rows once the viewer mounts.
const codePreviewLayoutActive = Boolean(
selectedFilePath &&
!changedMode &&
!isMarkdown &&
!previewErr &&
!preview?.err &&
!preview?.kind &&
!preview?.binary,
);
const openCodeSearch = () => {
if (!codePreviewActive || !selectedPath) return;
setCodeSearchRequestPath(selectedPath);
@@ -1556,7 +1543,8 @@ export function WorkspacePanel({
<Tooltip label={selectedPath ?? undefined}>
<span className="workspace-current-file__name">{previewTitle}</span>
</Tooltip>
{previewSubtitle && <span className="workspace-current-file__path">{previewSubtitle}</span>}
<WorkspacePathBreadcrumbs crumbs={previewSubtitleCrumbs} />
<WorkspacePathBreadcrumbs crumbs={previewCrumbs} />
</div>
<Tooltip label={t("workspace.recentFiles")}>
<button
@@ -1591,6 +1579,19 @@ export function WorkspacePanel({
{maximized ? <Minimize2 size={15} /> : <Maximize2 size={15} />}
</button>
</Tooltip>
{previewVisible && !changedMode && (
<Tooltip label={actualTreeVisible ? t("workspace.hideTree") : t("workspace.showTree")}>
<button
className={`workspace-iconbtn${actualTreeVisible ? " workspace-iconbtn--on" : ""}`}
type="button"
aria-label={actualTreeVisible ? t("workspace.hideTree") : t("workspace.showTree")}
aria-pressed={actualTreeVisible}
onClick={toggleTreeRail}
>
<FolderTree size={15} />
</button>
</Tooltip>
)}
{selectedPath && (
<Tooltip label={t("workspace.closePreview")}>
<button className="workspace-iconbtn" onClick={closePreviewArea}>
@@ -1672,7 +1673,7 @@ export function WorkspacePanel({
</div>
<div
className={`workspace-preview__body${selectedPath && !changedMode && !isMarkdown && !previewErr && !preview?.err && !preview?.kind && !preview?.binary ? " workspace-preview__body--code" : ""}`}
className={`workspace-preview__body${codePreviewLayoutActive ? " workspace-preview__body--code" : ""}`}
ref={previewBodyRef}
onContextMenu={openSelectionMenu}
onMouseUp={showSelectionToolbar}
@@ -2022,22 +2023,6 @@ export function WorkspacePanel({
</div>
</section>}
{showTreeRail && (
<section className="workspace-tree-rail" aria-label={actualTreeVisible ? t("workspace.hideTree") : t("workspace.showTree")}>
<Tooltip label={actualTreeVisible ? t("workspace.hideTree") : t("workspace.showTree")} side="right">
<button
className={`workspace-tree-reveal workspace-iconbtn${actualTreeVisible ? " workspace-iconbtn--on" : ""}`}
type="button"
aria-label={actualTreeVisible ? t("workspace.hideTree") : t("workspace.showTree")}
aria-pressed={actualTreeVisible}
onClick={toggleTreeRail}
>
<FolderTree size={15} />
</button>
</Tooltip>
</section>
)}
{actualTreeVisible && previewVisible && (
<button
className="workspace-tree-resizer"
@@ -2141,8 +2126,8 @@ export function WorkspacePanel({
<div
className="workspace-tree"
ref={treeRef}
onContextMenu={openTreeBlankMenu}
onScroll={onWorkspaceTreeScroll}
onContextMenu={openTreeBlankMenu}
style={{
height: "100%",
overflow: "auto",
@@ -2173,7 +2158,12 @@ export function WorkspacePanel({
width: "100%",
}}
>
{item.isSearch ? renderSearchRow(item) : renderNormalRow(item)}
<WorkspaceTreeRow
row={item}
onActivate={activateTreeRow}
onDragStart={startTreeDrag}
onContextMenu={openTreeMenu}
/>
</div>
);
})}
@@ -0,0 +1,38 @@
import { workspaceBasename as basename, workspaceParentPath as parentPath } from "../lib/workspacePanelFormat";
import { Tooltip } from "./Tooltip";
export interface WorkspacePathBreadcrumb {
label: string;
full: string;
}
export function buildWorkspacePathBreadcrumbs(cwd: string | undefined, path: string): WorkspacePathBreadcrumb[] {
if (!path) return [];
const root = basename(cwd ?? "");
const dirParts = parentPath(path).split("/").filter(Boolean);
const dirCrumbs: WorkspacePathBreadcrumb[] = [];
let currentPath = "";
for (const part of dirParts) {
currentPath += `${currentPath ? "/" : ""}${part}`;
dirCrumbs.push({ label: part, full: `${cwd ?? ""}/${currentPath}` });
}
const crumbs = [{ label: root, full: cwd ?? "" }];
if (dirParts.length > 0 && dirParts[0] !== root) crumbs.push(...dirCrumbs);
return crumbs;
}
export function WorkspacePathBreadcrumbs({ crumbs }: { crumbs: WorkspacePathBreadcrumb[] }) {
if (crumbs.length === 0) return null;
return (
<span className="workspace-current-file__path">
{crumbs.map((crumb, index) => (
<span key={index} className="workspace-current-file__crumb">
{index > 0 && <span className="workspace-current-file__crumb-sep" aria-hidden="true">›</span>}
<Tooltip label={crumb.full}>
<span>{crumb.label}</span>
</Tooltip>
</span>
))}
</span>
);
}
@@ -0,0 +1,96 @@
import type { DragEvent, MouseEvent } from "react";
import { ChevronRight, Folder } from "lucide-react";
import type { DirEntry } from "../lib/types";
import { workspaceBasename as basename, workspaceParentPath as parentPath } from "../lib/workspacePanelFormat";
import { WorkspaceFileIcon } from "./WorkspaceFileIcon";
export interface WorkspaceTreeRowData {
key: string;
path: string;
depth: number;
entry: DirEntry;
active: boolean;
isOpen?: boolean;
isSearch?: boolean;
compactPaths?: string[];
displayName?: string;
}
export function WorkspaceTreeRow({
row,
onActivate,
onDragStart,
onContextMenu,
}: {
row: WorkspaceTreeRowData;
onActivate: (row: WorkspaceTreeRowData) => void;
onDragStart: (event: DragEvent<HTMLElement>, path: string, isDir: boolean) => void;
onContextMenu: (event: MouseEvent<HTMLElement>, path: string, isDir: boolean) => void;
}) {
const { path, depth, entry, isOpen, active, displayName = entry.name } = row;
if (row.isSearch) {
const dir = parentPath(path);
return (
<button
className={`workspace-tree__row workspace-tree__row--search${active ? " workspace-tree__row--active" : ""}`}
data-workspace-path={path}
draggable
onDragStart={(event) => onDragStart(event, path, entry.isDir)}
onClick={() => onActivate(row)}
onContextMenu={(event) => onContextMenu(event, path, entry.isDir)}
>
{entry.isDir ? (
<Folder size={14} className="workspace-tree__icon workspace-tree__icon--dir" />
) : (
<WorkspaceFileIcon fileName={entry.name} />
)}
<span className="workspace-tree__result">
<span className="workspace-tree__result-name">{basename(path)}</span>
{dir && <span className="workspace-tree__result-dir">{dir}</span>}
</span>
</button>
);
}
return (
<button
className={`workspace-tree__row${active ? " workspace-tree__row--active" : ""}`}
data-workspace-path={path}
draggable
onDragStart={(event) => onDragStart(event, path, entry.isDir)}
onClick={() => onActivate(row)}
onContextMenu={(event) => onContextMenu(event, path, entry.isDir)}
style={{ paddingLeft: 8 + depth * 14 }}
>
{depth > 0 && (
<span className="workspace-tree__guides" aria-hidden="true">
{Array.from({ length: depth }, (_, index) => (
<span
className="workspace-tree__guide"
key={index}
style={{ left: 14 + index * 14 }}
/>
))}
</span>
)}
{entry.isDir ? (
<ChevronRight
size={13}
className={`workspace-tree__chev ${isOpen ? "workspace-tree__chev--open" : ""}`}
style={{
transition: "transform 0.15s ease",
transform: isOpen ? "rotate(90deg)" : "rotate(0deg)",
}}
/>
) : (
<span className="workspace-tree__chev" />
)}
{entry.isDir ? (
<Folder size={14} className="workspace-tree__icon workspace-tree__icon--dir" />
) : (
<WorkspaceFileIcon fileName={entry.name} />
)}
<span className="workspace-tree__name">{displayName}</span>
</button>
);
}
@@ -346,6 +346,28 @@ export default function LineNumberCode({
directDomUpdates: true,
});
// The virtualizer writes positioning straight onto DOM nodes (container
// height, scroll offset, per-row transforms). When a large (virtual) file
// stays mounted via SWR and is replaced by a small (non-virtual) file, React
// reuses those nodes; clear the direct DOM writes so the non-virtual rows
// lay out fresh instead of keeping stale offsets and gaps.
useEffect(() => {
if (isVirtual) return;
const scrollEl = scrollRef.current;
if (!scrollEl) return;
scrollEl.scrollTop = 0;
const wrap = scrollEl.querySelector<HTMLElement>(".code-lines-wrap");
if (!wrap) return;
wrap.style.removeProperty("height");
wrap.querySelectorAll<HTMLElement>("[data-line-index]").forEach((row) => {
row.style.removeProperty("transform");
row.style.removeProperty("position");
row.style.removeProperty("top");
row.style.removeProperty("left");
row.style.removeProperty("width");
});
}, [isVirtual]);
const scrollToLine = useCallback(
(index: number) => {
if (!scrollRef.current) return;
@@ -407,6 +429,7 @@ export default function LineNumberCode({
key={index}
data-line-index={index}
className={`code-line-row${isCurrent ? " code-line-row--current" : ""}${isDimmed ? " code-line-row--dim" : ""}`}
style={{ transform: "none" }}
>
{showLineNumbers !== false && (
<span
@@ -615,6 +638,12 @@ export default function LineNumberCode({
top: 0,
left: 0,
width: "100%",
// directDomUpdates writes the transform straight onto the
// DOM, but right after switching a non-virtual view to a
// virtual one the virtualizer may emit before its element
// cache is populated; rendering the offset here keeps the
// rows positioned (no stack-up) until then.
transform: `translate3d(0, ${row.start}px, 0)`,
}}
>
{renderRow(row.index)}
@@ -622,7 +651,7 @@ export default function LineNumberCode({
))}
</div>
) : (
<div className="code-lines-wrap">
<div className="code-lines-wrap" style={{ height: undefined }}>
{lines.map((_, index) => renderRow(index))}
</div>
)}
@@ -29,6 +29,10 @@ export function useWorkspaceTreeScrollPersistence<T extends HTMLElement>({
}, [memoryKey, open, scrollRef]);
return useCallback((event: UIEvent<T>) => {
// Only user-initiated scrolling is persisted: tree rebuilds reset the
// container to scrollTop 0 via script (isTrusted=false), and persisting
// that 0 would clobber the saved offset so restoration jumps back to top.
if (!event.nativeEvent.isTrusted) return;
rememberWorkspaceTreeScroll(memoryKey, event.currentTarget.scrollTop);
}, [memoryKey]);
}
@@ -112,6 +112,7 @@ export function workspaceSplitTreeWidthFromPointer({
railWidth = 0,
treeMinWidth,
previewMinWidth,
treeOnRight = false,
}: {
clientX: number;
panelLeft: number;
@@ -119,7 +120,19 @@ export function workspaceSplitTreeWidthFromPointer({
railWidth?: number;
treeMinWidth: number;
previewMinWidth: number;
treeOnRight?: boolean;
}): number {
if (treeOnRight && typeof panelWidth === "number") {
// Tree width is the distance from the pointer to the panel's right edge.
// The rail still occupies its own column on the opposite side.
return clampWorkspaceSplitTreeWidth({
width: panelLeft + panelWidth - clientX,
panelWidth,
railWidth,
treeMinWidth,
previewMinWidth,
});
}
return clampWorkspaceSplitTreeWidth({
width: clientX - panelLeft - railWidth,
panelWidth,
@@ -12,6 +12,7 @@ export interface WorkspaceTreeMemorySnapshot {
scrollTop: number;
dockTreeWidth: number | null;
dockPreviewWidth: number | null;
recentPaths: string[];
}
type PersistedWorkspaceState = Omit<WorkspaceTreeMemorySnapshot, "openDirs" | "visitId"> & {
@@ -26,6 +27,7 @@ interface PersistedWorkspaceEnvelope {
const STORAGE_KEY = "reasonix.workspaceState.v2";
const MAX_PERSISTED_PROJECTS = 50;
const MAX_RECENT_PATHS = 10;
const workspaceTreeMemory = new Map<string, WorkspaceTreeMemorySnapshot>();
let storageHydrated = false;
let activeWorkspaceTreeKey = "";
@@ -42,6 +44,7 @@ function defaultSnapshot(visitId = 0): WorkspaceTreeMemorySnapshot {
scrollTop: 0,
dockTreeWidth: null,
dockPreviewWidth: null,
recentPaths: [],
};
}
@@ -80,6 +83,9 @@ function hydrateWorkspaceTreeMemory(): void {
dockPreviewWidth: typeof state.dockPreviewWidth === "number" && Number.isFinite(state.dockPreviewWidth) && state.dockPreviewWidth > 0
? state.dockPreviewWidth
: null,
recentPaths: Array.isArray(state.recentPaths)
? state.recentPaths.filter((path): path is string => typeof path === "string").slice(0, MAX_RECENT_PATHS)
: [],
});
}
} catch {
@@ -103,6 +109,7 @@ function persistWorkspaceTreeMemory(recentKey: string): void {
scrollTop: snapshot.scrollTop,
dockTreeWidth: snapshot.dockTreeWidth,
dockPreviewWidth: snapshot.dockPreviewWidth,
recentPaths: snapshot.recentPaths,
updatedAt: key === recentKey ? now : 0,
},
}))
+29 -14
View File
@@ -56,20 +56,25 @@ function clampStoredSidebarWidth(width: number): number {
return Math.min(SIDEBAR_MAX_WIDTH, Math.max(CREATION_SIDEBAR_MIN_WIDTH, Math.round(width)));
}
export function clampRightDockPreviewWidth(width: number): number {
return Math.min(RIGHT_DOCK_MAX_WIDTH, Math.max(RIGHT_DOCK_PREVIEW_MIN_WIDTH, Math.round(width)));
export function clampRightDockPreviewWidth(width: number, maxWidth = RIGHT_DOCK_MAX_WIDTH): number {
// Cap at maxWidth (which may be below the default maximum when the viewport
// is narrow) while never dropping below the applicable minimum.
return Math.min(Math.max(maxWidth, RIGHT_DOCK_PREVIEW_MIN_WIDTH), Math.max(RIGHT_DOCK_PREVIEW_MIN_WIDTH, Math.round(width)));
}
export function clampRightDockTreeWidth(width: number): number {
return Math.min(RIGHT_DOCK_TREE_MAX_WIDTH, Math.max(RIGHT_DOCK_TREE_MIN_WIDTH, Math.round(width)));
export function clampRightDockTreeWidth(width: number, maxWidth = RIGHT_DOCK_TREE_MAX_WIDTH): number {
return Math.min(Math.max(maxWidth, RIGHT_DOCK_TREE_MIN_WIDTH), Math.max(RIGHT_DOCK_TREE_MIN_WIDTH, Math.round(width)));
}
export function clampCreationRightDockTreeWidth(width: number): number {
return Math.min(RIGHT_DOCK_TREE_MAX_WIDTH, Math.max(CREATION_RIGHT_DOCK_TREE_MIN_WIDTH, Math.round(width)));
export function clampCreationRightDockTreeWidth(width: number, maxWidth = RIGHT_DOCK_TREE_MAX_WIDTH): number {
return Math.min(Math.max(maxWidth, CREATION_RIGHT_DOCK_TREE_MIN_WIDTH), Math.max(CREATION_RIGHT_DOCK_TREE_MIN_WIDTH, Math.round(width)));
}
function clampStoredRightDockTreeWidth(width: number): number {
return Math.min(RIGHT_DOCK_TREE_MAX_WIDTH, Math.max(CREATION_RIGHT_DOCK_TREE_MIN_WIDTH, Math.round(width)));
// Stored widths are validated again against the live viewport at load time
// (resolveWorkspacePanelWidth clamps to the chat pane's 400px floor), so
// persistence only guards the sane lower bound and integer form.
return Math.max(CREATION_RIGHT_DOCK_TREE_MIN_WIDTH, Math.round(width));
}
export function defaultSidebarWidth(): number {
@@ -202,21 +207,31 @@ export function clampTerminalHeight(height: number, viewportHeight: number): num
return Math.min(max, Math.max(TERMINAL_MIN_HEIGHT, Math.round(height)));
}
function loadWorkspacePanelOpen(): boolean {
function workspacePanelOpenStorageKey(workspaceRoot: string): string {
return workspaceRoot ? `${WORKSPACE_PANEL_OPEN_KEY}.${workspaceRoot}` : WORKSPACE_PANEL_OPEN_KEY;
}
export function loadWorkspacePanelOpen(workspaceRoot: string): boolean {
if (typeof window === "undefined") return WORKSPACE_PANEL_DEFAULT_OPEN;
try {
const raw = window.localStorage.getItem(WORKSPACE_PANEL_OPEN_KEY);
if (raw === null) return WORKSPACE_PANEL_DEFAULT_OPEN;
return raw !== "0";
const raw = window.localStorage.getItem(workspacePanelOpenStorageKey(workspaceRoot));
if (raw !== null) return raw !== "0";
// Migration: the legacy single global key predates per-project keys.
// When a project has no stored preference yet, seed it from the old
// global value so an upgrade does not flip a user's existing choice
// (e.g. they had the dock closed; first open of any project stays closed).
const legacyRaw = window.localStorage.getItem(WORKSPACE_PANEL_OPEN_KEY);
if (legacyRaw !== null) return legacyRaw !== "0";
return WORKSPACE_PANEL_DEFAULT_OPEN;
} catch {
return WORKSPACE_PANEL_DEFAULT_OPEN;
}
}
export function saveWorkspacePanelOpen(open: boolean): void {
export function saveWorkspacePanelOpen(open: boolean, workspaceRoot = ""): void {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(WORKSPACE_PANEL_OPEN_KEY, open ? "1" : "0");
window.localStorage.setItem(workspacePanelOpenStorageKey(workspaceRoot), open ? "1" : "0");
} catch {
/* ignore storage failures */
}
@@ -250,7 +265,7 @@ export const useLayoutStore = create<LayoutState>((set) => ({
sidebarWidth: loadSidebarWidth(),
rightDockTreeWidth: loadRightDockTreeWidth(),
rightDockPreviewWidth: loadRightDockPreviewWidth(),
workspacePanelOpen: loadWorkspacePanelOpen(),
workspacePanelOpen: loadWorkspacePanelOpen(""),
workspacePanelMaximized: false,
workspacePreviewActive: false,
rightDockMode: "context",
File diff suppressed because it is too large. Load diff