返回 DeepSeek-Reasonix
App.tsx
根目录 / desktop / frontend / src / App.tsx
1 import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
2 import type { CSSProperties, KeyboardEvent, MouseEvent as ReactMouseEvent, PointerEvent as ReactPointerEvent } from "react";
3 import { ShellExpandProvider, useShellExpand } from "./lib/shellExpand";
4 import gsap from "gsap";
5 import { useGSAP } from "@gsap/react";
6 import { Flip } from "gsap/Flip";
7 import { ScrollToPlugin } from "gsap/ScrollToPlugin";
8 gsap.registerPlugin(useGSAP, Flip, ScrollToPlugin);
9 import {
10 Activity,
11 CircleHelp,
12 Command,
13 Copy as RestoreIcon,
14 Download,
15 Minus,
16 Search,
17 Server,
18 Square,
19 SquarePen,
20 PanelLeft,
21 PanelRight,
22 FileDown,
23 FileImage,
24 FileText,
25 FileJson,
26 GitBranch,
27 MessageSquare,
28 Settings as SettingsIcon,
29 Pencil,
30 RotateCw,
31 Trash2,
32 AlarmClock,
33 BarChart3,
34 Brain,
35 Cpu,
36 Palette,
37 Puzzle,
38 X,
39 TerminalSquare,
40 } from "lucide-react";
41 import { useToast } from "./lib/toast";
42 import { useGoalActionHandler } from "./lib/goalAction";
43 import { useWailsResizeFix } from "./lib/useWailsResizeFix";
44 import { asArray } from "./lib/array";
45 import { createBoundedRefreshCoordinator, sameTabMetaLists, shouldRefreshTabMetaForEvent, TAB_META_MAX_IN_FLIGHT, tabMetaFallbackDelay } from "./lib/tabMetaRefresh";
46 import { clearLegacyLangPref, normalizeLangPref, readLegacyLangPref, t, useI18n, useT, type Translator } from "./lib/i18n";
47 import { localizedNoticeText, useController, type Item, type LiveStream } from "./lib/useController";
48 import { app, onEvent, onProjectTreeChanged, onReady, onRuntimeRebuilt, onSessionRecovered, openExternal } from "./lib/bridge";
49 import { generativeMusic, isGenerativeMusicEnabled } from "./lib/generative-music";
50 import { clearAttentionChimeKeys, playAttentionChime, playSuccessChime, shouldPlayAttentionChimeForEvent } from "./lib/sound";
51 import { NoticeCard, Transcript } from "./components/Transcript";
52 import { Composer } from "./components/Composer";
53 import { TranscriptSelectionMenu } from "./components/TranscriptSelectionMenu";
54 import { TodoPanel } from "./components/TodoPanel";
55 import { ApprovalModal } from "./components/ApprovalModal";
56 import { AskCard } from "./components/AskCard";
57 import { ExtensionFormDialog } from "./components/ExtensionFormDialog";
58 import { ClearContextCard } from "./components/ClearContextCard";
59 import { RuntimeDecisionCard } from "./components/RuntimeDecisionCard";
60 import { decisionSurfaceMockFromInput, type DecisionSurfaceKind as MockDecisionSurfaceKind } from "./lib/decisionSurfaceMock";
61
62 const UndoRewindBanner = lazy(() => import("./components/UndoRewindBanner").then((module) => ({ default: module.UndoRewindBanner })));
63
64 /** Footer decision surface kinds. Runtime blockers are explicit recovery choices. */
65 type DecisionSurfaceKind = MockDecisionSurfaceKind | "extension_form";
66 import { StatusBar } from "./components/StatusBar";
67 import { RemoteHostKeyDialog } from "./components/RemoteHostKeyDialog";
68 import { RemoteSecretDialog } from "./components/RemoteSecretDialog";
69 import { onRemoteStatus, onRemoteForwards, onRemoteServer } from "./lib/bridge";
70 import { RemoteConnectionTimeoutError, useRemoteStore, waitForRemoteConnection } from "./store/remote";
71 import { RemoteWorkspaceLaunchGate, resolveRemoteWorkspace } from "./lib/remoteWorkspace";
72 import { CommandPalette, type PaletteItem } from "./components/CommandPalette";
73 import { UpdateBanner } from "./components/UpdateBanner";
74 import { UpdaterProvider } from "./lib/useUpdater";
75 import { ContextPanel } from "./components/ContextPanel";
76 import { Tooltip } from "./components/Tooltip";
77 import { StartupSplash } from "./components/StartupSplash";
78 import { OnboardingOverlay } from "./components/OnboardingOverlay";
79 import { dismissOnboarding, shouldOpenOnboarding } from "./lib/onboarding";
80 import { AppChrome } from "./components/AppChrome";
81 import { ShortcutsCheatsheet } from "./components/ShortcutsCheatsheet";
82 import { ProjectTree } from "./components/ProjectTree";
83 import { WorktreeBadge } from "./components/WorktreeBadge";
84 import { HeartbeatPanel } from "./custom/features/heartbeat/HeartbeatPanel";
85 import "./custom/features/heartbeat/heartbeat.css";
86 import { CopyButton } from "./components/CopyButton";
87 import { ExternalOpener } from "./components/ExternalOpener";
88 import { startTerminalEventBridge } from "./lib/terminalEvents";
89 import { applyTerminalThemePreference } from "./lib/terminalTheme";
90 import { formatTerminalOutputForComposer } from "./lib/terminalOutput";
91 import { useTerminalStore } from "./store/terminal";
92 import { parseTodos } from "./lib/tools";
93 import {
94 dismissedTodoKeyForScope,
95 resolveTodoPanelTodos,
96 scopedTodoBatchKey,
97 scopedTodoDismissalKey,
98 shouldShowTodoPanel,
99 todoBatchKey,
100 todoDismissalKey,
101 todoPanelScope,
102 } from "./lib/todoVisibility";
103 import {
104 type BotConnectionView,
105 type BotRuntimeStatusView,
106 type BotSettingsView,
107 type ActiveWorkView,
108 type BackgroundRuntimeView,
109 type CollaborationMode,
110 type ComposerInsertRequest,
111 type DesktopStartupSettingsView,
112 type Mode,
113 modeHasPlan,
114 type ProjectNode,
115 type RewindResultView,
116 type RemoteHostView,
117 type SessionMeta,
118 type SettingsView,
119 type TabMeta,
120 type TokenMode,
121 type ToolApprovalMode,
122 type WorkspaceConflictView,
123 } from "./lib/types";
124 import type { InvocationMetadataMap, StructuredInvocationSubmit } from "./lib/invocationDisplay";
125 import { formatSelectionReference, type SelectedTextInsertRequest } from "./lib/selectedTextContext";
126 import { workspaceTreeVisitId } from "./lib/workspaceTreeMemory";
127 import { resolveTaskMonitorSession } from "./lib/taskMonitorNavigation";
128 import {
129 composerProfileFromMeta,
130 composerProfileFromTab,
131 composerProfileMode,
132 composerProfileWithMode,
133 controllerComposerProfileCollaborationMode,
134 defaultComposerProfile,
135 displayedComposerProfileCollaborationMode,
136 hydrateComposerProfileFromMeta,
137 hydrateComposerProfilesFromTabs,
138 patchComposerProfile,
139 pruneUserPlanModeIntents,
140 resolvePlanRestoreTabId,
141 shouldRestoreUserPlanModeForProfile,
142 updateUserPlanModeIntent,
143 type ComposerProfile,
144 type ComposerProfileField,
145 type UserPlanModeIntents,
146 } from "./lib/composerProfile";
147 import {
148 restorableToolApprovalMode,
149 toggleYoloToolApprovalMode,
150 type RestorableToolApprovalMode,
151 } from "./lib/toolApprovalMode";
152 import {
153 CREATION_RIGHT_DOCK_MIN_RENDER_WIDTH,
154 CREATION_RIGHT_DOCK_TREE_MIN_WIDTH,
155 CREATION_SIDEBAR_MIN_WIDTH,
156 RIGHT_DOCK_MAX_WIDTH,
157 RIGHT_DOCK_MIN_RENDER_WIDTH,
158 RIGHT_DOCK_PREVIEW_DEFAULT_WIDTH,
159 RIGHT_DOCK_PREVIEW_MIN_WIDTH,
160 RIGHT_DOCK_TREE_MAX_WIDTH,
161 RIGHT_DOCK_TREE_MIN_WIDTH,
162 type RightDockMode,
163 SIDEBAR_MAX_WIDTH,
164 SIDEBAR_MIN_WIDTH,
165 TERMINAL_DEFAULT_HEIGHT,
166 TERMINAL_MIN_HEIGHT,
167 applyLayoutStyleDefaults,
168 clampCreationRightDockTreeWidth,
169 clampCreationSidebarWidth,
170 clampRightDockPreviewWidth,
171 clampRightDockTreeWidth,
172 clampSidebarWidth,
173 clampTerminalHeight,
174 defaultCreationRightDockTreeWidth,
175 defaultCreationSidebarWidth,
176 defaultRightDockTreeWidth,
177 defaultSidebarWidth,
178 saveRightDockPreviewWidth,
179 saveRightDockTreeWidth,
180 saveSidebarCollapsed,
181 saveSidebarWidth,
182 saveTerminalHeight,
183 saveTerminalPanelOpen,
184 terminalMaxHeight,
185 saveWorkspacePanelOpen,
186 useLayoutStore,
187 } from "./store/layout";
188 import { useOverlayStore } from "./store/overlays";
189 import { hydrateDisplayMode } from "./lib/displayMode";
190 import { DEFAULT_STATUS_BAR_ITEMS, normalizeStatusBarItems, type StatusBarItemId } from "./lib/statusBarItems";
191 import { paletteSessionDisplayTitle, paletteSessionHint, paletteSessionKeywords, sessionActivityTime } from "./lib/session";
192 import { enqueueNavigationRequest, type PendingNavigationRequest } from "./lib/openTopicCoalescing";
193 import {
194 applyTheme,
195 clearLegacyThemePreference,
196 getTheme,
197 getThemeStyle,
198 isThemeStyle,
199 normalizeThemePreference,
200 normalizeThemeStyleForTheme,
201 readLegacyThemePreference,
202 type Theme,
203 } from "./lib/theme";
204 import { applyConversationWidth } from "./lib/conversationWidth";
205 import { applyConfiguredBaseAppearance, applyThemePack, applyThemeScene, clearThemePack } from "./lib/themePack";
206 import { ThemeBackground } from "./components/ThemeBackground";
207 import { applyTextSize, DEFAULT_TEXT_SIZE, getTextSize, nextTextSize } from "./lib/textSize";
208 import { useViewportHeightVar, useWindowStatePersistence } from "./lib/windowState";
209 import { availableWorkspacePanelWidth, resolveLiveWorkspacePanelWidth, resolveWorkspacePanelWidth, workspacePanelAriaMinWidth } from "./lib/workspaceLayout";
210 import { createRafResizeUpdater } from "./lib/resizeDrag";
211 import { useGlobalShortcut } from "./lib/keyboardShortcuts";
212 import { topicShortcutIndexFromEvent, useTopicShortcuts, type TopicShortcutEntry } from "./lib/topicShortcuts";
213 import { composerDraftKeyForTab } from "./lib/composerDraftKey";
214 import { continueDelivery } from "./lib/deliveryContinue";
215 import { activateGoalAndSubmitOnTab } from "./lib/goalSubmit";
216 import logoWordmark from "./assets/logo-wordmark.svg";
217
218 function noticePreviewMockEnabled(): boolean {
219 const value = browserMockScenarioParam();
220 return value === "notice" || value === "notices" || value === "notice-preview";
221 }
222
223 function runtimeProfileShortKey(mode: TokenMode) {
224 return mode === "economy"
225 ? "composer.runtimeProfileEconomyShort" as const
226 : mode === "delivery"
227 ? "composer.runtimeProfileDeliveryShort" as const
228 : "composer.runtimeProfileBalancedShort" as const;
229 }
230
231 function noticePreviewItems(): Item[] {
232 const notice = (index: number, level: "info" | "warn", text: string, detail: string, code?: string): Item => ({
233 kind: "notice",
234 id: `notice-preview-${index}`,
235 level,
236 text: localizedNoticeText(text, code),
237 detail,
238 });
239 return [
240 {
241 kind: "notice",
242 id: "notice-preview-delivery",
243 level: "info",
244 variant: "delivery",
245 title: t("notice.deliveryIncompleteTitle"),
246 text: t("notice.deliveryIncompleteBody"),
247 detail: "final-answer readiness failed 3 times: missing verification, review_report, and complete_step receipts",
248 action: "continue_delivery",
249 },
250 notice(1, "info", "No visible answer was produced; asking the assistant to respond again.", "empty final answer blocked: qwen3.7-plus returned no visible answer text (finish=stop, reasoning=2314 chars); retrying", "empty_final"),
251 notice(2, "info", "The assistant answered before taking action; asking it to use the required tools.", "executor handoff: assistant produced a proposal before running required repository commands; nudged to execute", "executor_handoff"),
252 notice(3, "info", "Tool round limit reached; asking the assistant to summarize progress.", "tool budget reached after 128 tool calls; requesting a progress summary before continuing", "tool_budget"),
253 notice(4, "info", "The assistant is stuck retrying a blocked action; asking it to change approach.", "loop guard: repeated command failure matched the same stderr signature across 3 attempts", "loop_guard"),
254 notice(5, "info", "Context is getting large; preserving cache until cleanup is needed.", "context window 82% full; deferred cleanup to preserve reusable prompt cache"),
255 notice(6, "info", "Context cleanup skipped for now.", "cleanup skipped: recent turn included unresolved user approval state"),
256 notice(7, "info", "Automatic context cleanup paused because the context window is too small.", "configured compact threshold exceeds current model context window; auto cleanup paused for this model"),
257 notice(8, "info", "Context was compacted without a generated summary.", "compaction completed after upstream summary generation returned empty content; retained transcript checkpoint"),
258 notice(9, "info", "Goal is not ready to complete yet; continuing the remaining work.", "goal completion check found pending validation: desktop/frontend typecheck"),
259 notice(13, "info", "Goal still has unfinished task state; continuing the remaining work.", "active goal has open task state: implement preview, verify browser, report result"),
260 notice(14, "warn", "AutoResearch status update failed.", "autoresearch task completion update failed: write .reasonix/autoresearch/task-42/state/task_spec.json: permission denied"),
261 notice(15, "warn", "AutoResearch task marked blocked.", "autoresearch task blocked: task-42\nreason: missing accepted verification evidence after three turns"),
262 notice(16, "warn", "background export failed: needs attention", "background export failed: session archive upload returned 503 after 3 retries"),
263 notice(17, "warn", "Job artifact migration failed.", "artifact migration failed for job job_123: checksum mismatch while moving output.zip"),
264 notice(18, "warn", "Background job teardown timed out.", "job job_123 did not stop within 10s; process is still marked running by the supervisor"),
265 notice(19, "warn", "Some plan-mode tool settings were ignored.", "plan-mode tool settings ignored: unsupported tool allowlist entry \"browser.screenshot\""),
266 notice(20, "warn", "Some plan-mode command settings were ignored.", "plan-mode command settings ignored: invalid read-only prefix \"npm && test\""),
267 notice(21, "warn", "Config migration did not complete.", "config migration failed at providers.defaultModel: unknown provider reference \"old/deepseek\""),
268 notice(22, "warn", "Selected model is missing its API key.", "selected model deepseek/deepseek-v4-pro requires DEEPSEEK_API_KEY, but no key is configured"),
269 notice(23, "warn", "An MCP server failed to start.", "mcp server \"github\" failed to start: command not found: mcp-server-github"),
270 notice(24, "warn", "Some MCP servers failed to start; run /mcp for details.", "mcp startup failures: github(command not found), linear(authentication expired)"),
271 notice(25, "warn", "Guardian was disabled because its model was not found.", "guardian model \"glm-5-guard\" is not present in the configured provider catalog"),
272 notice(26, "warn", "Guardian was disabled because it could not start.", "guardian startup failed: provider returned 401 unauthorized"),
273 ];
274 }
275
276 function NoticePreviewPanel() {
277 return (
278 <div
279 style={{
280 flex: "1 1 auto",
281 minHeight: 0,
282 overflow: "auto",
283 padding: "44px 24px 128px",
284 }}
285 >
286 <div style={{ maxWidth: 920, margin: "0 auto" }}>
287 {noticePreviewItems().map((item) => {
288 if (item.kind !== "notice") return null;
289 return <NoticeCard key={item.id} item={item} onAction={item.action ? () => undefined : undefined} />;
290 })}
291 </div>
292 </div>
293 );
294 }
295
296 const HistoryPanel = lazy(() => import("./components/HistoryPanel").then((module) => ({ default: module.HistoryPanel })));
297 const SettingsPanel = lazy(() => import("./components/SettingsPanelEntry").then((module) => ({ default: module.SettingsPanel })));
298 const RemotePanel = lazy(() => import("./components/RemotePanel").then((module) => ({ default: module.RemotePanel })));
299 const TerminalPanel = lazy(() => import("./components/TerminalPanel").then((module) => ({ default: module.TerminalPanel })));
300 const TaskMonitorPanel = lazy(() => import("./components/TaskMonitorPanel").then((module) => ({ default: module.TaskMonitorPanel })));
301 const WorkspacePanel = lazy(() => import("./components/WorkspacePanel").then((module) => ({ default: module.WorkspacePanel })));
302
303 const CHAT_MIN_WIDTH = 400;
304 const CHAT_COMFORT_MIN_WIDTH = 560;
305 const WORKSPACE_RESIZER_WIDTH = 8;
306
307 function stripGoalResearchFlags(arg: string): string {
308 const parts = arg.trim().split(/\s+/).filter(Boolean);
309 while (parts.length > 0) {
310 const flag = parts[0].toLowerCase();
311 if (flag !== "--research" && flag !== "--auto-research" && flag !== "--deep" && flag !== "--simple" && flag !== "--no-research") break;
312 parts.shift();
313 }
314 return parts.join(" ");
315 }
316
317 function hasGoalResearchFlag(arg: string): boolean {
318 const first = arg.trim().split(/\s+/, 1)[0]?.toLowerCase();
319 return first === "--research" || first === "--auto-research" || first === "--deep" || first === "--simple" || first === "--no-research";
320 }
321
322 function isThemeMode(value: string): value is Theme {
323 return value === "auto" || value === "light" || value === "dark";
324 }
325
326 type DesktopLayoutStyle = "classic" | "workbench" | "creation";
327
328 function normalizeDesktopLayoutStyle(style: string | undefined): DesktopLayoutStyle {
329 if (style === "workbench") return "workbench";
330 if (style === "creation") return "creation";
331 return "classic";
332 }
333 const SHOW_CONTEXT_DOCK = true;
334 const DISMISSED_TODO_STORAGE_KEY = "todoPanel:dismissedKeys";
335 const MAX_DISMISSED_TODO_KEYS = 160;
336 type HistoryScopeFilter = { scope: "global" | "project"; workspaceRoot: string };
337 type WorkspaceInsertTarget = "composer" | "planRevision";
338 type DesktopPlatform = "darwin" | "windows" | "linux";
339 const MACOS_WORKBENCH_TITLEBAR_HEIGHT = 46;
340
341 function isMacOSWorkbenchSidebarTitlebar(target: HTMLElement | null, clientY: number, platform: DesktopPlatform): boolean {
342 if (platform !== "darwin") return false;
343 const sidebar = target?.closest(".sidebar--workbench");
344 if (!(sidebar instanceof HTMLElement)) return false;
345 const offsetY = clientY - sidebar.getBoundingClientRect().top;
346 return offsetY >= 0 && offsetY < MACOS_WORKBENCH_TITLEBAR_HEIGHT;
347 }
348
349 function useWindowsMaximised(enabled: boolean): readonly [boolean, () => void] {
350 const [maximised, setMaximised] = useState(false);
351 const syncGenerationRef = useRef(0);
352
353 const syncMaximised = useCallback(() => {
354 if (!enabled) return;
355 const generation = ++syncGenerationRef.current;
356 void app.IsMainWindowMaximised()
357 .then((value) => {
358 if (generation === syncGenerationRef.current) setMaximised(value);
359 })
360 .catch(() => {
361 if (generation === syncGenerationRef.current) setMaximised(false);
362 });
363 }, [enabled]);
364
365 useEffect(() => {
366 if (!enabled) {
367 syncGenerationRef.current += 1;
368 setMaximised(false);
369 return;
370 }
371 syncMaximised();
372 window.addEventListener("resize", syncMaximised);
373 window.addEventListener("focus", syncMaximised);
374 return () => {
375 syncGenerationRef.current += 1;
376 window.removeEventListener("resize", syncMaximised);
377 window.removeEventListener("focus", syncMaximised);
378 };
379 }, [enabled, syncMaximised]);
380
381 return [maximised, syncMaximised] as const;
382 }
383
384 function WindowsWindowControls({
385 maximised,
386 syncMaximised,
387 }: {
388 maximised: boolean;
389 syncMaximised: () => void;
390 }) {
391 const toggleMaximise = useCallback(() => {
392 void app.ToggleMaximiseMainWindow()
393 .then(() => window.setTimeout(syncMaximised, 80))
394 .catch(() => undefined);
395 }, [syncMaximised]);
396
397 return (
398 <div className="windows-window-controls" aria-label="Window controls">
399 <button
400 className="windows-window-control windows-window-control--minimize"
401 type="button"
402 aria-label="Minimize window"
403 title="Minimize"
404 onClick={() => void app.MinimiseMainWindow()}
405 >
406 <Minus size={13} strokeWidth={1.9} />
407 </button>
408 <button
409 className="windows-window-control windows-window-control--maximize"
410 type="button"
411 aria-label="Maximize or restore window"
412 aria-pressed={maximised}
413 title={maximised ? "Restore" : "Maximize"}
414 onClick={toggleMaximise}
415 >
416 {maximised ? <RestoreIcon size={12} strokeWidth={1.75} /> : <Square size={11} strokeWidth={1.8} />}
417 </button>
418 <button
419 className="windows-window-control windows-window-control--close"
420 type="button"
421 aria-label="Close window"
422 title="Close"
423 onClick={() => void app.CloseMainWindow()}
424 >
425 <X size={13} strokeWidth={1.9} />
426 </button>
427 </div>
428 );
429 }
430 type HistoryViewState =
431 | { kind: "history"; source: "scope"; filter: HistoryScopeFilter; sessions: SessionMeta[] }
432 | { kind: "history"; source: "all"; sessions: SessionMeta[] }
433 | { kind: "trash"; sessions: SessionMeta[] };
434 type SidebarImPlatform = "qq" | "feishu" | "lark" | "weixin";
435 type SidebarImStatus = "connected" | "disabled" | "pending" | "error" | "disconnected";
436 type SidebarImConnection = {
437 id: string;
438 connectionId: string;
439 platform: SidebarImPlatform;
440 title: string;
441 platformLabel: string;
442 subtitle: string;
443 status: SidebarImStatus;
444 statusLabel: string;
445 remoteId: string;
446 sessionId: string;
447 sessionSource: string;
448 scope: "global" | "project";
449 workspaceRoot: string;
450 allowAll: boolean;
451 allowlistEnabled: boolean;
452 allowlistUsers: string[];
453 allowlistMatched: boolean;
454 };
455 type DesktopNavigationIntent =
456 | { kind: "topic"; scope: string; workspaceRoot: string; topicId: string; sessionPath?: string }
457 | { kind: "blank"; scope: string; workspaceRoot: string }
458 | { kind: "delivery-worktree"; workspaceRoot: string }
459 | { kind: "sidebar-im"; connection: SidebarImConnection }
460 | { kind: "resume-session"; session: SessionMeta };
461 type DesktopNavigationInput = DesktopNavigationIntent & { navigationIntentSeq: number };
462 type PendingDesktopNavigationRequest = PendingNavigationRequest<DesktopNavigationInput>;
463 type SidebarImTopicSource = {
464 platform: SidebarImPlatform;
465 label: string;
466 title: string;
467 remoteId: string;
468 connectionId: string;
469 };
470 type SidebarImConnectionDetailProps = {
471 connection: SidebarImConnection;
472 onClose: () => void;
473 onOpenSession: () => void;
474 onOpenSettings: () => void;
475 onManageAllowlist: () => void;
476 };
477
478 function loadDismissedTodoKeys(): Set<string> {
479 try {
480 const saved = window.localStorage.getItem(DISMISSED_TODO_STORAGE_KEY);
481 if (!saved) return new Set();
482 const parsed = JSON.parse(saved) as unknown;
483 if (!Array.isArray(parsed)) return new Set();
484 return new Set(parsed.filter((value): value is string => typeof value === "string" && value.length > 0));
485 } catch {
486 return new Set();
487 }
488 }
489
490 function saveDismissedTodoKeys(keys: ReadonlySet<string>): void {
491 try {
492 window.localStorage.setItem(
493 DISMISSED_TODO_STORAGE_KEY,
494 JSON.stringify(Array.from(keys).slice(-MAX_DISMISSED_TODO_KEYS)),
495 );
496 } catch {
497 /* ignore quota errors */
498 }
499 }
500
501 function isSidebarImConnection(connection: BotConnectionView): boolean {
502 return connection.provider === "feishu" || connection.provider === "weixin";
503 }
504
505 function sidebarImPlatform(connection: BotConnectionView): SidebarImPlatform {
506 if (connection.provider === "weixin") return "weixin";
507 return connection.domain === "lark" ? "lark" : "feishu";
508 }
509
510 function sidebarImPlatformLabel(platform: SidebarImPlatform, translate: Translator): string {
511 if (platform === "qq") return "QQ";
512 if (platform === "lark") return "Lark";
513 if (platform === "weixin") return translate("settings.botWeixin");
514 return translate("settings.botFeishu");
515 }
516
517 function botMappingScope(mapping: BotConnectionView["sessionMappings"][number] | null | undefined, connectionWorkspaceRoot: string): "global" | "project" {
518 if (mapping?.scope === "project") return "project";
519 if ((mapping?.workspaceRoot ?? "").trim()) return "project";
520 return connectionWorkspaceRoot.trim() ? "project" : "global";
521 }
522
523 function botMappingWorkspaceRoot(
524 mapping: BotConnectionView["sessionMappings"][number] | null | undefined,
525 connectionWorkspaceRoot: string,
526 ): string {
527 const workspaceRoot = (mapping?.workspaceRoot ?? "").trim() || connectionWorkspaceRoot.trim();
528 return botMappingScope(mapping, connectionWorkspaceRoot) === "project" ? workspaceRoot : "";
529 }
530
531 function compactRemoteId(value: string): string {
532 const trimmed = value.trim();
533 if (trimmed.length <= 28) return trimmed;
534 return `${trimmed.slice(0, 12)}…${trimmed.slice(-8)}`;
535 }
536
537 function botMappingIdentityLabel(mapping: BotConnectionView["sessionMappings"][number] | null | undefined): string {
538 const chatType = (mapping?.chatType ?? "").trim();
539 const userId = (mapping?.userId ?? "").trim();
540 const threadId = (mapping?.threadId ?? "").trim();
541 if (threadId) return compactRemoteId(threadId);
542 if ((chatType === "group" || chatType === "guild") && userId) return compactRemoteId(userId);
543 return "";
544 }
545
546 function sidebarImStatus(connection: BotConnectionView, botEnabled: boolean): SidebarImStatus {
547 if (!botEnabled || !connection.enabled) return "disabled";
548 if (connection.status === "connected") return "connected";
549 if (connection.status === "pending") return "pending";
550 if (connection.status === "error") return "error";
551 return "disconnected";
552 }
553
554 function sidebarImStatusLabel(status: SidebarImStatus, translate: Translator): string {
555 switch (status) {
556 case "connected":
557 return translate("sidebar.imConnected");
558 case "disabled":
559 return translate("sidebar.imDisabled");
560 case "pending":
561 return translate("sidebar.imPending");
562 case "error":
563 return translate("sidebar.imError");
564 default:
565 return translate("sidebar.imDisconnected");
566 }
567 }
568
569 function uniqueTrimmedValues(values: string[]): string[] {
570 return Array.from(new Set(values.map((value) => value.trim()).filter(Boolean)));
571 }
572
573 function sidebarImAllowlistUsers(bot: BotSettingsView, platform: SidebarImPlatform): string[] {
574 if (platform === "qq") return uniqueTrimmedValues(asArray(bot.allowlist.qqUsers));
575 if (platform === "weixin") return uniqueTrimmedValues(asArray(bot.allowlist.weixinUsers));
576 return uniqueTrimmedValues(asArray(bot.allowlist.feishuUsers));
577 }
578
579 function sidebarImQQAdded(qq: BotSettingsView["qq"]): boolean {
580 return Boolean(qq.enabled || qq.secretSet || qq.appId.trim());
581 }
582
583 function sidebarImQQStatus(bot: BotSettingsView, runtimeStatus: BotRuntimeStatusView | null | undefined): SidebarImStatus {
584 const appId = bot.qq.appId.trim();
585 if (!bot.enabled || !bot.qq.enabled) return "disabled";
586 if (!appId || !bot.qq.secretSet) return "disconnected";
587 if (typeof window !== "undefined" && !window.runtime) return "pending";
588 if (!runtimeStatus) return "pending";
589 const status = runtimeStatus.status.trim().toLowerCase();
590 if (runtimeStatus.running && runtimeStatus.connections > 0 && status === "running") {
591 return "connected";
592 }
593 if (status === "error" || status === "blocked" || status === "degraded") return "error";
594 if (status === "stopped") return "disconnected";
595 return "pending";
596 }
597
598 async function loadBotRuntimeStatus(): Promise<BotRuntimeStatusView | null> {
599 if (typeof window !== "undefined" && !window.runtime) return null;
600 try {
601 return await app.BotRuntimeStatus();
602 } catch (e) {
603 console.warn("bot runtime status failed", e);
604 return null;
605 }
606 }
607
608 function sidebarImQQConnection(bot: BotSettingsView, translate: Translator, runtimeStatus?: BotRuntimeStatusView | null): SidebarImConnection | null {
609 if (!sidebarImQQAdded(bot.qq)) return null;
610 const remoteId = bot.qq.appId.trim();
611 const status = sidebarImQQStatus(bot, runtimeStatus);
612 const statusLabel = sidebarImStatusLabel(status, translate);
613 const allowlistUsers = sidebarImAllowlistUsers(bot, "qq");
614 const subtitleParts = [
615 remoteId ? compactRemoteId(remoteId) : "QQ",
616 statusLabel,
617 ].filter(Boolean);
618 return {
619 id: "__qq_bot__",
620 connectionId: "__qq_bot__",
621 platform: "qq",
622 title: "QQ Bot",
623 platformLabel: "QQ",
624 subtitle: subtitleParts.join(" · "),
625 status,
626 statusLabel,
627 remoteId,
628 sessionId: "",
629 sessionSource: "",
630 scope: "global",
631 workspaceRoot: "",
632 allowAll: bot.allowlist.allowAll,
633 allowlistEnabled: bot.allowlist.enabled,
634 allowlistUsers,
635 allowlistMatched: remoteId ? allowlistUsers.includes(remoteId) : false,
636 };
637 }
638
639 function sidebarImConnectionsFromBot(
640 bot: BotSettingsView | null | undefined,
641 translate: Translator,
642 runtimeStatus?: BotRuntimeStatusView | null,
643 ): SidebarImConnection[] {
644 if (!bot) return [];
645 const qqConnection = sidebarImQQConnection(bot, translate, runtimeStatus);
646 const connectionItems: SidebarImConnection[] = [];
647 for (const connection of asArray(bot.connections)) {
648 if (!isSidebarImConnection(connection)) continue;
649 const mappings = connection.sessionMappings.filter((mapping) => mapping.sessionId.trim() || mapping.remoteId.trim());
650 const rowMappings = mappings.length > 0 ? mappings : [null];
651 rowMappings.forEach((mapping, index) => {
652 const platform = sidebarImPlatform(connection);
653 const platformLabel = sidebarImPlatformLabel(platform, translate);
654 const remoteId = mapping?.remoteId.trim() ?? "";
655 const sessionId = mapping?.sessionId.trim() ?? "";
656 const sessionSource = mapping?.sessionSource.trim() ?? "";
657 const scope = botMappingScope(mapping, connection.workspaceRoot);
658 const workspaceRoot = botMappingWorkspaceRoot(mapping, connection.workspaceRoot);
659 const status = sidebarImStatus(connection, bot.enabled);
660 const title = connection.label.trim() || platformLabel;
661 const allowlistUsers = sidebarImAllowlistUsers(bot, platform);
662 const identityLabel = botMappingIdentityLabel(mapping);
663 const mappedUserId = mapping?.userId.trim() ?? "";
664 const subtitleParts = [
665 remoteId ? compactRemoteId(remoteId) : platformLabel,
666 identityLabel,
667 connection.model.trim() || "",
668 sidebarImStatusLabel(status, translate),
669 ].filter(Boolean);
670 connectionItems.push({
671 id: mapping ? `${connection.id}:mapping:${index}` : connection.id,
672 connectionId: connection.id,
673 platform,
674 title,
675 platformLabel,
676 subtitle: subtitleParts.join(" · "),
677 status,
678 statusLabel: sidebarImStatusLabel(status, translate),
679 remoteId,
680 sessionId,
681 sessionSource,
682 scope,
683 workspaceRoot,
684 allowAll: bot.allowlist.allowAll,
685 allowlistEnabled: bot.allowlist.enabled,
686 allowlistUsers,
687 allowlistMatched: remoteId
688 ? allowlistUsers.includes(remoteId) || (mappedUserId ? allowlistUsers.includes(mappedUserId) : false)
689 : false,
690 });
691 });
692 }
693 return qqConnection ? [qqConnection, ...connectionItems] : connectionItems;
694 }
695
696 function mappedSessionTarget(sessionId: string): { kind: "path" | "topic"; value: string } | null {
697 const trimmed = sessionId.trim();
698 if (!trimmed) return null;
699 const lower = trimmed.toLowerCase();
700 if (lower.startsWith("path:")) {
701 const value = trimmed.slice(5).trim();
702 return value ? { kind: "path", value } : null;
703 }
704 if (lower.startsWith("topic:")) {
705 const value = trimmed.slice(6).trim();
706 return value ? { kind: "topic", value } : null;
707 }
708 if (trimmed.endsWith(".jsonl") || trimmed.includes("/") || trimmed.includes("\\") || trimmed.startsWith("~")) {
709 return { kind: "path", value: trimmed };
710 }
711 return { kind: "topic", value: trimmed };
712 }
713
714 function taskSessionIDFromPath(path: string): string {
715 const base = path.replace(/\\/g, "/").split("/").pop() || "";
716 const extension = base.lastIndexOf(".");
717 return extension > 0 ? base.slice(0, extension) : base;
718 }
719
720 function sidebarImSessionTarget(connection: SidebarImConnection): { kind: "path" | "topic"; value: string } | null {
721 return mappedSessionTarget(connection.sessionId);
722 }
723
724 function isChannelSession(session: SessionMeta): boolean {
725 return session.kind === "channel" || session.sessionSource === "auto";
726 }
727
728 function sidebarImTopicSourcesFromBot(bot: BotSettingsView | null | undefined, translate: Translator): Record<string, SidebarImTopicSource> {
729 if (!bot?.connections?.length) return {};
730 const sources: Record<string, SidebarImTopicSource> = {};
731 for (const connection of bot.connections) {
732 if (!isSidebarImConnection(connection)) continue;
733 const platform = sidebarImPlatform(connection);
734 const label = sidebarImPlatformLabel(platform, translate);
735 const title = connection.label.trim() || label;
736 for (const mapping of asArray(connection.sessionMappings)) {
737 const scope = botMappingScope(mapping, connection.workspaceRoot);
738 if (scope !== "global") continue;
739 const target = mappedSessionTarget(mapping.sessionId);
740 if (!target || target.kind !== "topic") continue;
741 if (sources[target.value]) continue;
742 sources[target.value] = {
743 platform,
744 label,
745 title,
746 remoteId: mapping.remoteId.trim(),
747 connectionId: connection.id,
748 };
749 }
750 }
751 return sources;
752 }
753
754 function sidebarImScopeLabel(connection: SidebarImConnection, translate: Translator): string {
755 if (connection.scope === "project") return translate("botDetail.scopeProject", { name: connection.workspaceRoot || "Project" });
756 return translate("botDetail.scopeGlobal");
757 }
758
759 function sidebarImSessionLabel(connection: SidebarImConnection, translate: Translator): string {
760 const target = sidebarImSessionTarget(connection);
761 if (!target) {
762 return connection.remoteId ? translate("botDetail.readOnlyChannel") : translate("botDetail.noSession");
763 }
764 if (connection.sessionSource === "auto") return translate("botDetail.readOnlyChannel");
765 if (target.kind === "path") return target.value.split(/[\\/]/).pop() || target.value;
766 return target.value;
767 }
768
769 function sidebarImAccessModeLabel(connection: SidebarImConnection, translate: Translator): string {
770 if (connection.allowAll) return translate("botDetail.accessAllowAll");
771 if (connection.allowlistEnabled) return translate("botDetail.accessWhitelist");
772 return translate("botDetail.accessDisabled");
773 }
774
775 function sidebarImAccessStatusLabel(connection: SidebarImConnection, translate: Translator): string {
776 if (connection.allowAll) return translate("botDetail.accessOpen");
777 if (!connection.remoteId) return translate("botDetail.accessUnknown");
778 return connection.allowlistMatched ? translate("botDetail.accessMatched") : translate("botDetail.accessMissing");
779 }
780
781 function sidebarImAccessStatusClass(connection: SidebarImConnection): string {
782 if (connection.allowAll || connection.allowlistMatched) return "ok";
783 if (!connection.remoteId) return "muted";
784 return "warn";
785 }
786
787 function SidebarImConnectionDetail({ connection, onClose, onOpenSession, onOpenSettings, onManageAllowlist }: SidebarImConnectionDetailProps) {
788 const translate = useT();
789 const target = sidebarImSessionTarget(connection);
790 const accessStatusClass = sidebarImAccessStatusClass(connection);
791 return (
792 <div className="bot-detail">
793 <section className="bot-detail__summary">
794 <div className={`bot-detail__avatar bot-detail__avatar--${connection.platform}`} aria-hidden="true">
795 {connection.platform === "qq" ? "Q" : connection.platform === "weixin" ? "微" : connection.platform === "lark" ? "L" : "飞"}
796 </div>
797 <div className="bot-detail__summary-main">
798 <span>{translate("botDetail.subtitle")}</span>
799 <h2>{connection.title}</h2>
800 <div className="bot-detail__chips">
801 <span>{connection.platformLabel}</span>
802 <span>{connection.statusLabel}</span>
803 <span>{sidebarImScopeLabel(connection, translate)}</span>
804 </div>
805 </div>
806 <div className="bot-detail__summary-actions">
807 <button type="button" className="btn btn--primary btn--small bot-detail__primary" disabled={!target} title={target ? undefined : translate("botDetail.openDisabled")} onClick={onOpenSession}>
808 <MessageSquare size={14} />
809 {translate("botDetail.openSession")}
810 </button>
811 <button type="button" className="btn btn--secondary btn--small" onClick={onOpenSettings}>
812 <SettingsIcon size={14} />
813 {translate("botDetail.manage")}
814 </button>
815 <button type="button" className="btn btn--secondary btn--small" onClick={onClose}>
816 {translate("botDetail.close")}
817 </button>
818 </div>
819 </section>
820
821 <section className="bot-detail__panel bot-detail__panel--access" aria-label={translate("botDetail.access")}>
822 <div className="bot-detail__section-head">
823 <span>{translate("botDetail.access")}</span>
824 <div className="bot-detail__section-actions">
825 {connection.remoteId ? (
826 <CopyButton text={connection.remoteId} label={translate("botDetail.copyRemoteId")} />
827 ) : null}
828 <button type="button" className="btn btn--secondary btn--small" onClick={onManageAllowlist}>
829 {translate("botDetail.manageAllowlist")}
830 </button>
831 </div>
832 </div>
833 <div className="bot-detail__access-grid">
834 <div>
835 <span>{translate("botDetail.accessMode")}</span>
836 <strong>{sidebarImAccessModeLabel(connection, translate)}</strong>
837 </div>
838 <div>
839 <span>{translate("botDetail.accessCurrentUser")}</span>
840 <code title={connection.remoteId || undefined}>{connection.remoteId || "—"}</code>
841 </div>
842 <div>
843 <span>{translate("botDetail.accessStatus")}</span>
844 <strong className={`bot-detail__access-status bot-detail__access-status--${accessStatusClass}`}>
845 {sidebarImAccessStatusLabel(connection, translate)}
846 </strong>
847 </div>
848 </div>
849 <div className="bot-detail__allowlist">
850 <span>{translate("botDetail.channelAllowlistUsers")}</span>
851 <div className="bot-detail__id-list">
852 {connection.allowlistUsers.length > 0 ? (
853 connection.allowlistUsers.map((id) => (
854 <code
855 key={id}
856 className={id === connection.remoteId ? "bot-detail__id-list-item--active" : ""}
857 title={id}
858 >
859 {id}
860 </code>
861 ))
862 ) : (
863 <em>{translate("botDetail.emptyAllowlistUsers")}</em>
864 )}
865 </div>
866 </div>
867 </section>
868
869 <section className="bot-detail__panel bot-detail__panel--facts" aria-label={translate("botDetail.summary")}>
870 <div className="bot-detail__section-head">
871 <span>{translate("botDetail.summary")}</span>
872 </div>
873 <div className="bot-detail__facts">
874 <div>
875 <span>{translate("botDetail.remoteId")}</span>
876 <code>{connection.remoteId || "—"}</code>
877 </div>
878 <div>
879 <span>{translate("botDetail.localTopic")}</span>
880 <strong>{sidebarImSessionLabel(connection, translate)}</strong>
881 </div>
882 <div>
883 <span>{translate("botDetail.scope")}</span>
884 <strong>{sidebarImScopeLabel(connection, translate)}</strong>
885 </div>
886 </div>
887 </section>
888 </div>
889 );
890 }
891
892 function activeTopicTurnsFromTree(tree: ProjectNode[], tab?: TabMeta): number | undefined {
893 if (!tab?.topicId) return undefined;
894 const targetScope = tab.scope === "global" ? "global" : "project";
895 const walk = (nodes: ProjectNode[]): number | undefined => {
896 for (const node of nodes) {
897 if (!node) continue;
898 if (node.kind === "topic" || node.kind === "global_topic") {
899 const scope = node.kind === "global_topic" ? "global" : "project";
900 if (
901 scope === targetScope &&
902 node.topicId === tab.topicId &&
903 (scope === "global" || node.root === tab.workspaceRoot)
904 ) {
905 return node.turns;
906 }
907 }
908 const found = walk(asArray(node.children));
909 if (found !== undefined) return found;
910 }
911 return undefined;
912 };
913 return walk(tree);
914 }
915
916 function normalizeDesktopPlatform(value: string): DesktopPlatform {
917 if (value === "darwin" || value === "windows") return value;
918 return "linux";
919 }
920
921 function browserPlatformOverride(): DesktopPlatform | null {
922 if (typeof window === "undefined" || window.runtime) return null;
923 const value = new URLSearchParams(window.location.search).get("platform");
924 if (value === "darwin" || value === "windows" || value === "linux") return value;
925 return null;
926 }
927
928 const GUIDANCE_QUEUE_MOCK_ITEMS = [
929 "先确认发送后输入框为什么残留刚发的消息,再决定修哪里。",
930 "保持真实 steer 协议不变,只调整前端乐观队列和按钮状态。",
931 "最后补后端 submit 悬挂时的回归测试,确保输入框会立刻释放。",
932 ] as const;
933
934 function browserMockScenarioParam(): string {
935 if (typeof window === "undefined" || window.runtime) return "";
936 return new URLSearchParams(window.location.search).get("mock")?.trim().toLowerCase() ?? "";
937 }
938
939 function isGuidanceMockScenario(value: string): boolean {
940 return value === "guidance" || value === "guide" || value === "steer";
941 }
942
943 function detectBrowserPlatform(): DesktopPlatform {
944 const override = browserPlatformOverride();
945 if (override) return override;
946 if (typeof navigator === "undefined") return "linux";
947 const marker = `${navigator.platform} ${navigator.userAgent}`;
948 if (/Win/i.test(marker)) return "windows";
949 if (/Mac/i.test(marker)) return "darwin";
950 return "linux";
951 }
952
953 function tabWorkspaceTitle(tab?: TabMeta): string {
954 if (!tab) return "Global";
955 if (tab.scope === "project") return tab.workspaceName || tab.workspaceRoot || "Project";
956 if (tab.scope === "global") return tab.workspaceName || "Global";
957 return tab.workspaceName || tab.workspaceRoot || "Global";
958 }
959
960 function topicTitle(tab?: TabMeta): string {
961 if (!tab) return "Global";
962 const workspaceTitle = tabWorkspaceTitle(tab);
963 const topic = tab.topicTitle || (tab.scope === "global" ? workspaceTitle : "Untitled");
964 return topic === workspaceTitle ? workspaceTitle : `${workspaceTitle} / ${topic}`;
965 }
966
967 function topicDisplayTitle(tab?: TabMeta): string {
968 if (!tab) return "Global";
969 return tab.topicTitle || (tab.scope === "global" ? tabWorkspaceTitle(tab) : "Untitled");
970 }
971
972 function sessionsForScope(sessions: SessionMeta[], filter: HistoryScopeFilter): SessionMeta[] {
973 if (filter.scope === "project") {
974 return sessions.filter((session) => session.scope === "project" && session.workspaceRoot === filter.workspaceRoot);
975 }
976 return sessions.filter((session) => (session.scope || "global") === "global");
977 }
978
979 function isMissingSessionError(err: unknown): boolean {
980 const message = err instanceof Error ? err.message : String(err ?? "");
981 return /no such file|cannot find the file|file does not exist|session is pending cleanup|session .*not found/i.test(message);
982 }
983
984 function workspaceDisplayName(path?: string): string {
985 if (!path) return "";
986 const parts = path.split(/[/\\]/).filter(Boolean);
987 return parts.length > 0 ? parts[parts.length - 1] : path;
988 }
989
990 function materializeLiveItems(items: Item[], live?: LiveStream): Item[] {
991 if (!live) return items;
992 return items.map((item) => {
993 if (item.kind !== "assistant" || item.id !== live.id) return item;
994 return { ...item, text: live.text, reasoning: live.reasoning, streaming: true };
995 });
996 }
997
998 function fence(label: string, value: string): string {
999 if (!value.trim()) return "";
1000 const fenceToken = value.includes("```") ? "````" : "```";
1001 return `${label}\n${fenceToken}\n${value.trim()}\n${fenceToken}`;
1002 }
1003
1004 function sessionItemsToMarkdown(title: string, items: Item[], live?: LiveStream): string {
1005 const lines: string[] = [`# ${title.trim() || "Reasonix session"}`, ""];
1006 for (const item of materializeLiveItems(items, live)) {
1007 switch (item.kind) {
1008 case "user":
1009 lines.push("## User", "", item.text.trim(), "");
1010 break;
1011 case "assistant":
1012 lines.push("## Assistant");
1013 if (item.reasoning.trim()) {
1014 lines.push("", "### Reasoning", "", item.reasoning.trim());
1015 }
1016 if (item.text.trim()) {
1017 lines.push("", item.text.trim());
1018 }
1019 lines.push("");
1020 break;
1021 case "tool":
1022 lines.push(`### Tool: ${item.name}`);
1023 if (item.args.trim()) lines.push("", fence("Args", item.args));
1024 if (item.output?.trim()) lines.push("", fence("Output", item.output));
1025 if (item.error?.trim()) lines.push("", fence("Error", item.error));
1026 lines.push("");
1027 break;
1028 case "phase":
1029 lines.push(`### Phase`, "", item.text.trim(), "");
1030 break;
1031 case "notice":
1032 lines.push(`### ${item.level === "warn" ? "Warning" : "Notice"}`, "", item.text.trim(), "");
1033 if (item.detail?.trim()) {
1034 lines.push("Details:", "", item.detail.trim(), "");
1035 }
1036 break;
1037 case "compaction":
1038 lines.push("### Context Compaction", "");
1039 if (item.pending) {
1040 lines.push("Compaction pending.");
1041 } else {
1042 lines.push(`Messages: ${item.messages}`);
1043 if (item.trigger) lines.push(`Trigger: ${item.trigger}`);
1044 if (item.summary.trim()) lines.push("", item.summary.trim());
1045 }
1046 lines.push("");
1047 break;
1048 }
1049 }
1050 return lines.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd() + "\n";
1051 }
1052
1053 function sessionItemsToJson(title: string, items: Item[], live?: LiveStream): string {
1054 return JSON.stringify(
1055 {
1056 title,
1057 exportedAt: new Date().toISOString(),
1058 items: materializeLiveItems(items, live),
1059 },
1060 null,
1061 2,
1062 );
1063 }
1064
1065 function safeFilename(name: string): string {
1066 const cleaned = name.trim().replace(/[\\/:*?"<>|]+/g, "-").replace(/\s+/g, " ").slice(0, 80);
1067 return cleaned || "reasonix-session";
1068 }
1069
1070 /** Global hotkey handler for shell-expand toggle (Ctrl/Cmd+B). */
1071 function ShellHotkeys() {
1072 const shellExpand = useShellExpand();
1073 useGlobalShortcut("shell.toggle", () => shellExpand?.toggleLast(), [shellExpand], Boolean(shellExpand));
1074 return null;
1075 }
1076
1077 /** Global hotkey handler for text-size shortcuts (Ctrl/Cmd + Plus/Minus/0). */
1078 function TextSizeHotkeys() {
1079 useGlobalShortcut("textSize.increase", () => applyTextSize(nextTextSize(getTextSize(), 1)));
1080 useGlobalShortcut("textSize.decrease", () => applyTextSize(nextTextSize(getTextSize(), -1)));
1081 useGlobalShortcut("textSize.reset", () => applyTextSize(DEFAULT_TEXT_SIZE));
1082 return null;
1083 }
1084
1085 export default function App() {
1086 const {
1087 state,
1088 liveStore,
1089 activeTabId,
1090 sendToTab,
1091 recoverDeliveryToTab,
1092 runShellForTab,
1093 steerForTab,
1094 notice,
1095 cancel,
1096 approve,
1097 resolvePlanDecision,
1098 resolveRecovery,
1099 answerQuestion,
1100 setControllerMode,
1101 dismissExtensionForm,
1102 drainExtensionNotifications,
1103 setCollaborationMode: setControllerCollaborationMode,
1104 setToolApprovalMode: setControllerToolApprovalMode,
1105 setComposerProfileForTab: setControllerComposerProfileForTab,
1106 setGoalForTab: setControllerGoalForTab,
1107 resumeGoalForTab: resumeControllerGoalForTab,
1108 pauseGoalForTab: pauseControllerGoalForTab,
1109 clearGoal: clearControllerGoal,
1110 clearGoalForTab: clearControllerGoalForTab,
1111 clearSession,
1112 listSessions,
1113 listTrashedSessions,
1114 resumeSession,
1115 openChannelSession,
1116 previewSession,
1117 deleteSession,
1118 restoreSession,
1119 purgeTrashedSession,
1120 renameSession,
1121 loadOlderHistory,
1122 refreshMeta,
1123 pickWorkspace,
1124 switchWorkspace,
1125 rewindForTab,
1126 rewindForTabDetailed,
1127 undoRewindForTab,
1128 setModel,
1129 setEffort,
1130 setTokenMode,
1131 cancelJob,
1132 switchTab,
1133 openProjectTab,
1134 createDeliveryWorktree,
1135 openGlobalTab,
1136 closeTab,
1137 reorderTabs,
1138 openTopicSession,
1139 activateTopic,
1140 noteNavigationIntent,
1141 isNavigationIntentCurrent,
1142 syncActiveTab,
1143 ensureBlankTab,
1144 ensureBlankSurface,
1145 } = useController();
1146 const { locale, setPref: setLocalePref } = useI18n();
1147 const t = useT();
1148 const [composerProfilesByTab, setComposerProfilesByTab] = useState<Record<string, ComposerProfile>>({});
1149 const runtimeTransitionTabsRef = useRef<Set<string>>(new Set());
1150 const [runtimeTransitionsByTab, setRuntimeTransitionsByTab] = useState<Record<string, true>>({});
1151 const yoloRestoreToolApprovalModesRef = useRef<Record<string, RestorableToolApprovalMode>>({});
1152 const userPlanModeByTabRef = useRef<UserPlanModeIntents>({});
1153 const [tabMetas, setTabMetas] = useState<TabMeta[]>([]);
1154 const [tabOrderIds, setTabOrderIds] = useState<string[]>([]);
1155 const [tabRevealSignal, setTabRevealSignal] = useState(0);
1156 const [transcriptRevealSignal, setTranscriptRevealSignal] = useState(0);
1157 const startupSplashVisible = useOverlayStore((s) => s.startupSplashVisible);
1158 const setStartupSplashVisible = useOverlayStore((s) => s.setStartupSplashVisible);
1159 // null until the mount probe resolves; true shows the first-run guide.
1160 const needsOnboarding = useOverlayStore((s) => s.needsOnboarding);
1161 const setNeedsOnboarding = useOverlayStore((s) => s.setNeedsOnboarding);
1162 const [providerSetupNeeded, setProviderSetupNeeded] = useState(false);
1163 const settingsTarget = useOverlayStore((s) => s.settingsTarget);
1164 const setSettingsTarget = useOverlayStore((s) => s.setSettingsTarget);
1165 const settingsFocus = useOverlayStore((s) => s.settingsFocus);
1166 const setSettingsFocus = useOverlayStore((s) => s.setSettingsFocus);
1167 const [desktopLayoutStyle, setDesktopLayoutStyle] = useState<DesktopLayoutStyle>("workbench");
1168 const singleSurfaceLayout = desktopLayoutStyle === "workbench" || desktopLayoutStyle === "creation";
1169 const [configLoadWarnings, setConfigLoadWarnings] = useState<string[]>([]);
1170 const [startupUpdateChecksEnabled, setStartupUpdateChecksEnabled] = useState<boolean | null>(null);
1171 const [histView, setHistView] = useState<HistoryViewState | null>(null);
1172 const paletteOpen = useOverlayStore((s) => s.paletteOpen);
1173 const setPaletteOpen = useOverlayStore((s) => s.setPaletteOpen);
1174 const paletteExtensionActions = useOverlayStore((s) => s.paletteExtensionActions);
1175 const setPaletteExtensionActions = useOverlayStore((s) => s.setPaletteExtensionActions);
1176 const remoteExplorerOpen = useRemoteStore((s) => s.explorerOpen);
1177 const remoteExplorerHostId = useRemoteStore((s) => s.explorerHostId);
1178 const remoteHosts = useRemoteStore((s) => s.hosts);
1179 const remoteStatuses = useRemoteStore((s) => s.statuses);
1180 const { showToast } = useToast();
1181 const { runGoalAction, handleGoalActionError } = useGoalActionHandler();
1182 const setRemoteHosts = useRemoteStore((s) => s.setHosts);
1183 const hydrateRemoteStatuses = useRemoteStore((s) => s.hydrateStatuses);
1184 const requestRemoteExplorer = useRemoteStore((s) => s.openExplorer);
1185 const closeRemoteExplorerRequest = useRemoteStore((s) => s.closeExplorer);
1186 const applyRemoteStatus = useRemoteStore((s) => s.applyStatus);
1187 const requestRemoteStatusPopover = useRemoteStore((s) => s.requestStatusPopover);
1188 const setRemoteForwards = useRemoteStore((s) => s.setForwards);
1189 const setRemoteServer = useRemoteStore((s) => s.setServer);
1190
1191 const shortcutsOpen = useOverlayStore((s) => s.shortcutsOpen);
1192 const setShortcutsOpen = useOverlayStore((s) => s.setShortcutsOpen);
1193 const paletteSessions = useOverlayStore((s) => s.paletteSessions);
1194 const setPaletteSessions = useOverlayStore((s) => s.setPaletteSessions);
1195 const [sidebarImConnections, setSidebarImConnections] = useState<SidebarImConnection[]>([]);
1196 const [imTopicSources, setImTopicSources] = useState<Record<string, SidebarImTopicSource>>({});
1197 const [sidebarImDetailConnectionId, setSidebarImDetailConnectionId] = useState("");
1198 const sidebarCollapsed = useLayoutStore((s) => s.sidebarCollapsed);
1199 const setSidebarCollapsed = useLayoutStore((s) => s.setSidebarCollapsed);
1200 const heartbeatOpen = useOverlayStore((s) => s.heartbeatOpen);
1201 const setHeartbeatOpen = useOverlayStore((s) => s.setHeartbeatOpen);
1202 type TimeFilter = "all" | "10" | "20" | "1h" | "3h" | "5h" | "1d";
1203 const [topicTimeFilter, setTopicTimeFilter] = useState<TimeFilter>(() => {
1204 try {
1205 const saved = localStorage.getItem("projectTree:timeFilter");
1206 if (saved === "all" || saved === "10" || saved === "20" || saved === "1h" || saved === "3h" || saved === "5h" || saved === "1d") return saved;
1207 } catch { /* localStorage unavailable */ }
1208 return "all";
1209 });
1210 useEffect(() => {
1211 try { localStorage.setItem("projectTree:timeFilter", topicTimeFilter); } catch { /* ignore */ }
1212 }, [topicTimeFilter]);
1213 const sidebarWidth = useLayoutStore((s) => s.sidebarWidth);
1214 const setSidebarWidth = useLayoutStore((s) => s.setSidebarWidth);
1215 const [sidebarResizing, setSidebarResizing] = useState(false);
1216 const [tasksOpen, setTasksOpen] = useState(false);
1217 const [liveSidebarWidth, setLiveSidebarWidth] = useState<number | null>(null);
1218 const [viewportWidth, setViewportWidth] = useState(() => (typeof window === "undefined" ? 1440 : window.innerWidth));
1219 const [viewportHeight, setViewportHeight] = useState(() => (typeof window === "undefined" ? 720 : window.innerHeight));
1220 const workspacePanelOpen = useLayoutStore((s) => s.workspacePanelOpen);
1221 const setWorkspacePanelOpen = useLayoutStore((s) => s.setWorkspacePanelOpen);
1222 const rightDockTreeWidth = useLayoutStore((s) => s.rightDockTreeWidth);
1223 const setRightDockTreeWidth = useLayoutStore((s) => s.setRightDockTreeWidth);
1224 const rightDockPreviewWidth = useLayoutStore((s) => s.rightDockPreviewWidth);
1225 const setRightDockPreviewWidth = useLayoutStore((s) => s.setRightDockPreviewWidth);
1226 const workspacePreviewActive = useLayoutStore((s) => s.workspacePreviewActive);
1227 const setWorkspacePreviewActive = useLayoutStore((s) => s.setWorkspacePreviewActive);
1228 const attentionChimeEvents = useRef(new Set<string>());
1229 const workspaceScopeActiveTabRef = useRef(activeTabId);
1230 const [workspaceControllerEpoch, setWorkspaceControllerEpoch] = useState(0);
1231 workspaceScopeActiveTabRef.current = activeTabId;
1232 // Bump dockRefreshKey after each turn so WorkspacePanel/ContextPanel re-fetch
1233 // workspace changes, git history, and session metadata after AI tool writes.
1234 useEffect(() => {
1235 startTerminalEventBridge();
1236 const unsub = onEvent((e) => {
1237 if (e.kind === "turn_done") {
1238 setDockRefreshKey((v) => v + 1);
1239 }
1240 if (shouldPlayAttentionChimeForEvent(e, attentionChimeEvents.current)) {
1241 playAttentionChime();
1242 }
1243 if (e.kind === "turn_done") {
1244 if (!e.err) playSuccessChime();
1245 }
1246 });
1247 // Runtime rebuilds (model/effort/settings switch) replace the controller,
1248 // whose approval/ask ids restart from "1" — stale dedupe keys would mute
1249 // the first prompt after a rebuild. agent:ready fires when a (re)build
1250 // completes; clear that tab's keys (or all, for tab-less ready events).
1251 const unsubReady = onReady((readyTabId) => {
1252 clearAttentionChimeKeys(attentionChimeEvents.current, readyTabId);
1253 if (!readyTabId || readyTabId === workspaceScopeActiveTabRef.current) {
1254 setWorkspaceControllerEpoch((value) => value + 1);
1255 }
1256 });
1257 // Model/effort/token-mode switches and clear-while-running replace the
1258 // controller WITHOUT an agent:ready — they signal runtime:rebuilt instead
1259 // (a ready here would trigger a full session reload the UI already did).
1260 const unsubRebuilt = onRuntimeRebuilt((rebuiltTabId) => {
1261 clearAttentionChimeKeys(attentionChimeEvents.current, rebuiltTabId);
1262 if (!rebuiltTabId || rebuiltTabId === workspaceScopeActiveTabRef.current) {
1263 setWorkspaceControllerEpoch((value) => value + 1);
1264 }
1265 });
1266 return () => {
1267 unsub();
1268 unsubReady();
1269 unsubRebuilt();
1270 };
1271 }, []);
1272
1273 const [workspacePanelResizing, setWorkspacePanelResizing] = useState(false);
1274 const [liveWorkspacePanelRenderWidth, setLiveWorkspacePanelRenderWidth] = useState<number | null>(null);
1275 const [liveTerminalHeight, setLiveTerminalHeight] = useState<number | null>(null);
1276 const [terminalContentVisible, setTerminalContentVisible] = useState(false);
1277 const terminalResizing = liveTerminalHeight !== null;
1278 const workspacePanelMaximized = useLayoutStore((s) => s.workspacePanelMaximized);
1279 const setWorkspacePanelMaximized = useLayoutStore((s) => s.setWorkspacePanelMaximized);
1280 const rightDockMode = useLayoutStore((s) => s.rightDockMode);
1281 const setRightDockMode = useLayoutStore((s) => s.setRightDockMode);
1282 const terminalPanelOpen = useLayoutStore((s) => s.terminalPanelOpen);
1283 const setTerminalPanelOpen = useLayoutStore((s) => s.setTerminalPanelOpen);
1284 const terminalHeight = useLayoutStore((s) => s.terminalHeight);
1285 const setTerminalHeight = useLayoutStore((s) => s.setTerminalHeight);
1286 const [dockRefreshKey, setDockRefreshKey] = useState(0);
1287 const [fileRefRefreshKey, setFileRefRefreshKey] = useState(0);
1288 const refreshComposerFileRefs = useCallback(() => setFileRefRefreshKey((value) => value + 1), []);
1289 const composerFileRefRefreshKey = `${dockRefreshKey}:${fileRefRefreshKey}`;
1290 const [projectRevision, setProjectRevision] = useState(0);
1291 const [activeTopicTurns, setActiveTopicTurns] = useState<number | undefined>(undefined);
1292 const [composerInsertRequestsByTab, setComposerInsertRequestsByTab] = useState<Record<string, ComposerInsertRequest>>({});
1293 const [selectedTextRequestsByTab, setSelectedTextRequestsByTab] = useState<Record<string, SelectedTextInsertRequest>>({});
1294 const selectedTextRequestIdRef = useRef(0);
1295 const [planRevisionInsertRequest, setPlanRevisionInsertRequest] = useState<{
1296 tabId: string;
1297 approvalId: string;
1298 request: ComposerInsertRequest;
1299 } | null>(null);
1300 const [workspaceInsertTarget, setWorkspaceInsertTarget] = useState<WorkspaceInsertTarget>("composer");
1301 const transientOverlayDismissSignal = useOverlayStore((s) => s.transientOverlayDismissSignal);
1302 const setTransientOverlayDismissSignal = useOverlayStore((s) => s.setTransientOverlayDismissSignal);
1303 const [desktopPlatform, setDesktopPlatform] = useState<DesktopPlatform>(detectBrowserPlatform);
1304 const windowsFramelessChrome = desktopPlatform === "windows";
1305 const [mainWindowMaximised, syncMainWindowMaximised] = useWindowsMaximised(windowsFramelessChrome);
1306 useWailsResizeFix(windowsFramelessChrome, mainWindowMaximised);
1307 const [statusBarStyle, setStatusBarStyle] = useState<"icon" | "text">("text");
1308 const [statusBarItems, setStatusBarItems] = useState<StatusBarItemId[]>(() => [...DEFAULT_STATUS_BAR_ITEMS]);
1309 const [renamingTopicId, setRenamingTopicId] = useState<string | null>(null);
1310 const [topicTitleDraft, setTopicTitleDraft] = useState("");
1311 const topicExportOpen = useOverlayStore((s) => s.topicExportOpen);
1312 const setTopicExportOpen = useOverlayStore((s) => s.setTopicExportOpen);
1313 const sidebarSearchOpen = useOverlayStore((s) => s.sidebarSearchOpen);
1314 const setSidebarSearchOpen = useOverlayStore((s) => s.setSidebarSearchOpen);
1315 const sidebarSearchFocusSignal = useOverlayStore((s) => s.sidebarSearchFocusSignal);
1316 const setSidebarSearchFocusSignal = useOverlayStore((s) => s.setSidebarSearchFocusSignal);
1317 const [sidebarTogglePressed, setSidebarTogglePressed] = useState(false);
1318 const [workspaceTogglePressed, setWorkspaceTogglePressed] = useState(false);
1319 const [clearContextPending, setClearContextPending] = useState(false);
1320 const [backgroundRuntimes, setBackgroundRuntimes] = useState<BackgroundRuntimeView[]>([]);
1321 const [workspaceConflict, setWorkspaceConflict] = useState<WorkspaceConflictView | null>(null);
1322 const [pendingModeSwitch, setPendingModeSwitch] = useState<{
1323 tabId: string;
1324 target: TokenMode;
1325 previous: TokenMode;
1326 work: ActiveWorkView;
1327 stopping: boolean;
1328 } | null>(null);
1329 const [pendingClose, setPendingClose] = useState<{ tabId: string; work: ActiveWorkView; stopping: boolean } | null>(null);
1330 const topicRenameSkipCommitRef = useRef(false);
1331 const prevDecisionSurfaceRef = useRef<DecisionSurfaceKind | null>(null);
1332 const decisionSurfaceRef = useRef<DecisionSurfaceKind | null>(null);
1333 const topicRenameCommitHandledRef = useRef(false);
1334 const appRef = useRef<HTMLDivElement>(null);
1335 const layoutRef = useRef<HTMLDivElement>(null);
1336 const sidebarTogglePressTimerRef = useRef<number | null>(null);
1337 const workspaceTogglePressTimerRef = useRef<number | null>(null);
1338
1339 // Persist window geometry across launches.
1340 useWindowStatePersistence();
1341 useViewportHeightVar();
1342 useEffect(() => {
1343 document.documentElement.setAttribute("data-platform", desktopPlatform);
1344 }, [desktopPlatform]);
1345
1346 const refreshBackgroundRuntimes = useCallback(async () => {
1347 try {
1348 setBackgroundRuntimes(await app.BackgroundRuntimes());
1349 } catch {
1350 // The global recovery entry is supplementary; the active-tab job list
1351 // remains available even when the detached-runtime list is unavailable.
1352 }
1353 }, []);
1354
1355 useEffect(() => {
1356 let disposed = false;
1357 const refresh = async () => {
1358 if (disposed) return;
1359 await refreshBackgroundRuntimes();
1360 };
1361 void refresh();
1362 const timer = window.setInterval(() => void refresh(), 1000);
1363 return () => {
1364 disposed = true;
1365 window.clearInterval(timer);
1366 };
1367 }, [refreshBackgroundRuntimes]);
1368
1369 useEffect(() => {
1370 if (!activeTabId || !state.running) {
1371 setWorkspaceConflict(null);
1372 return;
1373 }
1374 let disposed = false;
1375 const inspect = async () => {
1376 try {
1377 const conflict = await app.WorkspaceConflictForTab(activeTabId);
1378 if (!disposed) setWorkspaceConflict(conflict.state === "none" ? null : conflict);
1379 } catch {
1380 if (!disposed) setWorkspaceConflict(null);
1381 }
1382 };
1383 void inspect();
1384 const timer = window.setInterval(() => void inspect(), 500);
1385 return () => {
1386 disposed = true;
1387 window.clearInterval(timer);
1388 };
1389 }, [activeTabId, state.running]);
1390
1391 useEffect(() => {
1392 if (pendingModeSwitch && pendingModeSwitch.tabId !== activeTabId) setPendingModeSwitch(null);
1393 }, [activeTabId, pendingModeSwitch]);
1394
1395 const closeTransientOverlays = useCallback(() => {
1396 setTransientOverlayDismissSignal((signal) => signal + 1);
1397 }, []);
1398
1399 const reloadSidebarImConnections = useCallback(async () => {
1400 const [settings, runtimeStatus] = await Promise.all([
1401 app.DesktopStartupSettings(),
1402 loadBotRuntimeStatus(),
1403 ]);
1404 setSidebarImConnections(sidebarImConnectionsFromBot(settings.bot, t, runtimeStatus));
1405 setImTopicSources(sidebarImTopicSourcesFromBot(settings.bot, t));
1406 }, [t]);
1407
1408 const refreshSidebarImConnectionsFromSettings = useCallback(async (settings: Pick<SettingsView | DesktopStartupSettingsView, "bot">) => {
1409 const runtimeStatus = await loadBotRuntimeStatus();
1410 setSidebarImConnections(sidebarImConnectionsFromBot(settings.bot, t, runtimeStatus));
1411 setImTopicSources(sidebarImTopicSourcesFromBot(settings.bot, t));
1412 }, [t]);
1413
1414 const openBotSettings = useCallback(() => {
1415 closeTransientOverlays();
1416 setSidebarImDetailConnectionId("");
1417 setSettingsFocus(null);
1418 setSettingsTarget("bots");
1419 }, [closeTransientOverlays]);
1420
1421 const openBotAllowlistSettings = useCallback((connectionId: string) => {
1422 closeTransientOverlays();
1423 setSidebarImDetailConnectionId("");
1424 setSettingsFocus({ target: "bot-allowlist", connectionId });
1425 setSettingsTarget("bots");
1426 }, [closeTransientOverlays]);
1427
1428 const pulseSidebarToggle = useCallback(() => {
1429 if (typeof window === "undefined") return;
1430 if (sidebarTogglePressTimerRef.current !== null) {
1431 window.clearTimeout(sidebarTogglePressTimerRef.current);
1432 }
1433 setSidebarTogglePressed(true);
1434 sidebarTogglePressTimerRef.current = window.setTimeout(() => {
1435 sidebarTogglePressTimerRef.current = null;
1436 setSidebarTogglePressed(false);
1437 }, 260);
1438 }, []);
1439
1440 const pulseWorkspaceToggle = useCallback(() => {
1441 if (typeof window === "undefined") return;
1442 if (workspaceTogglePressTimerRef.current !== null) {
1443 window.clearTimeout(workspaceTogglePressTimerRef.current);
1444 }
1445 setWorkspaceTogglePressed(true);
1446 workspaceTogglePressTimerRef.current = window.setTimeout(() => {
1447 workspaceTogglePressTimerRef.current = null;
1448 setWorkspaceTogglePressed(false);
1449 }, 260);
1450 }, []);
1451
1452 const anchorAppScrollToChat = useCallback(() => {
1453 if (typeof window === "undefined") return;
1454 const el = appRef.current;
1455 if (!el) return;
1456 const pin = () => {
1457 el.scrollLeft = 0;
1458 };
1459 pin();
1460 window.requestAnimationFrame(pin);
1461 window.setTimeout(pin, 300);
1462 }, []);
1463
1464 useEffect(() => {
1465 return () => {
1466 if (sidebarTogglePressTimerRef.current !== null) {
1467 window.clearTimeout(sidebarTogglePressTimerRef.current);
1468 }
1469 if (workspaceTogglePressTimerRef.current !== null) {
1470 window.clearTimeout(workspaceTogglePressTimerRef.current);
1471 }
1472 };
1473 }, []);
1474
1475 useEffect(() => {
1476 let cancelled = false;
1477 const override = browserPlatformOverride();
1478 if (override) {
1479 setDesktopPlatform(override);
1480 return () => {
1481 cancelled = true;
1482 };
1483 }
1484 void app.Platform()
1485 .then((value) => {
1486 if (!cancelled) setDesktopPlatform(normalizeDesktopPlatform(value));
1487 })
1488 .catch((e) => {
1489 console.warn("platform probe failed", e);
1490 });
1491 return () => {
1492 cancelled = true;
1493 };
1494 }, []);
1495
1496 const applyDesktopPreferences = useCallback(
1497 (settings: Pick<SettingsView, "desktopTheme" | "desktopThemeStyle" | "desktopTerminalTheme" | "desktopLayoutStyle" | "desktopLanguage" | "checkUpdates" | "statusBarStyle" | "statusBarItems" | "conversationWidth">) => {
1498 const nextTheme = normalizeThemePreference(settings.desktopTheme);
1499 const nextStyle = normalizeThemeStyleForTheme(settings.desktopThemeStyle, nextTheme);
1500 applyConfiguredBaseAppearance(nextTheme, nextStyle);
1501 applyTerminalThemePreference(settings.desktopTerminalTheme);
1502 applyConversationWidth(settings.conversationWidth);
1503 const nextLayoutStyle = normalizeDesktopLayoutStyle(settings.desktopLayoutStyle);
1504 setDesktopLayoutStyle(nextLayoutStyle);
1505 applyLayoutStyleDefaults(nextLayoutStyle);
1506 setLocalePref(normalizeLangPref(settings.desktopLanguage));
1507 setStartupUpdateChecksEnabled(settings.checkUpdates !== false);
1508 setStatusBarStyle(settings.statusBarStyle === "text" ? "text" : "icon");
1509 setStatusBarItems(normalizeStatusBarItems(settings.statusBarItems));
1510 },
1511 [setLocalePref],
1512 );
1513
1514 useEffect(() => {
1515 let cancelled = false;
1516 const syncDesktopPreferences = async () => {
1517 const legacyLanguage = readLegacyLangPref();
1518 const legacyTheme = readLegacyThemePreference();
1519 if (legacyLanguage || legacyTheme.hasValue) {
1520 await app.MigrateDesktopPreferences(legacyLanguage, legacyTheme.theme, legacyTheme.style);
1521 clearLegacyLangPref();
1522 clearLegacyThemePreference();
1523 }
1524 const [settings, runtimeStatus] = await Promise.all([
1525 app.DesktopStartupSettings(),
1526 loadBotRuntimeStatus(),
1527 ]);
1528 if (cancelled) return;
1529 applyDesktopPreferences(settings);
1530 setConfigLoadWarnings(
1531 Array.isArray(settings.configWarnings)
1532 ? settings.configWarnings.filter((w): w is string => typeof w === "string" && w.trim() !== "")
1533 : [],
1534 );
1535 hydrateDisplayMode(settings.displayMode);
1536 setSidebarImConnections(sidebarImConnectionsFromBot(settings.bot, t, runtimeStatus));
1537 setImTopicSources(sidebarImTopicSourcesFromBot(settings.bot, t));
1538 // Load unified theme experience after base appearance so pack tokens win.
1539 {
1540 try {
1541 const { loadThemeExperience, applyExperienceToDOM } = await import("./lib/themeExperience");
1542 const exp = await loadThemeExperience();
1543 if (cancelled) return;
1544 applyExperienceToDOM(exp);
1545 } catch (err) {
1546 console.warn("theme experience load failed", err);
1547 try {
1548 const active = await app.GetActiveThemePack();
1549 if (cancelled) return;
1550 if (active?.pack) applyThemePack(active.pack);
1551 else clearThemePack();
1552 } catch {
1553 clearThemePack();
1554 }
1555 }
1556 }
1557 };
1558 void syncDesktopPreferences().catch((e) => {
1559 console.warn("desktop preferences sync failed", e);
1560 setStartupUpdateChecksEnabled(true);
1561 });
1562 return () => {
1563 cancelled = true;
1564 };
1565 }, [applyDesktopPreferences, t]);
1566
1567 useEffect(() => {
1568 setSidebarImDetailConnectionId((current) => {
1569 if (!current) return "";
1570 return sidebarImConnections.some((connection) => connection.id === current) ? current : "";
1571 });
1572 }, [sidebarImConnections]);
1573
1574 // Open settings when the native menu item (CmdOrCtrl+,) is activated.
1575 useEffect(() => {
1576 if (typeof window === "undefined" || !window.runtime) return;
1577 return window.runtime.EventsOn("app:open-settings", () => {
1578 closeTransientOverlays();
1579 setSettingsTarget("general");
1580 });
1581 }, [closeTransientOverlays]);
1582 useEffect(() => {
1583 if (typeof window === "undefined") return;
1584 const onResize = () => {
1585 setViewportWidth(window.innerWidth);
1586 setViewportHeight(window.innerHeight);
1587 };
1588 window.addEventListener("resize", onResize);
1589 return () => window.removeEventListener("resize", onResize);
1590 }, []);
1591
1592 const [pendingPlanRevisionsByTab, setPendingPlanRevisionsByTab] = useState<Record<string, string>>({});
1593 const [invocationMetadataByTab, setInvocationMetadataByTab] = useState<Record<string, InvocationMetadataMap>>({});
1594 const pendingPlanRevisionSendingTabsRef = useRef(new Set<string>());
1595 const [footerHeight, setFooterHeight] = useState(0);
1596 const footerHeightRef = useRef(0);
1597 const footerRef = useRef<HTMLElement>(null);
1598 const activeTabIdRef = useRef(activeTabId);
1599 const commitThenSendRef = useRef<(
1600 tabId: string,
1601 displayText: string,
1602 submitText?: string,
1603 structured?: StructuredInvocationSubmit,
1604 initialGoal?: {
1605 goal: string;
1606 collaborationMode: CollaborationMode;
1607 toolApprovalMode: ToolApprovalMode;
1608 },
1609 ) => Promise<void>>(async () => {});
1610 const handleInvocationMetadataChange = useCallback((metadata: InvocationMetadataMap) => {
1611 const sourceTabId = activeTabIdRef.current;
1612 if (!sourceTabId) return;
1613 setInvocationMetadataByTab((current) => {
1614 const previous = current[sourceTabId] ?? {};
1615 const names = Object.keys(metadata);
1616 if (names.length === Object.keys(previous).length && names.every((name) => (
1617 previous[name]?.kind === metadata[name]?.kind && previous[name]?.color === metadata[name]?.color
1618 ))) return current;
1619 return { ...current, [sourceTabId]: metadata };
1620 });
1621 }, []);
1622 const rightDockDetailActive = rightDockMode !== "context" && workspacePreviewActive;
1623 const preferredWorkspacePanelWidth = rightDockDetailActive ? rightDockPreviewWidth : rightDockTreeWidth;
1624 const rightDockTreeMinWidth = desktopLayoutStyle === "creation" ? CREATION_RIGHT_DOCK_TREE_MIN_WIDTH : RIGHT_DOCK_TREE_MIN_WIDTH;
1625 const rightDockTreeWidthClamp = desktopLayoutStyle === "creation" ? clampCreationRightDockTreeWidth : clampRightDockTreeWidth;
1626 const rightDockMinRenderWidth = desktopLayoutStyle === "creation" && !rightDockDetailActive
1627 ? CREATION_RIGHT_DOCK_MIN_RENDER_WIDTH
1628 : RIGHT_DOCK_MIN_RENDER_WIDTH;
1629 const workspacePanelMinWidth = rightDockDetailActive ? RIGHT_DOCK_PREVIEW_MIN_WIDTH : rightDockTreeMinWidth;
1630 const chatReservedWidth = workspacePanelOpen && !workspacePanelMaximized ? CHAT_COMFORT_MIN_WIDTH : CHAT_MIN_WIDTH;
1631 const workspacePanelAvailableWidth = availableWorkspacePanelWidth({
1632 viewportWidth,
1633 sidebarCollapsed,
1634 sidebarWidth,
1635 chatMinWidth: chatReservedWidth,
1636 resizerWidth: WORKSPACE_RESIZER_WIDTH,
1637 });
1638
1639 const resolvedWorkspacePanelWidth = resolveWorkspacePanelWidth({
1640 open: workspacePanelOpen,
1641 maximized: workspacePanelMaximized,
1642 preferredWidth: preferredWorkspacePanelWidth,
1643 minWidth: workspacePanelMinWidth,
1644 availableWidth: workspacePanelAvailableWidth,
1645 });
1646
1647 const storedWorkspacePanelRenderWidth = workspacePanelMaximized ? preferredWorkspacePanelWidth : resolvedWorkspacePanelWidth;
1648 const workspacePanelRenderWidth = liveWorkspacePanelRenderWidth ?? storedWorkspacePanelRenderWidth;
1649 // The terminal is an independent bottom drawer; workspace panel renderability
1650 // no longer depends on terminal mode.
1651 const workspacePanelRenderable =
1652 workspacePanelOpen && (
1653 workspacePanelMaximized ||
1654 workspacePanelRenderWidth >= rightDockMinRenderWidth
1655 );
1656 const workspacePanelGridOpen = workspacePanelRenderable && !workspacePanelMaximized;
1657 const resolveLiveWorkspacePanelRenderWidth = useCallback(
1658 (preferredWidth: number, nextSidebarWidth = sidebarWidth) =>
1659 resolveLiveWorkspacePanelWidth({
1660 viewportWidth,
1661 sidebarCollapsed,
1662 sidebarWidth: nextSidebarWidth,
1663 chatMinWidth: chatReservedWidth,
1664 resizerWidth: WORKSPACE_RESIZER_WIDTH,
1665 open: workspacePanelOpen,
1666 maximized: workspacePanelMaximized,
1667 preferredWidth,
1668 minWidth: workspacePanelMinWidth,
1669 }),
1670 [chatReservedWidth, sidebarCollapsed, sidebarWidth, viewportWidth, workspacePanelMaximized, workspacePanelMinWidth, workspacePanelOpen],
1671 );
1672 const activeTab = useMemo(
1673 () => tabMetas.find((tab) => tab.id === activeTabId) ?? tabMetas.find((tab) => tab.active),
1674 [activeTabId, tabMetas],
1675 );
1676 const activePlanRevisionInsertRequest =
1677 planRevisionInsertRequest &&
1678 planRevisionInsertRequest.tabId === activeTabId &&
1679 planRevisionInsertRequest.approvalId === state.approval?.id
1680 ? planRevisionInsertRequest.request
1681 : null;
1682 const composerInsertRequest = activeTabId ? composerInsertRequestsByTab[activeTabId] ?? null : null;
1683 const handleRevisionActiveChange = useCallback((active: boolean) => {
1684 setWorkspaceInsertTarget(active ? "planRevision" : "composer");
1685 }, []);
1686 const selectedTextRequest = activeTabId ? selectedTextRequestsByTab[activeTabId] ?? null : null;
1687 const prefillSubagentCommand = useCallback((command: string) => {
1688 if (!activeTabId) return;
1689 setComposerInsertRequestsByTab((current) => ({
1690 ...current,
1691 [activeTabId]: { id: Date.now(), text: command, mode: "prefix" },
1692 }));
1693 }, [activeTabId]);
1694 const composerSessionKey = useMemo(() => {
1695 return composerDraftKeyForTab(activeTab, activeTabId);
1696 }, [activeTab, activeTabId]);
1697 const workspaceScopeKey = [
1698 activeTabId ?? "",
1699 activeTab?.sessionPath ?? "",
1700 state.meta?.sessionPath ?? "",
1701 state.meta?.cwd ?? "",
1702 state.sessionGen,
1703 workspaceControllerEpoch,
1704 ].join("\u0000");
1705 // A topic may contain multiple saved sessions; the concrete session path is
1706 // the runtime conversation identity, with topic/tab ids only as fallbacks.
1707 const workspaceTreeMemoryKey = [
1708 activeTab?.scope ?? "",
1709 activeTab?.workspaceRoot ?? state.meta?.cwd ?? "",
1710 activeTab?.sessionPath || state.meta?.sessionPath || activeTab?.topicId || activeTabId || "",
1711 ].join("\u0000");
1712 const workspaceTreeMemoryVisitId = workspaceTreeVisitId(workspaceTreeMemoryKey);
1713 const sidebarImDetailConnection = useMemo(
1714 () => sidebarImConnections.find((connection) => connection.id === sidebarImDetailConnectionId) ?? null,
1715 [sidebarImConnections, sidebarImDetailConnectionId],
1716 );
1717 useEffect(() => {
1718 let cancelled = false;
1719 if (!activeTab?.topicId) {
1720 setActiveTopicTurns(undefined);
1721 return () => {
1722 cancelled = true;
1723 };
1724 }
1725 void app.ListProjectTree()
1726 .then((tree) => {
1727 if (!cancelled) setActiveTopicTurns(activeTopicTurnsFromTree(asArray(tree), activeTab));
1728 })
1729 .catch(() => {
1730 if (!cancelled) setActiveTopicTurns(undefined);
1731 });
1732 return () => {
1733 cancelled = true;
1734 };
1735 }, [activeTab?.scope, activeTab?.topicId, activeTab?.workspaceRoot, projectRevision]);
1736 const sessionTurns = useMemo(() => {
1737 const visibleUserTurns = state.items.reduce((count, item) => (item.kind === "user" ? count + 1 : count), 0);
1738 const currentTabTurns = Math.max(state.checkpoints.length, visibleUserTurns);
1739 return currentTabTurns > 0 ? currentTabTurns : activeTopicTurns ?? 0;
1740 }, [activeTopicTurns, state.checkpoints.length, state.items]);
1741 const startupSplashHold = !activeTabId && state.meta?.ready !== true && !state.meta?.startupErr;
1742 const activeComposerProfile = activeTabId ? composerProfilesByTab[activeTabId] : undefined;
1743 const backendActiveComposerProfile = useMemo(() => {
1744 if (state.meta) {
1745 return composerProfileFromMeta(
1746 state.meta,
1747 activeTab ? composerProfileMode(composerProfileFromTab(activeTab, activeComposerProfile?.toolApprovalMode)) : undefined,
1748 activeComposerProfile?.toolApprovalMode,
1749 );
1750 }
1751 return composerProfileFromTab(activeTab, activeComposerProfile?.toolApprovalMode);
1752 }, [activeComposerProfile?.toolApprovalMode, activeTab, state.meta]);
1753 const composerProfile = activeTabId
1754 ? activeComposerProfile ?? backendActiveComposerProfile
1755 : defaultComposerProfile;
1756 const goal = composerProfile.goal;
1757 const collaborationMode = displayedComposerProfileCollaborationMode(composerProfile);
1758 const toolApprovalMode = composerProfile.toolApprovalMode;
1759 const tokenMode: TokenMode = composerProfile.tokenMode;
1760 const runtimeTransitioning = Boolean(activeTabId && runtimeTransitionsByTab[activeTabId]);
1761 const controllerReady =
1762 state.meta?.ready === true &&
1763 (!state.meta.runtime || state.meta.runtime.phase === "ready") &&
1764 !state.meta.startupErr &&
1765 !state.backendActivationPending &&
1766 !runtimeTransitioning;
1767 // Single footer decision surface. Composer stays mounted underneath and is
1768 // only visually/a11y-hidden so per-session draft caches survive.
1769 const decisionSurface = useMemo((): DecisionSurfaceKind | null => {
1770 if (state.approval) {
1771 return state.approval.tool === "exit_plan_mode" ? "plan_approval" : "tool_approval";
1772 }
1773 if (state.ask) return "ask";
1774 if (state.extensionForm) return "extension_form";
1775 if (workspaceConflict) return "workspace_conflict";
1776 if (pendingModeSwitch) return "mode_jobs";
1777 if (pendingClose) return "close_active";
1778 if (clearContextPending) return "clear_context";
1779 return null;
1780 }, [clearContextPending, pendingClose, pendingModeSwitch, state.approval, state.ask, state.extensionForm, workspaceConflict]);
1781 decisionSurfaceRef.current = decisionSurface;
1782 useEffect(() => {
1783 // Close composer menus/popovers when a decision takes over the footer.
1784 if (decisionSurface) {
1785 closeTransientOverlays();
1786 prevDecisionSurfaceRef.current = decisionSurface;
1787 return;
1788 }
1789 // Restore composer focus on the next frame only if the tab did not switch
1790 // and no new decision arrived (remote resolution / rapid consecutive prompts).
1791 const hadDecision = prevDecisionSurfaceRef.current != null;
1792 prevDecisionSurfaceRef.current = null;
1793 if (!hadDecision) return;
1794 const tabAtRelease = activeTabId;
1795 const frame = requestAnimationFrame(() => {
1796 if (decisionSurfaceRef.current != null) return;
1797 if (activeTabIdRef.current !== tabAtRelease) return;
1798 const input = document.getElementById("composer-input") as HTMLTextAreaElement | null;
1799 input?.focus({ preventScroll: true });
1800 });
1801 return () => cancelAnimationFrame(frame);
1802 }, [activeTabId, closeTransientOverlays, decisionSurface]);
1803
1804 // Extension form surface (stage 8b2): submit delivers the structured values
1805 // to the owning sidecar; cancel reports values{"cancelled": true} over the
1806 // same channel. A failed cancel still dismisses — the sidecar that could not
1807 // be reached is gone either way.
1808 const [extensionFormBusy, setExtensionFormBusy] = useState(false);
1809 const submitExtensionForm = useCallback(async (values: Record<string, unknown>) => {
1810 const pending = state.extensionForm;
1811 if (!pending || !activeTabId || extensionFormBusy) return;
1812 setExtensionFormBusy(true);
1813 try {
1814 await app.SubmitExtensionForm(activeTabId, pending.pluginId, pending.surfaceId, values);
1815 dismissExtensionForm();
1816 } catch (err) {
1817 showToast(err instanceof Error ? err.message : String(err), "error");
1818 } finally {
1819 setExtensionFormBusy(false);
1820 }
1821 }, [activeTabId, dismissExtensionForm, extensionFormBusy, showToast, state.extensionForm]);
1822 const cancelExtensionForm = useCallback(async () => {
1823 const pending = state.extensionForm;
1824 if (!pending || extensionFormBusy) return;
1825 setExtensionFormBusy(true);
1826 try {
1827 if (activeTabId) {
1828 await app.SubmitExtensionForm(activeTabId, pending.pluginId, pending.surfaceId, { cancelled: true }).catch(() => {});
1829 }
1830 dismissExtensionForm();
1831 } finally {
1832 setExtensionFormBusy(false);
1833 }
1834 }, [activeTabId, dismissExtensionForm, extensionFormBusy, state.extensionForm]);
1835
1836 // Extension notifications queue in per-tab state (the reducer cannot reach
1837 // the toast context); drain the active tab's queue into toasts here.
1838 useEffect(() => {
1839 const pending = state.extensionNotifications;
1840 if (!pending || pending.length === 0) return;
1841 for (const notification of pending) {
1842 const level = notification.severity === "error" ? "error" : notification.severity === "warn" ? "warn" : "info";
1843 showToast(notification.body ? `${notification.title} — ${notification.body}` : notification.title, level);
1844 }
1845 drainExtensionNotifications();
1846 }, [state.extensionNotifications, showToast, drainExtensionNotifications]);
1847 const extensionStatusList = useMemo(() => Object.values(state.extensionStatuses ?? {}), [state.extensionStatuses]);
1848 const patchActiveComposerProfile = useCallback(
1849 (patch: Partial<Omit<ComposerProfile, "pending">>, pendingFields: ComposerProfileField[]) => {
1850 if (!activeTabId) return;
1851 setComposerProfilesByTab((current) => patchComposerProfile(current, activeTabId, composerProfile, patch, pendingFields));
1852 },
1853 [activeTabId, composerProfile],
1854 );
1855 const patchComposerProfileForTab = useCallback(
1856 (tabId: string, patch: Partial<Omit<ComposerProfile, "pending">>, pendingFields: ComposerProfileField[]) => {
1857 if (!tabId) return;
1858 setComposerProfilesByTab((current) => {
1859 const base = current[tabId] ?? composerProfileFromTab(tabMetas.find((tab) => tab.id === tabId));
1860 return patchComposerProfile(current, tabId, base, patch, pendingFields);
1861 });
1862 },
1863 [tabMetas],
1864 );
1865 const topicbarEditing = Boolean(activeTab?.topicId && activeTab.topicId === renamingTopicId);
1866 const visibleTabId = activeTabId;
1867 const visibleTabs = useMemo(() => {
1868 const byId = new Map(tabMetas.map((tab) => [tab.id, tab]));
1869 const ordered = tabOrderIds.map((id) => byId.get(id)).filter((tab): tab is TabMeta => Boolean(tab));
1870 const missing = tabMetas.filter((tab) => !tabOrderIds.includes(tab.id));
1871 return [...ordered, ...missing].map((tab) => {
1872 const profile = composerProfilesByTab[tab.id] ?? composerProfileFromTab(tab);
1873 return {
1874 ...tab,
1875 running: tab.id === visibleTabId ? tab.running || state.running : tab.running,
1876 mode: composerProfileMode(profile),
1877 collaborationMode: displayedComposerProfileCollaborationMode(profile),
1878 toolApprovalMode: profile.toolApprovalMode,
1879 tokenMode: profile.tokenMode,
1880 goal: profile.goal,
1881 active: tab.id === visibleTabId,
1882 };
1883 });
1884 }, [composerProfilesByTab, state.running, tabMetas, tabOrderIds, visibleTabId]);
1885
1886 useEffect(() => {
1887 const ids = tabMetas.map((tab) => tab.id);
1888 setTabOrderIds((current) => {
1889 const next = current.filter((id) => ids.includes(id));
1890 for (const id of ids) {
1891 if (!next.includes(id)) next.push(id);
1892 }
1893 return next.join("\u0000") === current.join("\u0000") ? current : next;
1894 });
1895 }, [tabMetas]);
1896
1897 useEffect(() => {
1898 const ids = new Set(tabMetas.map((tab) => tab.id));
1899 for (const id of Object.keys(yoloRestoreToolApprovalModesRef.current)) {
1900 if (!ids.has(id)) delete yoloRestoreToolApprovalModesRef.current[id];
1901 }
1902 userPlanModeByTabRef.current = pruneUserPlanModeIntents(userPlanModeByTabRef.current, ids);
1903 setComposerProfilesByTab((current) => hydrateComposerProfilesFromTabs(current, tabMetas));
1904 }, [tabMetas]);
1905
1906 useEffect(() => {
1907 if (!renamingTopicId || activeTab?.topicId === renamingTopicId) return;
1908 topicRenameSkipCommitRef.current = false;
1909 topicRenameCommitHandledRef.current = false;
1910 setRenamingTopicId(null);
1911 setTopicTitleDraft("");
1912 }, [activeTab?.topicId, renamingTopicId]);
1913
1914 useEffect(() => {
1915 if (!activeTabId || !state.meta) return;
1916 setComposerProfilesByTab((current) => hydrateComposerProfileFromMeta(current, activeTabId, state.meta!));
1917 }, [activeTabId, state.meta]);
1918
1919 const syncModeToController = useCallback((m: Mode) => setControllerMode(m), [setControllerMode]);
1920
1921 useEffect(() => {
1922 void app.SetTrayLocale(locale).catch(() => {});
1923 }, [locale]);
1924
1925 // applyMode is the single source of truth for the input mode: it updates the
1926 // local pill and pushes the matching gate state to the controller (plan = read
1927 // only; yolo = auto-approve approval-gated tools while user decisions still wait).
1928 // normal clears both.
1929 const applyMode = useCallback(
1930 (m: Mode) => {
1931 userPlanModeByTabRef.current = updateUserPlanModeIntent(userPlanModeByTabRef.current, activeTabId, modeHasPlan(m));
1932 patchActiveComposerProfile(composerProfileWithMode(m), ["collaborationMode", "toolApprovalMode", "goal"]);
1933 void syncModeToController(m);
1934 },
1935 [activeTabId, patchActiveComposerProfile, syncModeToController],
1936 );
1937 const applyCollaborationMode = useCallback(
1938 async (m: CollaborationMode): Promise<void> => {
1939 if (m === "goal") {
1940 userPlanModeByTabRef.current = updateUserPlanModeIntent(userPlanModeByTabRef.current, activeTabId, false);
1941 patchActiveComposerProfile({ collaborationMode: "normal", goalDraftMode: true, goal: "" }, ["collaborationMode", "goal"]);
1942 return setControllerCollaborationMode("normal");
1943 }
1944 if (goal.trim()) await clearControllerGoal();
1945 await setControllerCollaborationMode(m);
1946 userPlanModeByTabRef.current = updateUserPlanModeIntent(userPlanModeByTabRef.current, activeTabId, m === "plan");
1947 patchActiveComposerProfile({ collaborationMode: m, goalDraftMode: false, goal: "" }, ["collaborationMode", "goal"]);
1948 },
1949 [activeTabId, clearControllerGoal, goal, patchActiveComposerProfile, setControllerCollaborationMode],
1950 );
1951 const applyToolApprovalMode = useCallback(
1952 (m: ToolApprovalMode) => {
1953 if (!activeTabId) return;
1954 if (m === "yolo") {
1955 if (toolApprovalMode !== "yolo") {
1956 yoloRestoreToolApprovalModesRef.current[activeTabId] = restorableToolApprovalMode(toolApprovalMode);
1957 }
1958 } else {
1959 yoloRestoreToolApprovalModesRef.current[activeTabId] = restorableToolApprovalMode(m);
1960 }
1961 patchActiveComposerProfile({ toolApprovalMode: m }, ["toolApprovalMode"]);
1962 void setControllerToolApprovalMode(m);
1963 },
1964 [activeTabId, patchActiveComposerProfile, setControllerToolApprovalMode, toolApprovalMode],
1965 );
1966 const toggleYoloApprovalMode = useCallback(() => {
1967 if (!activeTabId) return;
1968 const next = toggleYoloToolApprovalMode(
1969 toolApprovalMode,
1970 yoloRestoreToolApprovalModesRef.current[activeTabId],
1971 );
1972 if (next.restore) {
1973 yoloRestoreToolApprovalModesRef.current[activeTabId] = next.restore;
1974 }
1975 applyToolApprovalMode(next.mode);
1976 }, [activeTabId, applyToolApprovalMode, toolApprovalMode]);
1977 const patchActivatedGoalForTab = useCallback(
1978 (tabId: string, nextGoal: string): void => {
1979 const trimmed = nextGoal.trim();
1980 patchComposerProfileForTab(tabId, {
1981 collaborationMode: trimmed ? "goal" : "normal",
1982 goalDraftMode: false,
1983 goal: trimmed,
1984 }, ["collaborationMode", "goal"]);
1985 userPlanModeByTabRef.current = updateUserPlanModeIntent(userPlanModeByTabRef.current, tabId, false);
1986 },
1987 [patchComposerProfileForTab],
1988 );
1989 const applyGoalForTab = useCallback(
1990 async (tabId: string, nextGoal: string): Promise<void> => {
1991 if (!tabId) return;
1992 const trimmed = nextGoal.trim();
1993 // Activate the backend Goal first. Only then patch the local profile so a
1994 // failed SetGoalForTab cannot leave the Composer thinking a Goal is active.
1995 await (trimmed ? setControllerGoalForTab(tabId, trimmed) : clearControllerGoalForTab(tabId));
1996 patchActivatedGoalForTab(tabId, trimmed);
1997 },
1998 [clearControllerGoalForTab, patchActivatedGoalForTab, setControllerGoalForTab],
1999 );
2000 const applyGoal = useCallback(
2001 async (nextGoal: string): Promise<void> => {
2002 if (!activeTabId) return;
2003 await applyGoalForTab(activeTabId, nextGoal);
2004 },
2005 [activeTabId, applyGoalForTab],
2006 );
2007 const commitTokenModeSwitch = useCallback(
2008 async (tabId: string, m: TokenMode, previous: TokenMode, profile: ComposerProfile): Promise<void> => {
2009 if (!tabId || activeTabIdRef.current !== tabId || runtimeTransitionTabsRef.current.has(tabId)) return;
2010 runtimeTransitionTabsRef.current.add(tabId);
2011 setRuntimeTransitionsByTab((current) => ({ ...current, [tabId]: true }));
2012 setComposerProfilesByTab((current) => patchComposerProfile(current, tabId, profile, { tokenMode: m }, ["tokenMode"]));
2013 const switched = await setTokenMode(m);
2014 if (!switched) {
2015 setComposerProfilesByTab((current) => {
2016 const currentProfile = current[tabId] ?? profile;
2017 const pending = { ...currentProfile.pending };
2018 delete pending.tokenMode;
2019 return { ...current, [tabId]: { ...currentProfile, tokenMode: previous, pending } };
2020 });
2021 }
2022 runtimeTransitionTabsRef.current.delete(tabId);
2023 setRuntimeTransitionsByTab((current) => {
2024 if (!current[tabId]) return current;
2025 const next = { ...current };
2026 delete next[tabId];
2027 return next;
2028 });
2029 },
2030 [setTokenMode],
2031 );
2032 const applyTokenMode = useCallback(
2033 async (m: TokenMode): Promise<void> => {
2034 const tabId = activeTabId;
2035 if (!tabId || runtimeTransitionTabsRef.current.has(tabId) || m === composerProfile.tokenMode) return;
2036 const previous = composerProfile.tokenMode;
2037 try {
2038 const work = await app.ActiveWorkForTab(tabId);
2039 if (work.jobs.length > 0) {
2040 setPendingModeSwitch({ tabId, target: m, previous, work, stopping: false });
2041 return;
2042 }
2043 } catch {
2044 // The atomic backend guard remains authoritative on older runtimes.
2045 }
2046 await commitTokenModeSwitch(tabId, m, previous, composerProfile);
2047 },
2048 [activeTabId, commitTokenModeSwitch, composerProfile],
2049 );
2050 const stopJobsAndSwitchMode = useCallback(async () => {
2051 const request = pendingModeSwitch;
2052 if (!request || request.stopping) return;
2053 setPendingModeSwitch({ ...request, stopping: true });
2054 try {
2055 await app.CancelJobsForTab(request.tabId, request.work.jobs.map((job) => job.id));
2056 const deadline = Date.now() + 15_000;
2057 let work = await app.ActiveWorkForTab(request.tabId);
2058 while (work.jobs.length > 0 && Date.now() < deadline) {
2059 setPendingModeSwitch((current) => current?.tabId === request.tabId ? { ...current, work } : current);
2060 await new Promise((resolve) => window.setTimeout(resolve, 150));
2061 work = await app.ActiveWorkForTab(request.tabId);
2062 }
2063 if (work.jobs.length > 0) {
2064 setPendingModeSwitch((current) => current?.tabId === request.tabId ? { ...current, work, stopping: false } : current);
2065 showToast(t("status.jobStopFailed"), "error");
2066 return;
2067 }
2068 setPendingModeSwitch(null);
2069 await refreshBackgroundRuntimes();
2070 if (activeTabIdRef.current === request.tabId) {
2071 await commitTokenModeSwitch(request.tabId, request.target, request.previous, composerProfile);
2072 }
2073 } catch (err) {
2074 setPendingModeSwitch((current) => current?.tabId === request.tabId ? { ...current, stopping: false } : current);
2075 showToast(err instanceof Error ? err.message : String(err), "error");
2076 }
2077 }, [commitTokenModeSwitch, composerProfile, pendingModeSwitch, refreshBackgroundRuntimes, showToast, t]);
2078 const cancelRuntimeJob = useCallback(async (tabId: string, jobId: string): Promise<boolean> => {
2079 try {
2080 const cancelled = await app.CancelJobForTab(tabId, jobId);
2081 await refreshBackgroundRuntimes();
2082 return cancelled;
2083 } catch (err) {
2084 showToast(err instanceof Error ? err.message : String(err), "error");
2085 return false;
2086 }
2087 }, [refreshBackgroundRuntimes, showToast]);
2088 // Shift+Tab toggles only the collaboration axis; Ctrl/Cmd+Y toggles YOLO on the
2089 // tool-permission axis while preserving the Ask/Auto base mode.
2090 const cycleMode = useCallback(() => {
2091 runGoalAction(() => applyCollaborationMode(collaborationMode === "plan" ? "normal" : "plan"));
2092 }, [applyCollaborationMode, collaborationMode, runGoalAction]);
2093
2094 // Switching models rebuilds the controller, which starts in normal mode — so
2095 // re-apply the current mode, or the pill would say plan/YOLO while the fresh
2096 // controller silently uses normal gating.
2097 const switchModel = useCallback(
2098 async (name: string) => {
2099 const switched = await setModel(name);
2100 if (!switched) return false;
2101 if (!activeTabId) return false;
2102 const profileApplied = await setControllerComposerProfileForTab(
2103 activeTabId,
2104 controllerComposerProfileCollaborationMode(composerProfile),
2105 toolApprovalMode,
2106 goal,
2107 { propagateError: true },
2108 );
2109 return profileApplied;
2110 },
2111 [activeTabId, composerProfile, goal, setControllerComposerProfileForTab, setModel, toolApprovalMode],
2112 );
2113
2114 // Startup and workspace/model rebuilds create a fresh controller in normal
2115 // mode. Re-apply the UI mode once the controller is ready, including the case
2116 // where the user picked YOLO while boot was still loading and the legacy
2117 // SetBypass binding was a harmless no-op.
2118 useEffect(() => {
2119 if (!controllerReady || !activeTabId) return;
2120 runGoalAction(async () => {
2121 await setControllerComposerProfileForTab(
2122 activeTabId,
2123 controllerComposerProfileCollaborationMode(composerProfile),
2124 toolApprovalMode,
2125 goal,
2126 { propagateError: true },
2127 );
2128 });
2129 }, [activeTabId, composerProfile, controllerReady, goal, runGoalAction, setControllerComposerProfileForTab, toolApprovalMode]);
2130
2131 // The live task list pinned above the composer comes from the most recent
2132 // successful top-level todo_write result; failed or still-running attempts do
2133 // not advance the canonical panel state. Incomplete lists are always shown so
2134 // a stale local dismissal cannot hide work that still blocks final readiness;
2135 // every new list starts collapsed while its header keeps showing live progress
2136 // and the current task; completed lists can then be dismissed. The dismissal
2137 // key is still based on stable todo content/state so history reloads
2138 // do not resurrect the same finished list under a different event id. The
2139 // batch key ignores status changes so progress within the same task list does
2140 // not look like a brand-new task batch. Dismissal and open state are scoped to
2141 // the active session/topic/tab so different projects and sessions do not hide
2142 // or reopen each other's todo panels.
2143 const todoEntry = useMemo(() => {
2144 for (let i = state.items.length - 1; i >= 0; i--) {
2145 const it = state.items[i];
2146 if (it.kind === "tool" && it.name === "todo_write" && !it.parentId && it.status === "done" && !it.error) {
2147 return { item: it, index: i };
2148 }
2149 }
2150 return null;
2151 }, [state.items]);
2152 const todoItem = todoEntry?.item ?? null;
2153 const metaTodos = state.meta?.canonicalTodos;
2154 const todos = useMemo(
2155 () => resolveTodoPanelTodos(metaTodos, todoItem ? parseTodos(todoItem.args) : undefined),
2156 [metaTodos, todoItem],
2157 );
2158 const [dismissedTodoKeys, setDismissedTodoKeys] = useState<Set<string>>(loadDismissedTodoKeys);
2159 const todoKey = useMemo(() => todoDismissalKey(todos), [todos]);
2160 const todoBatch = useMemo(() => todoBatchKey(todos), [todos]);
2161 const todoScope = useMemo(
2162 () => todoPanelScope({ activeTab, activeTabId, eventChannel: state.meta?.eventChannel }),
2163 [activeTab, activeTabId, state.meta?.eventChannel],
2164 );
2165 const dismissedTodo = useMemo(
2166 () => dismissedTodoKeyForScope(todoScope, dismissedTodoKeys, todoKey),
2167 [dismissedTodoKeys, todoKey, todoScope],
2168 );
2169 const scopedTodoKey = useMemo(() => scopedTodoDismissalKey(todoScope, todoKey), [todoKey, todoScope]);
2170 const scopedTodoBatch = useMemo(() => scopedTodoBatchKey(todoScope, todoBatch), [todoBatch, todoScope]);
2171 const showTodos = shouldShowTodoPanel(todoKey, dismissedTodo, todos);
2172 const dismissTodos = useCallback(() => {
2173 if (!scopedTodoKey) return;
2174 setDismissedTodoKeys((current) => {
2175 if (current.has(scopedTodoKey)) return current;
2176 const next = new Set(current);
2177 next.add(scopedTodoKey);
2178 saveDismissedTodoKeys(next);
2179 return next;
2180 });
2181 }, [scopedTodoKey]);
2182
2183 const sessionTitle = topicTitle(activeTab);
2184 const sessionHasContent = state.items.length > 0 || Boolean(state.live?.text || state.live?.reasoning);
2185
2186 // Theme pack scene: home when the session is empty, task once content exists.
2187 useEffect(() => {
2188 applyThemeScene(sessionHasContent ? "task" : "home");
2189 }, [sessionHasContent]);
2190 const getSessionMarkdown = useCallback(
2191 () => sessionItemsToMarkdown(sessionTitle, state.items, liveStore.getSnapshot(activeTabId) ?? state.live),
2192 [activeTabId, liveStore, sessionTitle, state.items, state.live],
2193 );
2194 const getSessionJson = useCallback(
2195 () => sessionItemsToJson(sessionTitle, state.items, liveStore.getSnapshot(activeTabId) ?? state.live),
2196 [activeTabId, liveStore, sessionTitle, state.items, state.live],
2197 );
2198
2199 useEffect(() => {
2200 if (!topicExportOpen) return;
2201 const onDown = (event: MouseEvent) => {
2202 const target = event.target as Element | null;
2203 if (!target?.closest(".topicbar__export")) setTopicExportOpen(false);
2204 };
2205 document.addEventListener("mousedown", onDown);
2206 return () => document.removeEventListener("mousedown", onDown);
2207 }, [topicExportOpen]);
2208
2209 const exportSession = useCallback(
2210 async (format: "markdown" | "json" | "pdf" | "image") => {
2211 const base = safeFilename(sessionTitle);
2212 setTopicExportOpen(false);
2213 try {
2214 if (format === "json") {
2215 const path = await app.PickExportFile(`${base}.json`, "application/json");
2216 if (path) {
2217 await app.SaveExportFile(path, getSessionJson(), false);
2218 showToast(t("topicBar.exportSuccess", { count: 1 }), "info");
2219 }
2220 } else if (format === "pdf") {
2221 const path = await app.PickExportFile(`${base}.pdf`, "application/pdf");
2222 if (!path) return;
2223 const { blobToBase64, renderSessionPdfBlob } = await import("./lib/sessionExport");
2224 const blob = await renderSessionPdfBlob(getSessionMarkdown(), sessionTitle);
2225 await app.SaveExportFile(path, await blobToBase64(blob), true);
2226 showToast(t("topicBar.exportSuccess", { count: 1 }), "info");
2227 } else if (format === "image") {
2228 const path = await app.PickExportFile(`${base}.png`, "image/png");
2229 if (!path) return;
2230 const { renderSessionImageBase64Payloads } = await import("./lib/sessionExport");
2231 const payloads = await renderSessionImageBase64Payloads(getSessionMarkdown());
2232 await app.SaveExportImageFiles(path, payloads);
2233 showToast(
2234 payloads.length > 1
2235 ? t("topicBar.exportImageParts", { count: payloads.length })
2236 : t("topicBar.exportSuccess", { count: 1 }),
2237 "info",
2238 );
2239 } else {
2240 const path = await app.PickExportFile(`${base}.md`, "text/markdown");
2241 if (path) {
2242 await app.SaveExportFile(path, getSessionMarkdown(), false);
2243 showToast(t("topicBar.exportSuccess", { count: 1 }), "info");
2244 }
2245 }
2246 } catch (err) {
2247 console.error("Failed to export session", err);
2248 showToast(
2249 t("topicBar.exportFailed", { error: err instanceof Error ? err.message : String(err) }),
2250 "error",
2251 { durationMs: 8000 },
2252 );
2253 }
2254 },
2255 [getSessionJson, getSessionMarkdown, sessionTitle, showToast, t],
2256 );
2257
2258 useEffect(() => {
2259 if (!activeTabId || state.running) return;
2260 const text = pendingPlanRevisionsByTab[activeTabId];
2261 if (!text || pendingPlanRevisionSendingTabsRef.current.has(activeTabId)) return;
2262 pendingPlanRevisionSendingTabsRef.current.add(activeTabId);
2263 void commitThenSendRef.current(activeTabId, text)
2264 .then(() => {
2265 setPendingPlanRevisionsByTab((current) => {
2266 if (current[activeTabId] !== text) return current;
2267 const next = { ...current };
2268 delete next[activeTabId];
2269 return next;
2270 });
2271 })
2272 .catch((err) => {
2273 console.warn("Failed to submit pending plan revision", err);
2274 })
2275 .finally(() => {
2276 pendingPlanRevisionSendingTabsRef.current.delete(activeTabId);
2277 });
2278 }, [activeTabId, pendingPlanRevisionsByTab, state.running]);
2279
2280 useEffect(() => {
2281 setClearContextPending(false);
2282 setWorkspaceInsertTarget("composer");
2283 }, [activeTabId]);
2284
2285 const cancelClearContext = useCallback(() => {
2286 setClearContextPending(false);
2287 }, []);
2288
2289 const confirmClearContext = useCallback(async () => {
2290 setClearContextPending(false);
2291 try {
2292 await clearSession();
2293 setDockRefreshKey((v) => v + 1);
2294 notice(t("clearContext.done"));
2295 } catch (err) {
2296 const msg = err instanceof Error ? err.message : String(err);
2297 notice(msg || t("clearContext.failed"), "warn");
2298 }
2299 }, [clearSession, notice, t]);
2300
2301 useEffect(() => {
2302 activeTabIdRef.current = activeTabId;
2303 }, [activeTabId]);
2304
2305 // handleSend intercepts slash commands that need a desktop-native action before
2306 // they reach the backend: "/model <ref>" rebuilds on that model, "/memory"
2307 // opens Settings, and "/clear" shows an in-app confirmation card. Everything else — skills (/init, …),
2308 // custom commands, bare /model and the other read-only management verbs
2309 // (/skill, /hooks, /mcp) — goes straight to Submit, which the controller
2310 // resolves (a turn, or a listing Notice).
2311 const handleSend = useCallback(
2312 async (displayText: string, submitText = displayText, requestedTabId = activeTabId, structured?: StructuredInvocationSubmit) => {
2313 const sourceTabId = requestedTabId || activeTabId;
2314 if (!sourceTabId) throw new Error(t("composer.workspaceStarting"));
2315 const trimmed = displayText.trim();
2316 // "!<cmd>" runs a shell command directly, bypassing the model.
2317 if (trimmed.startsWith("!")) {
2318 const cmd = trimmed.slice(1).trim();
2319 if (!cmd) {
2320 notice("usage: !<command> (e.g. !ls -la)");
2321 return;
2322 }
2323 await runShellForTab(sourceTabId, cmd);
2324 return;
2325 }
2326 const model = /^\/model\s+(\S+)$/.exec(trimmed);
2327 if (model) {
2328 await switchModel(model[1]);
2329 return;
2330 }
2331 if (trimmed === "/memory") {
2332 if (activeTabIdRef.current !== sourceTabId) return;
2333 closeTransientOverlays();
2334 setSettingsTarget("memory");
2335 return;
2336 }
2337 if (trimmed === "/clear") {
2338 if (activeTabIdRef.current !== sourceTabId) return;
2339 setClearContextPending(true);
2340 return;
2341 }
2342 const decisionMock = typeof window !== "undefined" && !window.runtime
2343 ? decisionSurfaceMockFromInput(trimmed)
2344 : null;
2345 if (decisionMock === "workspace_conflict" || decisionMock === "mode_jobs" || decisionMock === "close_active" || decisionMock === "clear_context") {
2346 if (activeTabIdRef.current !== sourceTabId) return;
2347 closeTransientOverlays();
2348 setWorkspaceConflict(null);
2349 setPendingModeSwitch(null);
2350 setPendingClose(null);
2351 setClearContextPending(false);
2352 const mockWork: ActiveWorkView = {
2353 running: true,
2354 pendingPrompt: false,
2355 cancellable: true,
2356 jobs: [
2357 { id: "mock-decision-build", kind: "bash", label: "pnpm build", status: "running", startedAt: Date.now() - 42_000 },
2358 { id: "mock-decision-test", kind: "bash", label: "go test ./...", status: "running", startedAt: Date.now() - 18_000 },
2359 ],
2360 };
2361 if (decisionMock === "workspace_conflict") {
2362 setWorkspaceConflict({
2363 state: "local",
2364 ownerTabId: "mock-workspace-writer",
2365 ownerTitle: t("mock.topicDevStandard"),
2366 ownerWork: mockWork,
2367 canReveal: true,
2368 canCreateWorktree: true,
2369 });
2370 } else if (decisionMock === "mode_jobs") {
2371 setPendingModeSwitch({
2372 tabId: sourceTabId,
2373 target: tokenMode === "delivery" ? "full" : "delivery",
2374 previous: tokenMode,
2375 work: mockWork,
2376 stopping: false,
2377 });
2378 } else if (decisionMock === "close_active") {
2379 setPendingClose({ tabId: sourceTabId, work: mockWork, stopping: false });
2380 } else {
2381 setClearContextPending(true);
2382 }
2383 return;
2384 }
2385 const goalCommand = /^\/goal(?:\s+(.*))?$/.exec(trimmed);
2386 if (goalCommand) {
2387 const arg = (goalCommand[1] ?? "").trim();
2388 const displayGoal = stripGoalResearchFlags(arg);
2389 if (displayGoal && !["status", "clear", "off", "stop", "done"].includes(displayGoal.toLowerCase())) {
2390 if (hasGoalResearchFlag(arg)) {
2391 userPlanModeByTabRef.current = updateUserPlanModeIntent(userPlanModeByTabRef.current, activeTabId, false);
2392 patchActiveComposerProfile({
2393 collaborationMode: "goal",
2394 goalDraftMode: false,
2395 goal: displayGoal,
2396 }, ["collaborationMode", "goal"]);
2397 } else {
2398 await applyGoal(displayGoal);
2399 }
2400 } else if (["clear", "off", "stop", "done"].includes(displayGoal.toLowerCase())) {
2401 await applyGoal("");
2402 }
2403 if (!controllerReady) return;
2404 await commitThenSendRef.current(sourceTabId, trimmed, submitText.trim());
2405 return;
2406 }
2407 if (collaborationMode === "goal" && !goal.trim()) {
2408 if (!controllerReady) return;
2409 await activateGoalAndSubmitOnTab({
2410 tabId: sourceTabId,
2411 displayText: trimmed,
2412 submitText,
2413 structured,
2414 sendToTab: (tabId, nextGoal, display, routedSubmit, routedStructured) =>
2415 commitThenSendRef.current(
2416 tabId,
2417 display,
2418 routedSubmit,
2419 routedStructured,
2420 {
2421 goal: nextGoal,
2422 collaborationMode: controllerComposerProfileCollaborationMode(composerProfile),
2423 toolApprovalMode,
2424 },
2425 ),
2426 });
2427 patchActivatedGoalForTab(sourceTabId, trimmed);
2428 return;
2429 }
2430 const theme = /^\/theme(?:\s+(\S+))?$/.exec(trimmed);
2431 if (theme) {
2432 const arg = theme[1]?.toLowerCase();
2433 if (!arg) {
2434 const cur = getTheme();
2435 notice(t("settings.themeCurrent", { theme: cur, style: getThemeStyle(cur) }));
2436 return;
2437 }
2438 if (arg === "reset" || arg === "default" || arg === "clear") {
2439 try {
2440 await app.ResetThemePack();
2441 clearThemePack();
2442 notice(t("settings.themeReset"));
2443 } catch (err) {
2444 showToast(err instanceof Error ? err.message : String(err), "error");
2445 }
2446 return;
2447 }
2448 if (isThemeMode(arg)) {
2449 const next = arg;
2450 const style = getThemeStyle(next);
2451 try {
2452 await app.SetDesktopAppearance(next, style);
2453 applyTheme(next, style);
2454 notice(t("settings.themeChanged", { theme: next, style }));
2455 } catch (err) {
2456 showToast(err instanceof Error ? err.message : String(err), "error");
2457 }
2458 return;
2459 }
2460 if (isThemeStyle(arg)) {
2461 const cur = getTheme();
2462 try {
2463 await app.SetDesktopAppearance(cur, arg);
2464 applyTheme(cur, arg);
2465 notice(t("settings.themeChanged", { theme: cur, style: arg }));
2466 } catch (err) {
2467 showToast(err instanceof Error ? err.message : String(err), "error");
2468 }
2469 return;
2470 }
2471 notice(t("settings.themeUnknown", { name: arg }), "warn");
2472 return;
2473 }
2474 if (!controllerReady) return;
2475 const profileApplied = await setControllerComposerProfileForTab(
2476 sourceTabId,
2477 controllerComposerProfileCollaborationMode(composerProfile),
2478 toolApprovalMode,
2479 goal,
2480 );
2481 if (!profileApplied) return;
2482 await commitThenSendRef.current(sourceTabId, trimmed, submitText.trim(), structured);
2483 },
2484 [activeTabId, applyGoal, closeTransientOverlays, collaborationMode, composerProfile, controllerReady, goal, notice, runShellForTab,
2485 patchActivatedGoalForTab, setControllerComposerProfileForTab, switchModel, t, tokenMode, toolApprovalMode, showToast],
2486 );
2487
2488 const handleSteer = useCallback(async (text: string, requestedTabId = activeTabId) => {
2489 const sourceTabId = requestedTabId || activeTabId;
2490 if (!sourceTabId) throw new Error(t("composer.workspaceStarting"));
2491 await steerForTab(sourceTabId, text.trim());
2492 }, [activeTabId, steerForTab, t]);
2493
2494 const setCollaborationModeFromUi = useCallback((mode: CollaborationMode) => {
2495 runGoalAction(() => applyCollaborationMode(mode));
2496 }, [applyCollaborationMode, runGoalAction]);
2497 const clearGoalFromUi = useCallback(() => {
2498 runGoalAction(() => applyGoal(""));
2499 }, [applyGoal, runGoalAction]);
2500 const pauseGoalFromUi = useCallback(() => {
2501 runGoalAction(async () => {
2502 if (!activeTabIdRef.current) return;
2503 await pauseControllerGoalForTab(activeTabIdRef.current);
2504 });
2505 }, [pauseControllerGoalForTab, runGoalAction]);
2506 const resumeGoalFromUi = useCallback(() => {
2507 runGoalAction(async () => {
2508 if (!activeTabIdRef.current) return;
2509 await resumeControllerGoalForTab(activeTabIdRef.current);
2510 });
2511 }, [resumeControllerGoalForTab, runGoalAction]);
2512 const switchModelFromUi = useCallback(async (name: string): Promise<boolean> => {
2513 try {
2514 return await switchModel(name);
2515 } catch (error) {
2516 handleGoalActionError(error);
2517 return false;
2518 }
2519 }, [handleGoalActionError, switchModel]);
2520
2521 const tabMetaRefreshCoordinatorRef = useRef<ReturnType<typeof createBoundedRefreshCoordinator<TabMeta[]>> | null>(null);
2522 if (!tabMetaRefreshCoordinatorRef.current) {
2523 tabMetaRefreshCoordinatorRef.current = createBoundedRefreshCoordinator<TabMeta[]>(TAB_META_MAX_IN_FLIGHT);
2524 }
2525 const refreshTabMetas = useCallback(async (
2526 apply?: () => boolean,
2527 options?: { afterMutation?: boolean },
2528 ): Promise<TabMeta[]> => {
2529 const result = await tabMetaRefreshCoordinatorRef.current!.run(
2530 async () => asArray(await app.ListTabs().catch(() => [] as TabMeta[])),
2531 options?.afterMutation ? { invalidate: true } : undefined,
2532 );
2533 const tabs = result.value;
2534 if (result.latest && (!apply || apply())) {
2535 setTabMetas((current) => sameTabMetaLists(current, tabs) ? current : tabs);
2536 }
2537 return tabs;
2538 }, []);
2539 const seedActiveTabMeta = useCallback((tab: TabMeta): void => {
2540 setTabMetas((current) => {
2541 const seeded = { ...tab, active: true };
2542 let found = false;
2543 const next = current.map((existing) => {
2544 if (existing.id === tab.id) {
2545 found = true;
2546 return { ...existing, ...seeded };
2547 }
2548 return existing.active ? { ...existing, active: false } : existing;
2549 });
2550 return found ? next : [...next, seeded];
2551 });
2552 setTabOrderIds((current) => current.includes(tab.id) ? current : [...current, tab.id]);
2553 }, []);
2554
2555 useEffect(() => {
2556 const unsub = onEvent((e) => {
2557 if (shouldRefreshTabMetaForEvent(e.kind)) {
2558 void refreshTabMetas(undefined, { afterMutation: true });
2559 }
2560 if (e.kind !== "turn_done") return;
2561 const turnTabId = resolvePlanRestoreTabId(e.tabId, activeTabIdRef.current);
2562 window.setTimeout(() => {
2563 setProjectRevision((value) => value + 1);
2564 refreshTabMetas(undefined, { afterMutation: true }).then((tabs) => {
2565 if (!turnTabId) return;
2566 const tab = tabs.find((item) => item.id === turnTabId);
2567 const baseProfile = tab ? composerProfileFromTab(tab) : defaultComposerProfile;
2568 if (!shouldRestoreUserPlanModeForProfile(userPlanModeByTabRef.current, turnTabId, baseProfile)) {
2569 if (baseProfile.goal.trim()) {
2570 userPlanModeByTabRef.current = updateUserPlanModeIntent(userPlanModeByTabRef.current, turnTabId, false);
2571 }
2572 return;
2573 }
2574 setComposerProfilesByTab((current) => patchComposerProfile(
2575 current,
2576 turnTabId,
2577 current[turnTabId] ?? baseProfile,
2578 { collaborationMode: "plan", goalDraftMode: false, goal: "" },
2579 ["collaborationMode", "goal"],
2580 ));
2581 if (activeTabIdRef.current === turnTabId) {
2582 void setControllerCollaborationMode("plan");
2583 }
2584 });
2585 }, 250);
2586 });
2587 return unsub;
2588 }, [refreshTabMetas, setControllerCollaborationMode]);
2589
2590 const blankSessionTarget = useCallback(() => {
2591 const activeWorkspaceRoot = activeTab?.scope === "project" ? activeTab.workspaceRoot || "" : "";
2592 const scope = activeWorkspaceRoot ? "project" : "global";
2593 return { scope, workspaceRoot: activeWorkspaceRoot };
2594 }, [activeTab?.scope, activeTab?.workspaceRoot]);
2595
2596 useEffect(() => {
2597 let cancelled = false;
2598 let timer: number | undefined;
2599 const schedule = () => {
2600 if (cancelled) return;
2601 timer = window.setTimeout(() => {
2602 void refreshTabMetas();
2603 schedule();
2604 }, tabMetaFallbackDelay(document.visibilityState));
2605 };
2606 const refreshAndSchedule = () => {
2607 if (timer !== undefined) window.clearTimeout(timer);
2608 timer = undefined;
2609 void refreshTabMetas();
2610 schedule();
2611 };
2612 const onVisibilityChange = () => {
2613 if (document.visibilityState === "visible") refreshAndSchedule();
2614 else {
2615 if (timer !== undefined) window.clearTimeout(timer);
2616 schedule();
2617 }
2618 };
2619 refreshAndSchedule();
2620 document.addEventListener("visibilitychange", onVisibilityChange);
2621 return () => {
2622 cancelled = true;
2623 if (timer !== undefined) window.clearTimeout(timer);
2624 document.removeEventListener("visibilitychange", onVisibilityChange);
2625 };
2626 }, [refreshTabMetas]);
2627
2628 useEffect(() => {
2629 return onProjectTreeChanged(() => {
2630 setProjectRevision((value) => value + 1);
2631 void refreshTabMetas(undefined, { afterMutation: true });
2632 });
2633 }, [refreshTabMetas]);
2634
2635 // Bridge remote:* events into the remote store once, app-wide, so the
2636 // StatusBar chip, host manager, and explorer all see the same live state.
2637 useEffect(() => {
2638 const offStatus = onRemoteStatus((s) => {
2639 applyRemoteStatus(s);
2640 if (s.state === "stopped" && s.error) requestRemoteStatusPopover(s.hostId);
2641 });
2642 const offForwards = onRemoteForwards((e) => setRemoteForwards(e.hostId, e.forwards));
2643 const offServer = onRemoteServer((s) => setRemoteServer(s));
2644 return () => {
2645 offStatus();
2646 offForwards();
2647 offServer();
2648 };
2649 }, [applyRemoteStatus, requestRemoteStatusPopover, setRemoteForwards, setRemoteServer]);
2650
2651 useEffect(() => {
2652 let cancelled = false;
2653 void app.RemoteHosts()
2654 .then((hosts) => {
2655 if (!cancelled) setRemoteHosts(hosts);
2656 })
2657 .catch(() => {});
2658 void app.RemoteConnectionStatuses()
2659 .then((statuses) => {
2660 if (!cancelled) hydrateRemoteStatuses(statuses);
2661 })
2662 .catch(() => {});
2663 return () => {
2664 cancelled = true;
2665 };
2666 }, [hydrateRemoteStatuses, setRemoteHosts]);
2667
2668 const refreshProviderSetupState = useCallback(async () => {
2669 const needs = await app.NeedsOnboarding();
2670 setProviderSetupNeeded(needs);
2671 return needs;
2672 }, []);
2673
2674 useEffect(() => {
2675 let cancelled = false;
2676 (async () => {
2677 try {
2678 const needs = await app.NeedsOnboarding();
2679 if (!cancelled) {
2680 setProviderSetupNeeded(needs);
2681 setNeedsOnboarding(shouldOpenOnboarding(needs));
2682 }
2683 } catch {
2684 // Bridge unavailable (browser dev seam) — skip the gate; a real key
2685 // failure still surfaces via the topbar startupError banner.
2686 if (!cancelled) setNeedsOnboarding(false);
2687 }
2688 })();
2689 return () => {
2690 cancelled = true;
2691 };
2692 }, [setNeedsOnboarding]);
2693
2694 useEffect(() => {
2695 const el = footerRef.current;
2696 if (!el || typeof ResizeObserver === "undefined") return;
2697 let frame = 0;
2698 const update = () => {
2699 if (frame) window.cancelAnimationFrame(frame);
2700 frame = window.requestAnimationFrame(() => {
2701 frame = 0;
2702 const next = Math.round(el.getBoundingClientRect().height);
2703 if (Math.abs(footerHeightRef.current - next) < 2) return;
2704 footerHeightRef.current = next;
2705 setFooterHeight(next);
2706 });
2707 };
2708 update();
2709 const observer = new ResizeObserver(update);
2710 observer.observe(el);
2711 return () => {
2712 if (frame) window.cancelAnimationFrame(frame);
2713 observer.disconnect();
2714 };
2715 }, []);
2716
2717 // Run the ambient engine only while the agent is generating.
2718 useEffect(() => {
2719 if (state.running && isGenerativeMusicEnabled()) {
2720 generativeMusic.start();
2721 } else {
2722 generativeMusic.stop();
2723 }
2724 return () => generativeMusic.stop();
2725 }, [state.running]);
2726
2727 // playTokenNote no-ops unless the engine is running, so subscribe unconditionally.
2728 useEffect(() => {
2729 const unsub = onEvent((e) => {
2730 if (e.kind === "text" || e.kind === "reasoning" || e.kind === "tool_dispatch") {
2731 generativeMusic.playTokenNote();
2732 }
2733 });
2734 return unsub;
2735 }, []);
2736
2737 const toggleSidebar = useCallback(() => {
2738 closeTransientOverlays();
2739 pulseSidebarToggle();
2740 anchorAppScrollToChat();
2741 const nextCollapsed = !sidebarCollapsed;
2742 if (nextCollapsed) setSidebarSearchOpen(false);
2743 setSidebarCollapsed(nextCollapsed);
2744 saveSidebarCollapsed(nextCollapsed);
2745 }, [anchorAppScrollToChat, closeTransientOverlays, pulseSidebarToggle, sidebarCollapsed]);
2746
2747 const sidebarWidthClamp = desktopLayoutStyle === "creation" ? clampCreationSidebarWidth : clampSidebarWidth;
2748 const sidebarRenderWidth = liveSidebarWidth ?? sidebarWidth;
2749 const sidebarResizeMinWidth = desktopLayoutStyle === "creation" ? CREATION_SIDEBAR_MIN_WIDTH : SIDEBAR_MIN_WIDTH;
2750
2751 useEffect(() => {
2752 if (desktopLayoutStyle === "creation" || sidebarWidth >= SIDEBAR_MIN_WIDTH) return;
2753 setSidebarWidth(SIDEBAR_MIN_WIDTH);
2754 saveSidebarWidth(SIDEBAR_MIN_WIDTH);
2755 }, [desktopLayoutStyle, sidebarWidth]);
2756
2757 useEffect(() => {
2758 if (desktopLayoutStyle === "creation") {
2759 if (rightDockTreeWidth >= CREATION_RIGHT_DOCK_TREE_MIN_WIDTH) return;
2760 setRightDockTreeWidth(CREATION_RIGHT_DOCK_TREE_MIN_WIDTH);
2761 saveRightDockTreeWidth(CREATION_RIGHT_DOCK_TREE_MIN_WIDTH);
2762 return;
2763 }
2764 if (rightDockTreeWidth >= RIGHT_DOCK_TREE_MIN_WIDTH) return;
2765 setRightDockTreeWidth(RIGHT_DOCK_TREE_MIN_WIDTH);
2766 saveRightDockTreeWidth(RIGHT_DOCK_TREE_MIN_WIDTH);
2767 }, [desktopLayoutStyle, rightDockTreeWidth]);
2768
2769 // Creation no longer exposes the overview tab. If a previous session left
2770 // rightDockMode on "context", coerce it to files so 文件 stays selected.
2771 useEffect(() => {
2772 if (desktopLayoutStyle !== "creation") return;
2773 if (rightDockMode !== "context") return;
2774 setRightDockMode("files");
2775 }, [desktopLayoutStyle, rightDockMode, setRightDockMode]);
2776
2777 const setExpandedSidebarWidth = useCallback((width: number) => {
2778 closeTransientOverlays();
2779 const next = sidebarWidthClamp(width);
2780 setSidebarWidth(next);
2781 saveSidebarWidth(next);
2782 }, [closeTransientOverlays, sidebarWidthClamp]);
2783
2784 const startSidebarResize = useCallback(
2785 (event: ReactPointerEvent<HTMLButtonElement>) => {
2786 if (sidebarCollapsed) return;
2787 const layout = layoutRef.current;
2788 if (!layout) return;
2789 event.preventDefault();
2790 closeTransientOverlays();
2791 setSidebarResizing(true);
2792 let nextWidth = sidebarWidth;
2793 const liveResize = createRafResizeUpdater({
2794 target: layout,
2795 separator: event.currentTarget,
2796 cssVar: "--sidebar-expanded-width",
2797 onApply: setLiveSidebarWidth,
2798 });
2799 const dockLiveResize = createRafResizeUpdater({
2800 target: layout,
2801 cssVar: "--workspace-width",
2802 onApply: setLiveWorkspacePanelRenderWidth,
2803 });
2804 const onMove = (moveEvent: PointerEvent) => {
2805 nextWidth = sidebarWidthClamp(moveEvent.clientX);
2806 liveResize.schedule(nextWidth);
2807 dockLiveResize.schedule(resolveLiveWorkspacePanelRenderWidth(preferredWorkspacePanelWidth, nextWidth));
2808 };
2809 const onDone = () => {
2810 liveResize.flush();
2811 dockLiveResize.flush();
2812 setSidebarWidth(nextWidth);
2813 saveSidebarWidth(nextWidth);
2814 setLiveSidebarWidth(null);
2815 setLiveWorkspacePanelRenderWidth(null);
2816 setSidebarResizing(false);
2817 window.removeEventListener("pointermove", onMove);
2818 window.removeEventListener("pointerup", onDone);
2819 window.removeEventListener("pointercancel", onDone);
2820 document.body.style.cursor = "";
2821 document.body.style.userSelect = "";
2822 };
2823 document.body.style.cursor = "col-resize";
2824 document.body.style.userSelect = "none";
2825 window.addEventListener("pointermove", onMove);
2826 window.addEventListener("pointerup", onDone);
2827 window.addEventListener("pointercancel", onDone);
2828 },
2829 [closeTransientOverlays, preferredWorkspacePanelWidth, resolveLiveWorkspacePanelRenderWidth, sidebarCollapsed, sidebarWidth, sidebarWidthClamp],
2830 );
2831
2832 const resizeSidebarWithKeyboard = useCallback(
2833 (event: KeyboardEvent<HTMLButtonElement>) => {
2834 if (sidebarCollapsed) return;
2835 if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
2836 event.preventDefault();
2837 setExpandedSidebarWidth(sidebarWidth + (event.key === "ArrowRight" ? 16 : -16));
2838 } else if (event.key === "Home") {
2839 event.preventDefault();
2840 setExpandedSidebarWidth(sidebarResizeMinWidth);
2841 } else if (event.key === "End") {
2842 event.preventDefault();
2843 setExpandedSidebarWidth(SIDEBAR_MAX_WIDTH);
2844 }
2845 },
2846 [setExpandedSidebarWidth, sidebarCollapsed, sidebarWidth, sidebarResizeMinWidth],
2847 );
2848
2849 const setSavedWorkspacePanelWidth = useCallback(
2850 (width: number) => {
2851 closeTransientOverlays();
2852 if (rightDockDetailActive) {
2853 const next = clampRightDockPreviewWidth(width);
2854 setRightDockPreviewWidth(next);
2855 saveRightDockPreviewWidth(next);
2856 return;
2857 }
2858 const next = rightDockTreeWidthClamp(width);
2859 setRightDockTreeWidth(next);
2860 saveRightDockTreeWidth(next);
2861 },
2862 [closeTransientOverlays, rightDockDetailActive, rightDockTreeWidthClamp],
2863 );
2864
2865 const ensureWorkspacePanelWidth = useCallback(
2866 (width: number) => {
2867 closeTransientOverlays();
2868 if (rightDockMode === "context") return;
2869 const next = clampRightDockPreviewWidth(width);
2870 setRightDockPreviewWidth(next);
2871 saveRightDockPreviewWidth(next);
2872 },
2873 [closeTransientOverlays, rightDockMode],
2874 );
2875
2876 const startWorkspacePanelResize = useCallback(
2877 (event: ReactPointerEvent<HTMLButtonElement>) => {
2878 if (!workspacePanelOpen) return;
2879 const layout = layoutRef.current;
2880 if (!layout) return;
2881 event.preventDefault();
2882 closeTransientOverlays();
2883 setWorkspacePanelResizing(true);
2884 const startX = event.clientX;
2885 const startDockWidth = workspacePanelRenderWidth;
2886 let nextDockWidth = startDockWidth;
2887 const liveResize = createRafResizeUpdater({
2888 target: layout,
2889 separator: event.currentTarget,
2890 cssVar: "--workspace-width",
2891 onApply: setLiveWorkspacePanelRenderWidth,
2892 });
2893 const onMove = (moveEvent: PointerEvent) => {
2894 const delta = moveEvent.clientX - startX;
2895 nextDockWidth = startDockWidth - delta;
2896 if (rightDockDetailActive) {
2897 nextDockWidth = clampRightDockPreviewWidth(nextDockWidth);
2898 } else {
2899 nextDockWidth = rightDockTreeWidthClamp(nextDockWidth);
2900 }
2901 liveResize.schedule(resolveLiveWorkspacePanelRenderWidth(nextDockWidth));
2902 };
2903 const onDone = () => {
2904 liveResize.flush();
2905 setSavedWorkspacePanelWidth(nextDockWidth);
2906 setLiveWorkspacePanelRenderWidth(null);
2907 setWorkspacePanelResizing(false);
2908 window.removeEventListener("pointermove", onMove);
2909 window.removeEventListener("pointerup", onDone);
2910 window.removeEventListener("pointercancel", onDone);
2911 document.body.style.cursor = "";
2912 document.body.style.userSelect = "";
2913 };
2914 document.body.style.cursor = "col-resize";
2915 document.body.style.userSelect = "none";
2916 window.addEventListener("pointermove", onMove);
2917 window.addEventListener("pointerup", onDone);
2918 window.addEventListener("pointercancel", onDone);
2919 },
2920 [closeTransientOverlays, resolveLiveWorkspacePanelRenderWidth, rightDockDetailActive, rightDockTreeWidthClamp, setSavedWorkspacePanelWidth, workspacePanelOpen, workspacePanelRenderWidth],
2921 );
2922
2923 const resizeWorkspacePanelWithKeyboard = useCallback(
2924 (event: KeyboardEvent<HTMLButtonElement>) => {
2925 if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
2926 event.preventDefault();
2927 setSavedWorkspacePanelWidth(workspacePanelRenderWidth + (event.key === "ArrowLeft" ? 16 : -16));
2928 } else if (event.key === "Home") {
2929 event.preventDefault();
2930 setSavedWorkspacePanelWidth(rightDockDetailActive ? RIGHT_DOCK_PREVIEW_MIN_WIDTH : rightDockTreeMinWidth);
2931 } else if (event.key === "End") {
2932 event.preventDefault();
2933 setSavedWorkspacePanelWidth(rightDockDetailActive ? RIGHT_DOCK_MAX_WIDTH : RIGHT_DOCK_TREE_MAX_WIDTH);
2934 }
2935 },
2936 [rightDockDetailActive, rightDockTreeMinWidth, setSavedWorkspacePanelWidth, workspacePanelRenderWidth],
2937 );
2938
2939 const terminalRenderHeight = clampTerminalHeight(terminalHeight, viewportHeight);
2940 const terminalResizeMaxHeight = terminalMaxHeight(viewportHeight);
2941 const setSavedTerminalHeight = useCallback(
2942 (height: number) => {
2943 const next = clampTerminalHeight(height, viewportHeight);
2944 setTerminalHeight(next);
2945 saveTerminalHeight(next);
2946 },
2947 [setTerminalHeight, viewportHeight],
2948 );
2949
2950 const startTerminalResize = useCallback(
2951 (event: ReactPointerEvent<HTMLButtonElement>) => {
2952 if (!terminalPanelOpen) return;
2953 const layout = layoutRef.current;
2954 if (!layout) return;
2955 event.preventDefault();
2956 closeTransientOverlays();
2957 const startY = event.clientY;
2958 const startHeight = terminalRenderHeight;
2959 let nextHeight = startHeight;
2960 const liveResize = createRafResizeUpdater({
2961 target: layout,
2962 separator: event.currentTarget,
2963 cssVar: "--terminal-height",
2964 onApply: setLiveTerminalHeight,
2965 });
2966 const onMove = (moveEvent: PointerEvent) => {
2967 const delta = startY - moveEvent.clientY;
2968 nextHeight = clampTerminalHeight(startHeight + delta, viewportHeight);
2969 liveResize.schedule(nextHeight);
2970 };
2971 const onDone = () => {
2972 liveResize.flush();
2973 setLiveTerminalHeight(null);
2974 setSavedTerminalHeight(nextHeight);
2975 window.removeEventListener("pointermove", onMove);
2976 window.removeEventListener("pointerup", onDone);
2977 window.removeEventListener("pointercancel", onDone);
2978 document.body.style.cursor = "";
2979 document.body.style.userSelect = "";
2980 };
2981 document.body.style.cursor = "row-resize";
2982 document.body.style.userSelect = "none";
2983 window.addEventListener("pointermove", onMove);
2984 window.addEventListener("pointerup", onDone);
2985 window.addEventListener("pointercancel", onDone);
2986 },
2987 [closeTransientOverlays, setLiveTerminalHeight, setSavedTerminalHeight, terminalPanelOpen, terminalRenderHeight, viewportHeight],
2988 );
2989
2990 const resizeTerminalWithKeyboard = useCallback(
2991 (event: KeyboardEvent<HTMLButtonElement>) => {
2992 if (!terminalPanelOpen) return;
2993 if (event.key === "ArrowUp" || event.key === "ArrowDown") {
2994 event.preventDefault();
2995 setSavedTerminalHeight(terminalRenderHeight + (event.key === "ArrowUp" ? 16 : -16));
2996 } else if (event.key === "Home") {
2997 event.preventDefault();
2998 setSavedTerminalHeight(TERMINAL_MIN_HEIGHT);
2999 } else if (event.key === "End") {
3000 event.preventDefault();
3001 setSavedTerminalHeight(terminalResizeMaxHeight);
3002 }
3003 },
3004 [setSavedTerminalHeight, terminalPanelOpen, terminalRenderHeight, terminalResizeMaxHeight],
3005 );
3006
3007 // Manage terminal content visibility for open/close animation.
3008 // On open: mount content immediately. On close: wait for the grid-template-rows
3009 // transition to finish before unmounting.
3010 const handleTerminalTransitionEnd = useCallback((event: React.TransitionEvent<HTMLDivElement>) => {
3011 if (event.propertyName === "grid-template-rows" && !terminalPanelOpen) {
3012 setTerminalContentVisible(false);
3013 }
3014 }, [terminalPanelOpen]);
3015
3016 useEffect(() => {
3017 if (terminalPanelOpen) {
3018 setTerminalContentVisible(true);
3019 }
3020 }, [terminalPanelOpen]);
3021
3022 const openWorkspacePanel = useCallback(
3023 (mode: RightDockMode = rightDockMode) => {
3024 closeTransientOverlays();
3025 if (mode === "context" || mode !== rightDockMode) {
3026 setWorkspacePreviewActive(false);
3027 }
3028 setRightDockMode(mode);
3029 let nextMaximized = workspacePanelMaximized;
3030 if (mode === "context") {
3031 nextMaximized = false;
3032 setWorkspacePanelMaximized(false);
3033 } else {
3034 // Keep file/change views docked; the rendered dock width is clamped to
3035 // the viewport so opening it reflows instead of forcing maximize.
3036 nextMaximized = false;
3037 setWorkspacePanelMaximized(false);
3038 }
3039 if (workspacePanelOpen && workspacePanelMaximized === nextMaximized) {
3040 return;
3041 }
3042 setWorkspacePanelOpen(true);
3043 saveWorkspacePanelOpen(true);
3044 },
3045 [closeTransientOverlays, rightDockMode, workspacePanelMaximized, workspacePanelOpen],
3046 );
3047
3048 const closeWorkspacePanel = useCallback(() => {
3049 closeTransientOverlays();
3050 if (!workspacePanelOpen) {
3051 return;
3052 }
3053 setLiveWorkspacePanelRenderWidth(null);
3054 setWorkspacePanelMaximized(false);
3055 setWorkspacePanelOpen(false);
3056 saveWorkspacePanelOpen(false);
3057 }, [closeTransientOverlays, workspacePanelOpen]);
3058
3059 const toggleWorkspacePanel = useCallback(() => {
3060 pulseWorkspaceToggle();
3061 if (workspacePanelRenderable) {
3062 closeWorkspacePanel();
3063 return;
3064 }
3065 // Creation hides the overview tab; never reopen into the invisible "context"
3066 // mode or neither 文件/改动 will show an active selection.
3067 if (desktopLayoutStyle === "creation") {
3068 openWorkspacePanel(rightDockMode === "changed" ? "changed" : "files");
3069 return;
3070 }
3071 openWorkspacePanel("context");
3072 }, [closeWorkspacePanel, desktopLayoutStyle, openWorkspacePanel, pulseWorkspaceToggle, rightDockMode, workspacePanelRenderable]);
3073
3074 const openRightDockMode = useCallback(
3075 (mode: RightDockMode) => {
3076 openWorkspacePanel(mode);
3077 },
3078 [openWorkspacePanel],
3079 );
3080
3081 const toggleTerminalPanel = useCallback(() => {
3082 setTerminalPanelOpen((prev) => {
3083 const next = !prev;
3084 saveTerminalPanelOpen(next);
3085 return next;
3086 });
3087 }, [setTerminalPanelOpen]);
3088
3089 const openTerminalForPath = useCallback(
3090 (path = ".") => {
3091 setTerminalPanelOpen(true);
3092 saveTerminalPanelOpen(true);
3093 if (!activeTabId) return;
3094 void useTerminalStore.getState().createSession(activeTabId, path || ".", "default").catch(() => {});
3095 },
3096 [activeTabId, setTerminalPanelOpen],
3097 );
3098
3099 useGlobalShortcut("terminal.toggle", () => {
3100 toggleTerminalPanel();
3101 }, [toggleTerminalPanel]);
3102 useGlobalShortcut("terminal.newSession", () => {
3103 if (!activeTabId) return;
3104 setTerminalPanelOpen(true);
3105 saveTerminalPanelOpen(true);
3106 void useTerminalStore.getState().createSession(activeTabId, ".", "default").catch(() => {});
3107 }, [activeTabId, setTerminalPanelOpen]);
3108
3109 useEffect(() => {
3110 if (!remoteExplorerOpen) return;
3111 openRightDockMode("remote");
3112 closeRemoteExplorerRequest();
3113 }, [closeRemoteExplorerRequest, openRightDockMode, remoteExplorerOpen]);
3114
3115 useEffect(() => {
3116 if (remoteHosts.length > 0 || rightDockMode !== "remote") return;
3117 setRightDockMode("files");
3118 }, [remoteHosts.length, rightDockMode, setRightDockMode]);
3119
3120 const openRemoteDock = useCallback(() => {
3121 const fallback = remoteHosts.find((host) => {
3122 const state = useRemoteStore.getState().statuses[host.id]?.state;
3123 return state === "connected" || state === "degraded";
3124 }) ?? remoteHosts[0];
3125 const hostId = remoteExplorerHostId && remoteHosts.some((host) => host.id === remoteExplorerHostId)
3126 ? remoteExplorerHostId
3127 : fallback?.id;
3128 if (hostId) requestRemoteExplorer(hostId);
3129 }, [remoteExplorerHostId, remoteHosts, requestRemoteExplorer]);
3130
3131 const remoteWorkspaceLaunchGate = useRef(new RemoteWorkspaceLaunchGate());
3132 const launchRemoteWorkspace = useCallback(async (host: RemoteHostView, requestSeq: number) => {
3133 const lastWorkspace = await app.RemoteLastWorkspace(host.id).catch(() => "");
3134 const workspace = resolveRemoteWorkspace(lastWorkspace, host.defaultWorkspace);
3135 if (!remoteWorkspaceLaunchGate.current.isCurrent(host.id, requestSeq)) return;
3136 await app.OpenRemoteWorkspace(host.id, workspace);
3137 }, []);
3138
3139 const openRemoteWorkspaceFromStatus = useCallback((host: RemoteHostView) => {
3140 const requestSeq = remoteWorkspaceLaunchGate.current.begin(host.id);
3141 void launchRemoteWorkspace(host, requestSeq).catch((err) => {
3142 showToast(err instanceof Error ? err.message : String(err), "error", { durationMs: 6000 });
3143 });
3144 }, [launchRemoteWorkspace, showToast]);
3145
3146 const connectAndOpenRemoteWorkspace = useCallback(function connectRemoteWorkspace(host: RemoteHostView) {
3147 const requestSeq = remoteWorkspaceLaunchGate.current.begin(host.id);
3148 void (async () => {
3149 try {
3150 const status = useRemoteStore.getState().statuses[host.id]?.state;
3151 if (status !== "connected" && status !== "degraded") {
3152 // Clear any stale failure before the new generation starts; otherwise a
3153 // previous stopped+error snapshot could make the waiter reject before
3154 // the kernel's fresh connecting event reaches the frontend.
3155 useRemoteStore.getState().applyStatus({ hostId: host.id, state: "connecting" });
3156 await app.ConnectRemoteHost(host.id);
3157 await waitForRemoteConnection(host.id);
3158 }
3159 } catch (err) {
3160 if (err instanceof RemoteConnectionTimeoutError) {
3161 showToast(t("remote.error.timeout", { host: host.label }), "error", {
3162 actionLabel: t("remote.error.stopAndRetry"),
3163 durationMs: 10_000,
3164 onAction: () => {
3165 void app.DisconnectRemoteHost(host.id)
3166 .catch(() => undefined)
3167 .then(() => connectRemoteWorkspace(host));
3168 },
3169 });
3170 return;
3171 }
3172 // Connection failures are host-scoped. Keep the persistent error and its
3173 // recovery actions beside the Remote SSH status entry instead of
3174 // stretching a raw backend error across the native titlebar.
3175 requestRemoteStatusPopover(host.id);
3176 return;
3177 }
3178
3179 try {
3180 await launchRemoteWorkspace(host, requestSeq);
3181 } catch (err) {
3182 showToast(err instanceof Error ? err.message : String(err), "error", { durationMs: 6000 });
3183 }
3184 })();
3185 }, [launchRemoteWorkspace, requestRemoteStatusPopover, showToast, t]);
3186
3187 const handleWorkspacePreviewModeChange = useCallback(
3188 (active: boolean) => {
3189 if (workspacePreviewActive === active) return;
3190 closeTransientOverlays();
3191 setWorkspacePreviewActive(active);
3192 },
3193 [closeTransientOverlays, workspacePreviewActive],
3194 );
3195
3196 const layoutStyle = useMemo(
3197 () =>
3198 ({
3199 "--sidebar-expanded-width": `${sidebarRenderWidth}px`,
3200 "--chat-min-width": `${chatReservedWidth}px`,
3201 "--workspace-width": `${workspacePanelRenderWidth}px`,
3202 "--workspace-resizer-width": `${WORKSPACE_RESIZER_WIDTH}px`,
3203 "--terminal-height": `${liveTerminalHeight ?? (terminalPanelOpen ? terminalRenderHeight : 0)}px`,
3204 }) as CSSProperties,
3205 [chatReservedWidth, liveTerminalHeight, sidebarRenderWidth, terminalPanelOpen, terminalRenderHeight, workspacePanelRenderWidth],
3206 );
3207
3208 const setWorkspacePanel = useCallback((open: boolean) => {
3209 if (open) {
3210 openWorkspacePanel();
3211 } else {
3212 closeWorkspacePanel();
3213 }
3214 }, [closeWorkspacePanel, openWorkspacePanel]);
3215
3216 const addWorkspaceTextToComposer = useCallback((text: string) => {
3217 if (activeTabId && workspaceInsertTarget === "planRevision" && state.approval?.tool === "exit_plan_mode") {
3218 setPlanRevisionInsertRequest({
3219 tabId: activeTabId,
3220 approvalId: state.approval.id,
3221 request: { id: Date.now(), text },
3222 });
3223 return;
3224 }
3225 if (activeTabId) {
3226 setComposerInsertRequestsByTab((current) => ({
3227 ...current,
3228 [activeTabId]: { id: Date.now(), text },
3229 }));
3230 }
3231 }, [activeTabId, state.approval, workspaceInsertTarget]);
3232
3233 const addTerminalOutputToComposer = useCallback(async (sessionId: string) => {
3234 if (!activeTabId) return;
3235 try {
3236 const output = await app.TerminalOutputForTab(activeTabId, sessionId);
3237 const formatted = formatTerminalOutputForComposer(output);
3238 if (!formatted) {
3239 showToast(t("terminal.noOutput"), "info");
3240 return;
3241 }
3242 addWorkspaceTextToComposer(formatted);
3243 } catch (error) {
3244 showToast(error instanceof Error ? error.message : String(error), "error");
3245 }
3246 }, [activeTabId, addWorkspaceTextToComposer, showToast, t]);
3247
3248 const addSelectedTextToComposer = useCallback((text: string) => {
3249 const selected = text.trim();
3250 if (!activeTabId || !selected) return;
3251 selectedTextRequestIdRef.current += 1;
3252 setSelectedTextRequestsByTab((current) => ({
3253 ...current,
3254 [activeTabId]: { id: selectedTextRequestIdRef.current, text: selected },
3255 }));
3256 }, [activeTabId]);
3257
3258 const addWorkspaceCodeToComposer = useCallback((path: string, code: string) => {
3259 if (!activeTabId || !code.trim()) return;
3260 if (workspaceInsertTarget === "planRevision" && state.approval?.tool === "exit_plan_mode") {
3261 // The plan-revision input is plain text and only consumes request.text,
3262 // so hand it the fenced rendering instead of a structured reference.
3263 setPlanRevisionInsertRequest({
3264 tabId: activeTabId,
3265 approvalId: state.approval.id,
3266 request: { id: Date.now(), text: formatSelectionReference(path, code) },
3267 });
3268 return;
3269 }
3270 selectedTextRequestIdRef.current += 1;
3271 setSelectedTextRequestsByTab((current) => ({
3272 ...current,
3273 [activeTabId]: { id: selectedTextRequestIdRef.current, text: code, path },
3274 }));
3275 }, [activeTabId, state.approval, workspaceInsertTarget]);
3276
3277 // Coalesce tab-bar switches through the same last-click-wins scheduler that
3278 // openTopic/blank/resume navigation uses, so rapidly clicking between two
3279 // running sessions can't run two switchTab() calls concurrently. Concurrent
3280 // switches race on the backend SetActiveTab/confirmBackendActiveTab ordering,
3281 // which lands events + hydration on the wrong session (#5352). switchTab's own
3282 // loadSessionDataForTab is already seq-guarded; this serializes the backend
3283 // activation around it.
3284 const tabSwitchSeqRef = useRef(0);
3285 const tabSwitchRunningRef = useRef(false);
3286 const tabSwitchPendingRef = useRef<PendingNavigationRequest<{ tabId: string; optimisticTab?: TabMeta; navigationIntentSeq: number }> | null>(null);
3287 const enqueueTabSwitch = useCallback(
3288 (tabId: string, optimisticTab?: TabMeta): Promise<void> => {
3289 // Claim the shared navigation epoch at click time, before this request
3290 // can wait behind an older tab switch. That immediately invalidates any
3291 // in-flight blank/topic completion from a previous user intent.
3292 const navigationIntentSeq = noteNavigationIntent();
3293 return enqueueNavigationRequest(
3294 { seqRef: tabSwitchSeqRef, runningRef: tabSwitchRunningRef, pendingRef: tabSwitchPendingRef },
3295 { tabId, optimisticTab, navigationIntentSeq },
3296 async (request) => {
3297 if (!isNavigationIntentCurrent(request.navigationIntentSeq)) return;
3298 await switchTab(request.tabId, request.optimisticTab, request.navigationIntentSeq);
3299 if (!isNavigationIntentCurrent(request.navigationIntentSeq)) return;
3300 await refreshTabMetas(
3301 () => isNavigationIntentCurrent(request.navigationIntentSeq),
3302 { afterMutation: true },
3303 );
3304 },
3305 );
3306 },
3307 [isNavigationIntentCurrent, noteNavigationIntent, refreshTabMetas, switchTab],
3308 );
3309
3310 const revealBackgroundRuntime = useCallback(async (tabId: string): Promise<void> => {
3311 try {
3312 const meta = await app.RevealBackgroundRuntime(tabId);
3313 await switchTab(meta.id, meta);
3314 await refreshTabMetas(undefined, { afterMutation: true });
3315 } catch (err) {
3316 showToast(err instanceof Error ? err.message : String(err), "error");
3317 }
3318 }, [refreshTabMetas, showToast, switchTab]);
3319
3320 const handleTabChange = useCallback((id: string) => {
3321 closeTransientOverlays();
3322 const selected = tabMetas.find((tab) => tab.id === id);
3323 setTabMetas((current) => current.map((tab) => ({ ...tab, active: tab.id === id })));
3324 void enqueueTabSwitch(id, selected);
3325 setTabRevealSignal((signal) => signal + 1);
3326 }, [closeTransientOverlays, enqueueTabSwitch, tabMetas]);
3327
3328 const finishTabClose = useCallback(async (
3329 id: string,
3330 policy: "keep_running" | "stop_and_close",
3331 ): Promise<boolean> => {
3332 closeTransientOverlays();
3333 const closed = await closeTab(id, policy);
3334 if (!closed) {
3335 showToast(t("runtime.closeFailed"), "error");
3336 return false;
3337 }
3338 setComposerProfilesByTab((current) => {
3339 if (!(id in current)) return current;
3340 const next = { ...current };
3341 delete next[id];
3342 return next;
3343 });
3344 setTabMetas((current) => {
3345 if (current.length <= 1) return current;
3346 const closingIndex = current.findIndex((tab) => tab.id === id);
3347 if (closingIndex < 0) return current;
3348 const closingTab = current[closingIndex];
3349 const remaining = current.filter((tab) => tab.id !== id);
3350 if (!closingTab.active && closingTab.id !== activeTabId) return remaining;
3351 const nextIndex = Math.min(closingIndex, remaining.length - 1);
3352 const nextActiveId = remaining[nextIndex]?.id;
3353 return remaining.map((tab) => ({ ...tab, active: tab.id === nextActiveId }));
3354 });
3355 await refreshTabMetas(undefined, { afterMutation: true });
3356 await refreshBackgroundRuntimes();
3357 setTabRevealSignal((signal) => signal + 1);
3358 return true;
3359 }, [activeTabId, closeTab, closeTransientOverlays, refreshBackgroundRuntimes, refreshTabMetas, showToast, t]);
3360
3361 const handleTabClose = useCallback(async (id: string) => {
3362 try {
3363 const work = await app.ActiveWorkForTab(id);
3364 if (work.running || work.pendingPrompt || work.jobs.length > 0) {
3365 setPendingClose({ tabId: id, work, stopping: false });
3366 return;
3367 }
3368 } catch {
3369 // CloseTabWithPolicy re-checks the controller state atomically.
3370 }
3371 await finishTabClose(id, "stop_and_close");
3372 }, [finishTabClose]);
3373
3374 const resolvePendingClose = useCallback(async (policy: "keep_running" | "stop_and_close") => {
3375 const request = pendingClose;
3376 if (!request || request.stopping) return;
3377 if (policy === "stop_and_close") setPendingClose({ ...request, stopping: true });
3378 const closed = await finishTabClose(request.tabId, policy);
3379 if (closed) setPendingClose(null);
3380 else setPendingClose((current) => current?.tabId === request.tabId ? { ...current, stopping: false } : current);
3381 }, [finishTabClose, pendingClose]);
3382
3383 const revealWorkspaceWriter = useCallback(async () => {
3384 if (!activeTabId) return;
3385 try {
3386 const meta = await app.RevealWorkspaceWriterForTab(activeTabId);
3387 setWorkspaceConflict(null);
3388 await switchTab(meta.id, meta);
3389 await refreshTabMetas(undefined, { afterMutation: true });
3390 } catch (err) {
3391 showToast(err instanceof Error ? err.message : String(err), "error");
3392 }
3393 }, [activeTabId, refreshTabMetas, showToast, switchTab]);
3394
3395 const continueInDeliveryWorktree = useCallback(async () => {
3396 const root = state.meta?.workspaceRoot || state.meta?.workspacePath || state.meta?.cwd;
3397 if (!root) return;
3398 cancel();
3399 setWorkspaceConflict(null);
3400 try {
3401 await createDeliveryWorktree(root);
3402 await refreshTabMetas(undefined, { afterMutation: true });
3403 } catch (err) {
3404 showToast(err instanceof Error ? err.message : String(err), "error");
3405 }
3406 }, [cancel, createDeliveryWorktree, refreshTabMetas, showToast, state.meta?.cwd, state.meta?.workspacePath, state.meta?.workspaceRoot]);
3407
3408 const handleTabsClose = useCallback(async (ids: string[], nextActiveTabId?: string) => {
3409 closeTransientOverlays();
3410 const currentIds = tabMetas.map((tab) => tab.id);
3411 const targets = ids.filter((id, index) => currentIds.includes(id) && ids.indexOf(id) === index);
3412 if (targets.length === 0) return;
3413 for (const id of targets) {
3414 let work: ActiveWorkView | null = null;
3415 try {
3416 work = await app.ActiveWorkForTab(id);
3417 } catch { /* the close path remains authoritative */ }
3418 if (work && (work.running || work.pendingPrompt || work.jobs.length > 0)) {
3419 setPendingClose({ tabId: id, work, stopping: false });
3420 return;
3421 }
3422 await finishTabClose(id, "stop_and_close");
3423 }
3424 if (nextActiveTabId && currentIds.includes(nextActiveTabId)) {
3425 const selected = tabMetas.find((tab) => tab.id === nextActiveTabId);
3426 setTabMetas((current) => current.map((tab) => ({ ...tab, active: tab.id === nextActiveTabId })));
3427 void enqueueTabSwitch(nextActiveTabId, selected);
3428 }
3429 await refreshTabMetas(undefined, { afterMutation: true });
3430 setTabRevealSignal((signal) => signal + 1);
3431 }, [closeTransientOverlays, enqueueTabSwitch, finishTabClose, refreshTabMetas, tabMetas]);
3432
3433 const handleTabsReorder = useCallback(async (ids: string[]) => {
3434 setTabOrderIds(ids);
3435 setTabMetas((current) => {
3436 const byId = new Map(current.map((tab) => [tab.id, tab]));
3437 const ordered = ids.map((id) => byId.get(id)).filter((tab): tab is TabMeta => Boolean(tab));
3438 return ordered.length === current.length ? ordered : current;
3439 });
3440 await reorderTabs(ids);
3441 await refreshTabMetas(undefined, { afterMutation: true });
3442 setTabRevealSignal((signal) => signal + 1);
3443 }, [refreshTabMetas, reorderTabs]);
3444
3445 const [rewindSignal, setRewindSignal] = useState(0);
3446
3447 // ── Immediate rewind ──────────────────────────────────────────────────
3448 // On confirm, call Go prepare+commit immediately. Only after success does
3449 // the UI truncate the transcript, refresh files, and fill the composer.
3450 // Real backend undo uses UndoRewindForTab when a transaction id is available.
3451 type RewindState = {
3452 turnDiff: number; // turns rolled back
3453 transactionId?: string;
3454 undoAvailable?: boolean;
3455 filesRestored?: string[];
3456 filesRemoved?: string[];
3457 };
3458 const [rewindStatesByTab, setRewindStatesByTab] = useState<Record<string, RewindState>>({});
3459 const rewindStatesByTabRef = useRef(rewindStatesByTab);
3460 rewindStatesByTabRef.current = rewindStatesByTab;
3461 const [rewindCommittingByTab, setRewindCommittingByTab] = useState<Record<string, boolean>>({});
3462 const rewindState = activeTabId ? rewindStatesByTab[activeTabId] ?? null : null;
3463 const rewindCommitting = Boolean(activeTabId && rewindCommittingByTab[activeTabId]);
3464
3465 const setRewindStateForTab = useCallback((tabId: string, nextState: RewindState | null) => {
3466 if (!tabId) return;
3467 const next = { ...rewindStatesByTabRef.current };
3468 if (nextState) next[tabId] = nextState;
3469 else delete next[tabId];
3470 rewindStatesByTabRef.current = next;
3471 setRewindStatesByTab(next);
3472 }, []);
3473
3474 const setRewindCommittingForTab = useCallback((tabId: string, committing: boolean) => {
3475 setRewindCommittingByTab((current) => {
3476 const next = { ...current };
3477 if (committing) next[tabId] = true;
3478 else delete next[tabId];
3479 return next;
3480 });
3481 }, []);
3482
3483 const handleSessionRevertCommitted = useCallback((sourceTabId: string, outcome: RewindResultView) => {
3484 if (!sourceTabId || !outcome.ok) return;
3485 setRewindStateForTab(sourceTabId, {
3486 turnDiff: 0,
3487 transactionId: outcome.transactionId,
3488 undoAvailable: outcome.undoAvailable,
3489 filesRestored: outcome.written ?? [],
3490 filesRemoved: outcome.deleted ?? [],
3491 });
3492 setDockRefreshKey((value) => value + 1);
3493 setProjectRevision((value) => value + 1);
3494 }, [setRewindStateForTab]);
3495
3496 const hydratePlaceholderActive = Boolean(
3497 state.hydrating &&
3498 state.items.length === 0 &&
3499 state.hydratePlaceholderItems?.length,
3500 );
3501 const transcriptHydrating = state.hydrating && !state.hydrateHistoryLoaded;
3502 // Creation hero only after history hydration settles on a truly empty session.
3503 // Avoid flash while switching tabs: items may be empty while placeholders show.
3504 // Exclude IM/Bot detail: hero CSS collapses .main, which also hosts that panel.
3505 // (desktopLayoutStyle is available here; sidebarCreation is declared later.)
3506 const creationEmptyHero =
3507 desktopLayoutStyle === "creation" &&
3508 !sidebarImDetailConnection &&
3509 !sessionHasContent &&
3510 !transcriptHydrating &&
3511 !hydratePlaceholderActive;
3512 const transcriptItems = hydratePlaceholderActive ? state.hydratePlaceholderItems! : state.items;
3513
3514 // Display items: backend history is authoritative after immediate commit.
3515 // rewindState only drives the undo banner, not optimistic truncation.
3516 const displayItems = transcriptItems;
3517 const latestGuidanceConsumed = useMemo(() => {
3518 for (let i = state.items.length - 1; i >= 0; i--) {
3519 const item = state.items[i];
3520 if (item.kind === "notice" && item.text.startsWith("↪ ")) {
3521 return { key: item.id, text: item.text.slice(2) };
3522 }
3523 }
3524 return null;
3525 }, [state.items]);
3526
3527 // send wrapper: clear local undo banner state before sending a new turn
3528 // (new mutation invalidates undo). Rewind itself already committed immediately.
3529 const commitThenSend = useCallback(async (
3530 sourceTabId: string,
3531 displayText: string,
3532 submitText?: string,
3533 structured?: StructuredInvocationSubmit,
3534 initialGoal?: {
3535 goal: string;
3536 collaborationMode: CollaborationMode;
3537 toolApprovalMode: ToolApprovalMode;
3538 },
3539 ) => {
3540 const sourceTab = tabMetas.find((tab) => tab.id === sourceTabId);
3541 if (!sourceTab) throw new Error(t("composer.workspaceStarting"));
3542 if (sourceTab.readOnly) throw new Error(t("composer.readOnlyChannel"));
3543 if (
3544 sourceTab.ready !== true ||
3545 (sourceTab.runtime && sourceTab.runtime.phase !== "ready") ||
3546 sourceTab.startupErr
3547 ) {
3548 throw new Error(sourceTab.runtime?.issue?.message || sourceTab.startupErr || t("composer.workspaceStarting"));
3549 }
3550 // New turn invalidates the last undo slot.
3551 if (rewindStatesByTabRef.current[sourceTabId]) {
3552 setRewindStateForTab(sourceTabId, null);
3553 }
3554 await sendToTab(sourceTabId, displayText, submitText, undefined, structured, initialGoal);
3555 }, [sendToTab, setRewindStateForTab, t, tabMetas]);
3556
3557 const handleTranscriptPrompt = useCallback((text: string) => {
3558 if (!activeTabId || !controllerReady) return;
3559 void commitThenSend(activeTabId, text).catch((err) => {
3560 console.warn("Failed to submit transcript prompt", err);
3561 });
3562 }, [activeTabId, commitThenSend, controllerReady]);
3563
3564 const handleDeliveryContinue = useCallback(async () => {
3565 await continueDelivery({
3566 tabId: activeTabIdRef.current,
3567 ready: controllerReady,
3568 goal: state.meta?.goal,
3569 activeTabId: () => activeTabIdRef.current,
3570 resumeGoal: resumeControllerGoalForTab,
3571 send: (tabId) => recoverDeliveryToTab(tabId, t("notice.deliveryIncompleteContinuePrompt")),
3572 });
3573 }, [controllerReady, recoverDeliveryToTab, resumeControllerGoalForTab, state.meta?.goal, t]);
3574 commitThenSendRef.current = commitThenSend;
3575
3576 const handleMessageAction = useCallback((turn: number, scope: string) => {
3577 const sourceTabId = activeTabId;
3578 if (!sourceTabId || activeTab?.readOnly) return;
3579 if (hydratePlaceholderActive) return;
3580 if (scope === "fork") {
3581 // Fork still goes through the controller (not optimistic).
3582 rewindForTab(sourceTabId, turn, scope).then((ok) => {
3583 if (!ok) return;
3584 void refreshTabMetas(undefined, { afterMutation: true });
3585 setProjectRevision((v) => v + 1);
3586 });
3587 return;
3588 }
3589
3590 // Code-only rewind only affects files — no message truncation,
3591 // no optimistic UI needed. Execute immediately.
3592 if (scope === "code") {
3593 setRewindCommittingForTab(sourceTabId, true);
3594 void rewindForTabDetailed(sourceTabId, turn, scope).then((outcome) => {
3595 setRewindCommittingForTab(sourceTabId, false);
3596 if (!outcome.ok) return;
3597 setRewindStateForTab(sourceTabId, {
3598 turnDiff: 0,
3599 transactionId: outcome.transactionId,
3600 undoAvailable: outcome.undoAvailable,
3601 filesRestored: outcome.written ?? [],
3602 filesRemoved: outcome.deleted ?? [],
3603 });
3604 setDockRefreshKey((v) => v + 1);
3605 setProjectRevision((v) => v + 1);
3606 });
3607 return;
3608 }
3609
3610 // Summarize only compresses the conversation log — no files touched,
3611 // no optimistic UI needed. Execute immediately like code-only rewind.
3612 if (scope === "summ-from" || scope === "summ-upto") {
3613 rewindForTab(sourceTabId, turn, scope).then((ok) => {
3614 if (!ok) return;
3615 setDockRefreshKey((v) => v + 1);
3616 setProjectRevision((v) => v + 1);
3617 });
3618 return;
3619 }
3620
3621 const items = state.items;
3622 const hasCheckpointTurns = items.some((it) => it.kind === "user" && it.checkpointTurn != null);
3623 let boundaryIdx = -1;
3624 let userCount = 0;
3625 let targetUserCount = -1;
3626 for (let i = 0; i < items.length; i++) {
3627 if (items[i].kind === "user") {
3628 const item = items[i] as Extract<Item, { kind: "user" }>;
3629 const matches = hasCheckpointTurns ? item.checkpointTurn === turn : userCount === turn;
3630 if (matches) {
3631 boundaryIdx = i;
3632 targetUserCount = userCount;
3633 break;
3634 }
3635 userCount++;
3636 }
3637 }
3638 if (boundaryIdx < 0) {
3639 rewindForTab(sourceTabId, turn, scope).then((ok) => {
3640 if (!ok) return;
3641 if (scope === "both") {
3642 setDockRefreshKey((v) => v + 1);
3643 setProjectRevision((v) => v + 1);
3644 }
3645 });
3646 return;
3647 }
3648
3649 const prevUserCount = items.filter((it) => it.kind === "user").length;
3650 const turnDiff = prevUserCount - targetUserCount;
3651 const userItem = items[boundaryIdx]?.kind === "user" ? items[boundaryIdx] as Extract<Item, { kind: "user" }> : undefined;
3652 const prompt = userItem?.text ?? "";
3653
3654 // Immediate backend commit — only update UI after success.
3655 setRewindCommittingForTab(sourceTabId, true);
3656 void rewindForTabDetailed(sourceTabId, turn, scope).then((outcome) => {
3657 setRewindCommittingForTab(sourceTabId, false);
3658 if (!outcome.ok) {
3659 // Keep conversation/files as-is; notices already carry the reason.
3660 return;
3661 }
3662 setRewindStateForTab(sourceTabId, {
3663 turnDiff,
3664 transactionId: outcome.transactionId,
3665 undoAvailable: outcome.undoAvailable,
3666 filesRestored: outcome.written ?? [],
3667 filesRemoved: outcome.deleted ?? [],
3668 });
3669 const insertId = Date.now();
3670 setComposerInsertRequestsByTab((current) => ({
3671 ...current,
3672 [sourceTabId]: { id: insertId, text: prompt, mode: "replace" },
3673 }));
3674 setRewindSignal((v) => v + 1);
3675 if (scope === "both" || scope === "code") {
3676 setDockRefreshKey((v) => v + 1);
3677 setProjectRevision((v) => v + 1);
3678 }
3679 });
3680 }, [activeTab?.readOnly, activeTabId, hydratePlaceholderActive, state.items, rewindForTab, rewindForTabDetailed, refreshTabMetas, setRewindStateForTab, setRewindCommittingForTab]);
3681
3682 const handleEditPrompt = useCallback(async (turn: number, displayText: string, submitText?: string): Promise<boolean> => {
3683 const sourceTabId = activeTabId;
3684 if (!sourceTabId || activeTab?.readOnly || !controllerReady || hydratePlaceholderActive || rewindStatesByTabRef.current[sourceTabId] || state.running || state.messageAction != null || state.approval != null || state.ask != null || clearContextPending) return false;
3685 const next = displayText.trim();
3686 if (!next) return false;
3687 const submit = (submitText ?? displayText).trim();
3688 const hasCheckpointTurns = state.items.some((it) => it.kind === "user" && it.checkpointTurn != null);
3689 let original = "";
3690 let userCount = 0;
3691 for (const item of state.items) {
3692 if (item.kind !== "user") continue;
3693 const matches = hasCheckpointTurns ? item.checkpointTurn === turn : userCount === turn;
3694 if (matches) {
3695 original = (item.submitText ?? item.text).trim();
3696 break;
3697 }
3698 userCount++;
3699 }
3700 const ok = await rewindForTab(sourceTabId, turn, "conversation");
3701 if (!ok) return false;
3702 setRewindSignal((v) => v + 1);
3703 try {
3704 await sendToTab(sourceTabId, next, submit, original);
3705 return true;
3706 } catch {
3707 return false;
3708 }
3709 }, [activeTab?.readOnly, activeTabId, clearContextPending, controllerReady, hydratePlaceholderActive, sendToTab, state.approval, state.ask, state.items, state.messageAction, state.running, rewindForTab]);
3710
3711 const openTrash = useCallback(async () => {
3712 closeTransientOverlays();
3713 setHistView({ kind: "trash", sessions: await listTrashedSessions() });
3714 }, [closeTransientOverlays, listTrashedSessions]);
3715 const closeHistory = useCallback(() => {
3716 closeTransientOverlays();
3717 setHistView(null);
3718 }, [closeTransientOverlays]);
3719 const refreshHistoryView = useCallback(async () => {
3720 const sessions = await listSessions().catch(() => null);
3721 if (!sessions) return;
3722 setHistView((cur) =>
3723 cur === null || cur.kind !== "history"
3724 ? cur
3725 : cur.source === "scope"
3726 ? { ...cur, sessions: sessionsForScope(sessions, cur.filter) }
3727 : { ...cur, sessions },
3728 );
3729 }, [listSessions]);
3730
3731 const navigationSeqRef = useRef(0);
3732 const navigationRunningRef = useRef(false);
3733 const navigationPendingRef = useRef<PendingDesktopNavigationRequest | null>(null);
3734 const runNavigationRequest = useCallback(async (request: PendingDesktopNavigationRequest) => {
3735 const latest = () => request.seq === navigationSeqRef.current && isNavigationIntentCurrent(request.navigationIntentSeq);
3736 if (!latest()) return;
3737 const refreshLatestTabMetas = async (): Promise<TabMeta[]> => {
3738 const tabs = asArray(await app.ListTabs().catch(() => [] as TabMeta[]));
3739 if (latest()) setTabMetas(tabs);
3740 return tabs;
3741 };
3742 const openTopicTarget = async (scope: string, workspaceRoot: string, topicId: string, sessionPath?: string): Promise<TabMeta> => {
3743 if (singleSurfaceLayout) return activateTopic(scope, workspaceRoot, topicId, sessionPath || "", request.navigationIntentSeq);
3744 if (sessionPath) return openTopicSession(scope, workspaceRoot, topicId, sessionPath, request.navigationIntentSeq);
3745 if (scope === "global") return openGlobalTab(topicId, request.navigationIntentSeq);
3746 return openProjectTab(workspaceRoot, topicId, request.navigationIntentSeq);
3747 };
3748 const openBlankTarget = async (scope: string, workspaceRoot: string): Promise<TabMeta> => {
3749 const root = scope === "project" ? workspaceRoot : "";
3750 return singleSurfaceLayout
3751 ? ensureBlankSurface(scope, root, request.navigationIntentSeq)
3752 : ensureBlankTab(scope, root, request.navigationIntentSeq);
3753 };
3754
3755 try {
3756 if (request.kind === "topic") {
3757 const openedTab = await openTopicTarget(request.scope, request.workspaceRoot, request.topicId, request.sessionPath);
3758 if (!latest()) return;
3759 seedActiveTabMeta(openedTab);
3760 void refreshLatestTabMetas();
3761 setTabRevealSignal((signal) => signal + 1);
3762 setTranscriptRevealSignal((signal) => signal + 1);
3763 return;
3764 }
3765
3766 if (request.kind === "blank") {
3767 const openedTab = await openBlankTarget(request.scope, request.workspaceRoot);
3768 if (!latest()) return;
3769 seedActiveTabMeta(openedTab);
3770 setProjectRevision((value) => value + 1);
3771 await refreshLatestTabMetas();
3772 if (!latest()) return;
3773 setTabRevealSignal((signal) => signal + 1);
3774 setTranscriptRevealSignal((signal) => signal + 1);
3775 return;
3776 }
3777
3778 if (request.kind === "delivery-worktree") {
3779 const result = await createDeliveryWorktree(request.workspaceRoot, request.navigationIntentSeq);
3780 if (!latest()) return;
3781 seedActiveTabMeta(result.tab);
3782 setProjectRevision((value) => value + 1);
3783 await refreshLatestTabMetas();
3784 if (!latest()) return;
3785 showToast(
3786 result.sourceDirty
3787 ? t("projectTree.worktreeCreatedDirty", { branch: result.branch })
3788 : t("projectTree.worktreeCreated", { branch: result.branch }),
3789 result.sourceDirty ? "warn" : "info",
3790 { durationMs: result.sourceDirty ? 7000 : 3500 },
3791 );
3792 setTabRevealSignal((signal) => signal + 1);
3793 setTranscriptRevealSignal((signal) => signal + 1);
3794 return;
3795 }
3796
3797 if (request.kind === "sidebar-im") {
3798 const { connection } = request;
3799 const target = sidebarImSessionTarget(connection);
3800 if (!target) {
3801 if (latest()) showToast(t("sidebar.imWaiting", { name: connection.title }));
3802 return;
3803 }
3804 let openedTab: TabMeta | undefined;
3805 if (connection.sessionSource === "auto" && target.kind === "path") {
3806 openedTab = await openBlankTarget(connection.scope, connection.workspaceRoot);
3807 if (!latest()) return;
3808 await openChannelSession(target.value, openedTab.id, request.navigationIntentSeq);
3809 } else if (target.kind === "path") {
3810 openedTab = await openBlankTarget(connection.scope, connection.workspaceRoot);
3811 if (!latest()) return;
3812 await resumeSession(target.value, openedTab.id, request.navigationIntentSeq);
3813 } else {
3814 openedTab = await openTopicTarget(connection.scope, connection.workspaceRoot, target.value);
3815 }
3816 if (!latest()) return;
3817 if (openedTab) seedActiveTabMeta(openedTab);
3818 await refreshLatestTabMetas();
3819 if (!latest()) return;
3820 setTabRevealSignal((value) => value + 1);
3821 setTranscriptRevealSignal((value) => value + 1);
3822 setProjectRevision((value) => value + 1);
3823 return;
3824 }
3825
3826 const { session } = request;
3827 const scope = session.scope || (session.workspaceRoot ? "project" : "global");
3828 let targetTab: TabMeta;
3829 if (isChannelSession(session)) {
3830 targetTab = await openBlankTarget(scope === "project" ? "project" : "global", scope === "project" ? session.workspaceRoot || "" : "");
3831 if (!latest()) return;
3832 await openChannelSession(session.path, targetTab.id, request.navigationIntentSeq);
3833 } else if (scope === "project" && session.workspaceRoot && session.topicId) {
3834 targetTab = await openTopicTarget("project", session.workspaceRoot, session.topicId, session.path);
3835 } else if (scope === "global" && session.topicId) {
3836 targetTab = await openTopicTarget("global", "", session.topicId, session.path);
3837 } else {
3838 throw new Error(scope === "global" && !session.topicId
3839 ? t("history.failedOpenSession")
3840 : (session.topicId ? t("history.missingWorkspaceRoot") : t("history.failedOpenSession")));
3841 }
3842 if (!latest()) return;
3843 seedActiveTabMeta(targetTab);
3844 setHistView(null);
3845 void refreshLatestTabMetas();
3846 setTabRevealSignal((value) => value + 1);
3847 setTranscriptRevealSignal((value) => value + 1);
3848 } catch (err: any) {
3849 if (!latest()) return;
3850 if (request.kind === "topic" || request.kind === "blank") {
3851 console.warn("desktop navigation failed", err);
3852 showToast(t("history.failedOpenSession"), "error");
3853 void refreshLatestTabMetas();
3854 return;
3855 }
3856 if (request.kind === "delivery-worktree") {
3857 console.warn("isolated Delivery workspace creation failed", err);
3858 showToast(err instanceof Error ? err.message : String(err), "error", { durationMs: 6000 });
3859 return;
3860 }
3861 if (request.kind === "sidebar-im") {
3862 console.warn("bot sidebar open failed", err);
3863 showToast(t("sidebar.imOpenFailed", { name: request.connection.title }));
3864 return;
3865 }
3866 await refreshHistoryView();
3867 if (!latest() || isMissingSessionError(err)) return;
3868 setHistView(null);
3869 const session = request.session;
3870 const scope = session.scope || (session.workspaceRoot ? "project" : "global");
3871 if (scope === "project" && session.workspaceRoot) {
3872 const name = workspaceDisplayName(session.workspaceRoot);
3873 showToast(t("history.failedOpenProject", { name, path: session.workspaceRoot }));
3874 } else {
3875 showToast(err?.message || String(err));
3876 }
3877 }
3878 }, [activateTopic, createDeliveryWorktree, ensureBlankSurface, ensureBlankTab, isNavigationIntentCurrent, openChannelSession, openGlobalTab, openProjectTab, openTopicSession, refreshHistoryView, resumeSession, seedActiveTabMeta, showToast, singleSurfaceLayout, t]);
3879
3880 const enqueueNavigationWithIntent = useCallback((input: DesktopNavigationIntent, navigationIntentSeq: number): Promise<void> => {
3881 return enqueueNavigationRequest(
3882 { seqRef: navigationSeqRef, runningRef: navigationRunningRef, pendingRef: navigationPendingRef },
3883 { ...input, navigationIntentSeq } as DesktopNavigationInput,
3884 runNavigationRequest,
3885 );
3886 }, [runNavigationRequest]);
3887
3888 const enqueueNavigation = useCallback((input: DesktopNavigationIntent): Promise<void> => {
3889 // Invalidate any in-flight activation's stale apply at ENQUEUE time. The
3890 // queue serializes requests, so a click made while another request runs
3891 // only advances the controller's navigation epoch when it eventually
3892 // starts — too late: the running request's ActivateTopic would resolve,
3893 // pass the controller-local guard, flip the visible tab, and prune the
3894 // newer surface's cached state (#6613 review).
3895 const navigationIntentSeq = noteNavigationIntent();
3896 return enqueueNavigationWithIntent(input, navigationIntentSeq);
3897 }, [enqueueNavigationWithIntent, noteNavigationIntent]);
3898
3899 const openBlankSession = useCallback((scope: string, workspaceRoot: string): Promise<void> =>
3900 enqueueNavigation({ kind: "blank", scope, workspaceRoot: scope === "project" ? workspaceRoot : "" }),
3901 [enqueueNavigation]);
3902
3903 useEffect(() => onSessionRecovered(() => {
3904 setProjectRevision((value) => value + 1);
3905 void refreshTabMetas(undefined, { afterMutation: true });
3906 }), [refreshTabMetas]);
3907
3908 const handleNewTab = useCallback(async () => {
3909 closeTransientOverlays();
3910 setSidebarImDetailConnectionId("");
3911 const target = blankSessionTarget();
3912 await openBlankSession(target.scope, target.workspaceRoot);
3913 }, [blankSessionTarget, closeTransientOverlays, openBlankSession]);
3914
3915 const handleOpenTopic = useCallback((scope: string, workspaceRoot: string, topicId: string, sessionPath?: string): Promise<void> => {
3916 closeTransientOverlays();
3917 setSidebarImDetailConnectionId("");
3918 return enqueueNavigation({ kind: "topic", scope, workspaceRoot, topicId, sessionPath });
3919 }, [closeTransientOverlays, enqueueNavigation]);
3920
3921 const openSidebarImConnectionSession = useCallback((connection: SidebarImConnection): Promise<void> => {
3922 setSidebarImDetailConnectionId("");
3923 return enqueueNavigation({ kind: "sidebar-im", connection });
3924 }, [enqueueNavigation]);
3925
3926 const onResumeSession = useCallback((session: SessionMeta): Promise<void> => {
3927 if (state.running && !singleSurfaceLayout) return Promise.resolve();
3928 return enqueueNavigation({ kind: "resume-session", session });
3929 }, [enqueueNavigation, singleSurfaceLayout, state.running]);
3930
3931 const openTaskMonitorSession = useCallback(async (tabID: string, taskID: string): Promise<boolean> => {
3932 if (state.running && !singleSurfaceLayout) {
3933 throw new Error(t("history.failedOpenSession"));
3934 }
3935 // Claim the navigation epoch before the first Wails await. If the user
3936 // switches tabs while the task/session lookup is pending, its completion is
3937 // stale and must not enqueue a newer navigation request.
3938 const navigationIntentSeq = noteNavigationIntent();
3939 const session = await resolveTaskMonitorSession({
3940 tabID,
3941 taskID,
3942 intentSeq: navigationIntentSeq,
3943 isIntentCurrent: isNavigationIntentCurrent,
3944 openTaskSessionForTab: (sourceTabID, sourceTaskID) => app.OpenTaskSessionForTab(sourceTabID, sourceTaskID),
3945 listSessionsForTab: async (sourceTabID) => asArray(await app.ListSessionsForTab(sourceTabID)),
3946 sessionIDFromPath: taskSessionIDFromPath,
3947 });
3948 if (!session) return false;
3949 await enqueueNavigationWithIntent({ kind: "resume-session", session }, navigationIntentSeq);
3950 return isNavigationIntentCurrent(navigationIntentSeq);
3951 }, [enqueueNavigationWithIntent, isNavigationIntentCurrent, noteNavigationIntent, singleSurfaceLayout, state.running, t]);
3952
3953 // Command palette: ⌘K / Ctrl+K opens a fuzzy navigator over commands and
3954 // recent sessions. Sessions are snapshotted on open so the list is stable
3955 // while the palette is up; extension actions follow the same snapshot rule.
3956 const openPalette = useCallback(async () => {
3957 closeTransientOverlays();
3958 setPaletteOpen(true);
3959 setPaletteSessions(await listSessions().catch(() => []));
3960 setPaletteExtensionActions(await app.ExtensionActions(activeTabIdRef.current ?? "").catch(() => []));
3961 }, [closeTransientOverlays, listSessions, setPaletteExtensionActions]);
3962 useGlobalShortcut("commandPalette.open", () => {
3963 setPaletteOpen((current) => {
3964 if (!current) void openPalette();
3965 return !current; // ← fix: toggle the state so the palette actually opens/closes
3966 });
3967 }, [openPalette]);
3968 useGlobalShortcut("app.newSession", () => void handleNewTab(), [handleNewTab]);
3969 useGlobalShortcut("settings.open", () => {
3970 closeTransientOverlays();
3971 setSettingsTarget("general");
3972 }, [closeTransientOverlays]);
3973 useGlobalShortcut("tab.close", () => {
3974 if (activeTabId) void handleTabClose(activeTabId);
3975 }, [activeTabId, handleTabClose], Boolean(activeTabId));
3976 useGlobalShortcut("shortcuts.show", () => setShortcutsOpen(true));
3977 useGlobalShortcut("sidebar.toggle", toggleSidebar, [toggleSidebar]);
3978
3979 // --- Topic shortcut navigation (Cmd/Ctrl+1-9) ---
3980 const visibleTopicsRef = useRef<TopicShortcutEntry[]>([]);
3981 const handleVisibleTopicsChange = useCallback((topics: TopicShortcutEntry[]) => {
3982 visibleTopicsRef.current = topics;
3983 }, []);
3984 const handleNavigateTopic = useCallback((entry: TopicShortcutEntry) => {
3985 void handleOpenTopic(entry.scope, entry.workspaceRoot, entry.topicId, entry.sessionPath);
3986 }, [handleOpenTopic]);
3987 const { showBadges: showTopicBadges } = useTopicShortcuts(!sidebarCollapsed, desktopPlatform);
3988
3989 // Register Cmd/Ctrl+1-9 shortcuts for topic navigation
3990 useEffect(() => {
3991 if (sidebarCollapsed) return;
3992 const onKeydown = (event: globalThis.KeyboardEvent) => {
3993 const idx = topicShortcutIndexFromEvent(event, desktopPlatform);
3994 if (idx === null) return;
3995 event.preventDefault();
3996 const topics = visibleTopicsRef.current;
3997 if (idx < topics.length) {
3998 handleNavigateTopic(topics[idx]);
3999 }
4000 };
4001 document.addEventListener("keydown", onKeydown);
4002 return () => document.removeEventListener("keydown", onKeydown);
4003 }, [sidebarCollapsed, desktopPlatform, handleNavigateTopic]);
4004
4005 const paletteItems = useMemo<PaletteItem[]>(() => {
4006 const cmds: PaletteItem[] = [
4007 { id: "cmd-new", group: t("palette.group.commands"), title: t("palette.cmd.newSession"), icon: <SquarePen size={15} />, compact: true, keywords: ["new", "新建"], run: () => void handleNewTab() },
4008 { id: "cmd-trash", group: t("palette.group.commands"), title: t("palette.cmd.trash"), icon: <Trash2 size={15} />, compact: true, keywords: ["trash", "回收站"], run: () => void openTrash() },
4009 { id: "cmd-settings", group: t("palette.group.commands"), title: t("palette.cmd.settings"), icon: <SettingsIcon size={15} />, compact: true, keywords: ["settings", "设置"], run: () => setSettingsTarget("general") },
4010 { id: "cmd-appearance", group: t("palette.group.commands"), title: t("palette.cmd.appearance"), icon: <Palette size={15} />, compact: true, keywords: ["theme", "appearance", "外观", "主题"], run: () => setSettingsTarget("appearance") },
4011 {
4012 id: "cmd-theme-reset",
4013 group: t("palette.group.commands"),
4014 title: t("settings.themeLibrary.reset"),
4015 icon: <Palette size={15} />,
4016 compact: true,
4017 keywords: ["theme", "reset", "default", "恢复默认", "主题"],
4018 run: () => {
4019 void app.ResetThemePack()
4020 .then(() => {
4021 clearThemePack();
4022 notice(t("settings.themeReset"));
4023 })
4024 .catch((err) => showToast(err instanceof Error ? err.message : String(err), "error"));
4025 },
4026 },
4027 { id: "cmd-memory", group: t("palette.group.commands"), title: t("palette.cmd.memory"), icon: <Brain size={15} />, compact: true, keywords: ["memory", "记忆"], run: () => setSettingsTarget("memory") },
4028 { id: "cmd-models", group: t("palette.group.commands"), title: t("palette.cmd.models"), icon: <Cpu size={15} />, compact: true, keywords: ["model", "模型"], run: () => setSettingsTarget("models") },
4029 {
4030 id: "cmd-usage-stats",
4031 group: t("palette.group.commands"),
4032 title: t("palette.cmd.usageStats"),
4033 icon: <BarChart3 size={15} />,
4034 compact: true,
4035 keywords: ["usage", "stats", "statistics", "用量", "统计"],
4036 run: () => {
4037 setSettingsFocus((current) => ({
4038 target: "model-stats",
4039 requestId: (current?.requestId ?? 0) + 1,
4040 }));
4041 setSettingsTarget("models");
4042 },
4043 },
4044 { id: "cmd-terminal", group: t("palette.group.commands"), title: t("rightDock.terminal"), icon: <TerminalSquare size={15} />, compact: true, keywords: ["terminal", "shell", "终端"], run: () => toggleTerminalPanel() },
4045 {
4046 id: "cmd-reload-runtime",
4047 group: t("palette.group.commands"),
4048 title: t("palette.cmd.reloadRuntime"),
4049 icon: <RotateCw size={15} />,
4050 compact: true,
4051 keywords: ["reload", "runtime", "重载", "运行时"],
4052 run: () => {
4053 const tabID = activeTab?.id;
4054 if (!tabID) return;
4055 // Success/queued feedback arrives as a tab notice from the Go side;
4056 // only hard failures need a toast here.
4057 void app.ReloadRuntime(tabID).catch((err) => showToast(err instanceof Error ? err.message : String(err), "error"));
4058 },
4059 },
4060 ];
4061 const startOfDay = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
4062 const dayLabel = (ms: number) => {
4063 const days = Math.round((startOfDay(new Date()) - startOfDay(new Date(ms))) / 86_400_000);
4064 if (days <= 0) return t("history.today");
4065 if (days === 1) return t("history.yesterday");
4066 return new Date(ms).toLocaleDateString();
4067 };
4068 const sessionItems: PaletteItem[] = paletteSessions.slice(0, 12).map((s) => ({
4069 id: `sess-${s.path}`,
4070 group: t("palette.group.sessions"),
4071 title: paletteSessionDisplayTitle(s, t("history.emptySession")),
4072 hint: paletteSessionHint(s),
4073 keywords: paletteSessionKeywords(s),
4074 meta: dayLabel(sessionActivityTime(s)),
4075 badge: t(s.turns === 1 ? "history.turnOne" : "history.turnOther", { n: s.turns }),
4076 run: () => void onResumeSession(s),
4077 }));
4078 const remoteItems: PaletteItem[] = remoteHosts.map((host) => {
4079 const status = remoteStatuses[host.id];
4080 const connected = status?.state === "connected" || status?.state === "degraded";
4081 const target = `${host.user ? `${host.user}@` : ""}${host.host}${host.port && host.port !== 22 ? `:${host.port}` : ""}`;
4082 return {
4083 id: `remote-${host.id}`,
4084 group: t("palette.group.remote"),
4085 title: connected
4086 ? t("palette.remote.open", { host: host.label })
4087 : t("palette.remote.connect", { host: host.label }),
4088 hint: host.defaultWorkspace || target,
4089 icon: <Server size={15} />,
4090 keywords: ["ssh", "remote", "远程", "连接", host.label, host.host],
4091 run: () => {
4092 if (connected) openRemoteWorkspaceFromStatus(host);
4093 else connectAndOpenRemoteWorkspace(host);
4094 },
4095 };
4096 });
4097 const extensionItems: PaletteItem[] = paletteExtensionActions.map((action) => ({
4098 id: `ext-${action.slash}`,
4099 group: t("palette.group.extensions"),
4100 title: action.description || action.slash,
4101 hint: action.slash,
4102 icon: <Puzzle size={15} />,
4103 keywords: ["extension", "扩展", action.plugin, action.action, action.slash],
4104 run: () => {
4105 const tabID = activeTab?.id;
4106 if (!tabID) return;
4107 // The extension's result message is user-facing feedback; only hard
4108 // failures need an error toast.
4109 void app.InvokeExtensionAction(tabID, action.slash, {})
4110 .then((message) => {
4111 if (message) showToast(message, "info");
4112 })
4113 .catch((err) => showToast(err instanceof Error ? err.message : String(err), "error"));
4114 },
4115 }));
4116 return [...cmds, ...extensionItems, ...remoteItems, ...sessionItems];
4117 }, [t, paletteSessions, paletteExtensionActions, remoteHosts, remoteStatuses, activeTab?.id, handleNewTab, openTrash, onResumeSession, openRemoteWorkspaceFromStatus, connectAndOpenRemoteWorkspace, openRightDockMode, showToast]);
4118 // Delete / rename act on disk, then re-fetch so the panel reflects the change.
4119 const onDeleteSession = useCallback(
4120 async (path: string) => {
4121 if (state.running) return;
4122 try {
4123 await deleteSession(path);
4124 } catch {
4125 await refreshHistoryView();
4126 return;
4127 }
4128 // Local state removal: filter the deleted session out of the current
4129 // history view instead of re-fetching the full list from the backend.
4130 setHistView((cur) =>
4131 cur === null || cur.kind !== "history"
4132 ? cur
4133 : { ...cur, sessions: cur.sessions.filter((s) => s.path !== path) },
4134 );
4135 },
4136 [state.running, deleteSession, refreshHistoryView],
4137 );
4138 const onDeleteManySessions = useCallback(
4139 async (paths: string[]) => {
4140 if (state.running) return;
4141 const uniquePaths = Array.from(new Set(paths));
4142 for (const path of uniquePaths) {
4143 // Best effort per path: one locked/missing file must not abandon the
4144 // rest of the sweep. The guarded backend method revalidates actual
4145 // branch and parent content before moving anything.
4146 await app.DeleteRecoveryCopy(path).catch(() => undefined);
4147 }
4148 await refreshHistoryView();
4149 },
4150 [state.running, refreshHistoryView],
4151 );
4152 const onRenameSession = useCallback(
4153 async (path: string, title: string) => {
4154 if (state.running) return;
4155 await renameSession(path, title);
4156 const sessions = await listSessions();
4157 setHistView((cur) =>
4158 cur === null
4159 ? null
4160 : cur.kind === "history"
4161 ? { ...cur, sessions: cur.source === "scope" ? sessionsForScope(sessions, cur.filter) : sessions }
4162 : cur,
4163 );
4164 },
4165 [state.running, renameSession, listSessions],
4166 );
4167 const onRestoreTrashedSession = useCallback(
4168 async (path: string) => {
4169 await restoreSession(path);
4170 const trashed = await listTrashedSessions();
4171 setHistView((cur) => (cur === null ? null : { kind: "trash", sessions: trashed }));
4172 },
4173 [restoreSession, listTrashedSessions],
4174 );
4175 const onPurgeTrashedSession = useCallback(
4176 async (path: string) => {
4177 await purgeTrashedSession(path);
4178 const trashed = await listTrashedSessions();
4179 setHistView((cur) => (cur === null ? null : { kind: "trash", sessions: trashed }));
4180 },
4181 [purgeTrashedSession, listTrashedSessions],
4182 );
4183 const onPurgeAllTrashedSessions = useCallback(
4184 async (paths: string[]) => {
4185 const uniquePaths = Array.from(new Set(paths));
4186 for (const path of uniquePaths) {
4187 await purgeTrashedSession(path);
4188 }
4189 const trashed = await listTrashedSessions();
4190 setHistView((cur) => (cur === null ? null : { kind: "trash", sessions: trashed }));
4191 },
4192 [purgeTrashedSession, listTrashedSessions],
4193 );
4194 const onPurgeRecoveryCopies = useCallback(
4195 async (paths: string[]) => {
4196 const uniquePaths = Array.from(new Set(paths));
4197 for (const path of uniquePaths) {
4198 // Permanent copy cleanup must not trust the list result: the backend
4199 // rechecks the trashed transcript and its live parent for every path.
4200 await app.PurgeRecoveryCopy(path).catch(() => undefined);
4201 }
4202 const trashed = await listTrashedSessions();
4203 setHistView((cur) => (cur === null ? null : { kind: "trash", sessions: trashed }));
4204 },
4205 [listTrashedSessions],
4206 );
4207
4208 // Workspace: open the folder chooser and switch projects. The hook resets the
4209 // transcript and refreshes meta on a pick. A cancel is a no-op.
4210 const switchFolder = useCallback(async (path?: string) => {
4211 const picked = path === undefined ? await pickWorkspace() : await switchWorkspace(path);
4212 if (picked) {
4213 setProjectRevision((value) => value + 1);
4214 await refreshTabMetas(undefined, { afterMutation: true });
4215 }
4216 return picked;
4217 }, [pickWorkspace, switchWorkspace, refreshTabMetas]);
4218
4219 const refreshProjectsAndTabs = useCallback(async () => {
4220 setProjectRevision((value) => value + 1);
4221 const tabs = await refreshTabMetas(undefined, { afterMutation: true });
4222 if (activeTabId && !tabs.some((tab) => tab.id === activeTabId)) {
4223 await syncActiveTab(false);
4224 }
4225 }, [activeTabId, refreshTabMetas, syncActiveTab]);
4226
4227 const renameTopic = useCallback(async (topicId: string, title: string) => {
4228 const nextTitle = title.trim();
4229 if (!topicId || !nextTitle) return;
4230 try {
4231 await app.RenameTopic(topicId, nextTitle);
4232 await refreshProjectsAndTabs();
4233 } catch (err) {
4234 showToast(err instanceof Error ? err.message : String(err), "error");
4235 }
4236 }, [refreshProjectsAndTabs, showToast]);
4237
4238 const startActiveTopicRename = useCallback(() => {
4239 if (!activeTab?.topicId) return;
4240 topicRenameSkipCommitRef.current = false;
4241 topicRenameCommitHandledRef.current = false;
4242 setRenamingTopicId(activeTab.topicId);
4243 setTopicTitleDraft(activeTab.topicTitle || "");
4244 }, [activeTab?.topicId, activeTab?.topicTitle]);
4245
4246 const cancelActiveTopicRename = useCallback(() => {
4247 topicRenameSkipCommitRef.current = true;
4248 topicRenameCommitHandledRef.current = true;
4249 setRenamingTopicId(null);
4250 setTopicTitleDraft("");
4251 }, []);
4252
4253 const commitActiveTopicRename = useCallback(async () => {
4254 if (topicRenameSkipCommitRef.current) {
4255 topicRenameSkipCommitRef.current = false;
4256 topicRenameCommitHandledRef.current = false;
4257 setRenamingTopicId(null);
4258 return;
4259 }
4260 if (topicRenameCommitHandledRef.current) return;
4261 topicRenameCommitHandledRef.current = true;
4262 const topicId = renamingTopicId;
4263 setRenamingTopicId(null);
4264 if (!topicId) return;
4265 const nextTitle = topicTitleDraft.trim();
4266 if (!nextTitle) return;
4267 try {
4268 await renameTopic(topicId, nextTitle);
4269 } catch {
4270 /* keep the app usable if a stale topic cannot be renamed */
4271 }
4272 }, [renameTopic, renamingTopicId, topicTitleDraft]);
4273
4274 const sidebarExpandBlocked = false;
4275 const sidebarToggleTitle = sidebarCollapsed
4276 ? t("sidebar.expand")
4277 : t("sidebar.collapse");
4278 const sidebarNavTooltipDisabled = !sidebarCollapsed;
4279 const browserPreviewChrome = typeof window !== "undefined" && !window.runtime;
4280 const browserMockScenario = browserPreviewChrome ? browserMockScenarioParam() : "";
4281 const guidanceQueueMockItems = isGuidanceMockScenario(browserMockScenario) ? GUIDANCE_QUEUE_MOCK_ITEMS : undefined;
4282 const workspacePanelResetWidth = rightDockDetailActive
4283 ? RIGHT_DOCK_PREVIEW_DEFAULT_WIDTH
4284 : desktopLayoutStyle === "creation"
4285 ? defaultCreationRightDockTreeWidth()
4286 : defaultRightDockTreeWidth();
4287 const workspacePanelResizeMinWidth = workspacePanelAriaMinWidth(workspacePanelMinWidth, workspacePanelRenderWidth);
4288 const workspacePanelMaxWidth = rightDockDetailActive ? RIGHT_DOCK_MAX_WIDTH : RIGHT_DOCK_TREE_MAX_WIDTH;
4289 const sidebarCreation = desktopLayoutStyle === "creation";
4290 const topicbarTitle = sidebarImDetailConnection ? t("botDetail.title", { name: sidebarImDetailConnection.title }) : topicDisplayTitle(activeTab);
4291 const topicbarWorkspaceLabel = sidebarImDetailConnection ? t("botDetail.subtitle") : activeTab ? tabWorkspaceTitle(activeTab) : "";
4292 const topicbarWorkspacePath = activeTab?.scope === "project" ? activeTab.workspaceRoot || state.meta?.cwd : "";
4293 const topicbarImSource = activeTab?.scope === "global" && activeTab.topicId ? imTopicSources[activeTab.topicId] : undefined;
4294 const topicbarImSourceLabel = sidebarImDetailConnection
4295 ? sidebarImDetailConnection.platformLabel
4296 : topicbarImSource ? t("msg.fromIm", { source: topicbarImSource.label }) : "";
4297 const topicbarImSourcePlatform = sidebarImDetailConnection?.platform ?? topicbarImSource?.platform;
4298 const topicbarSubtitleVisible = !sidebarCreation && Boolean(topicbarWorkspaceLabel || topicbarImSourceLabel);
4299 const topicbarSubtitleTitle = sidebarImDetailConnection
4300 ? [topicbarWorkspaceLabel, topicbarImSourceLabel, sidebarImScopeLabel(sidebarImDetailConnection, t)].filter(Boolean).join(" · ")
4301 : [topicbarWorkspacePath || topicbarWorkspaceLabel, topicbarImSourceLabel].filter(Boolean).join(" · ");
4302 const topicbarCanRename = !sidebarImDetailConnection && Boolean(activeTab?.topicId);
4303 const topicbarTitleEditSize = Math.min(56, Math.max(4, topicTitleDraft.length || topicbarTitle.length || 1));
4304 const sidebarWorkbench = desktopLayoutStyle === "workbench";
4305 // The Wails drag runtime ignores anything with detail !== 1, so a double click
4306 // on a --wails-draggable region never reaches the OS. Both platforms that hide
4307 // their native title bar need this handled here.
4308 const chromeDoubleClickZooms = windowsFramelessChrome || desktopPlatform === "darwin";
4309 const handleChromeTitlebarDoubleClick = useCallback((event: ReactMouseEvent<HTMLDivElement>) => {
4310 if (!chromeDoubleClickZooms) return;
4311 const target = event.target as HTMLElement | null;
4312 const onChromeSurface = target?.closest(".app-chrome, .topicbar, .workbench-dock__tools");
4313 const onMacOSWorkbenchSidebarTitlebar = isMacOSWorkbenchSidebarTitlebar(target, event.clientY, desktopPlatform);
4314 if (!onChromeSurface && !onMacOSWorkbenchSidebarTitlebar) return;
4315 if (target?.closest("button, input, textarea, select, a, [role='button'], [role='tab'], .windows-window-controls")) return;
4316 event.preventDefault();
4317 void app.ToggleMaximiseMainWindow()
4318 .then(() => window.setTimeout(syncMainWindowMaximised, 80))
4319 .catch(() => undefined);
4320 }, [chromeDoubleClickZooms, desktopPlatform, syncMainWindowMaximised]);
4321 // Creation keeps the classic sidebar/chat structure while gating chrome tweaks
4322 // behind its own style flag so classic/workbench remain unchanged.
4323 const appChromeHidden = sidebarWorkbench || sidebarCreation;
4324 const workbenchChromeHidden = sidebarWorkbench;
4325 const sidebarClassName = [
4326 "sidebar",
4327 sidebarCollapsed ? "sidebar--collapsed" : "",
4328 sidebarWorkbench ? "sidebar--workbench" : "",
4329 ].filter(Boolean).join(" ");
4330
4331 return (
4332 <ShellExpandProvider>
4333 <UpdaterProvider>
4334 <ShellHotkeys />
4335 <TextSizeHotkeys />
4336 <div
4337 ref={appRef}
4338 onDoubleClickCapture={handleChromeTitlebarDoubleClick}
4339 className={[
4340 "app",
4341 `app--${desktopPlatform}`,
4342 windowsFramelessChrome ? "app--windows-frameless" : "",
4343 browserPreviewChrome ? "app--browser-preview" : "",
4344 sidebarWorkbench ? "app--workbench" : "",
4345 sidebarCreation ? "app--creation" : "",
4346 !sidebarWorkbench && !sidebarCreation ? "app--classic" : "",
4347 ].filter(Boolean).join(" ")}
4348 >
4349 <ThemeBackground />
4350 <div
4351 ref={layoutRef}
4352 className={[
4353 "layout",
4354 sidebarWorkbench ? "layout--workbench" : "",
4355 workbenchChromeHidden ? "layout--workbench-chrome-hidden" : "",
4356 sidebarCreation ? "layout--creation-chrome-hidden" : "",
4357 sidebarImDetailConnection ? "layout--statusbar-hidden" : "",
4358 sidebarCollapsed ? "layout--sidebar-collapsed" : "",
4359 sidebarResizing ? "layout--resizing layout--sidebar-resizing" : "",
4360 workspacePanelGridOpen ? "layout--workspace-open" : "",
4361 "layout--terminal-drawer-open",
4362 terminalPanelOpen ? "layout--terminal-drawer-expanded" : "",
4363 terminalResizing ? "layout--terminal-resizing" : "",
4364 workspacePanelOpen && workspacePanelMaximized ? "layout--workspace-maximized" : "",
4365 workspacePanelResizing ? "layout--resizing layout--workspace-resizing" : "",
4366 ]
4367 .filter(Boolean)
4368 .join(" ")}
4369 style={layoutStyle}
4370 onTransitionEnd={handleTerminalTransitionEnd}
4371 >
4372 {!appChromeHidden && (
4373 <AppChrome
4374 platform={desktopPlatform}
4375 browserPreviewChrome={browserPreviewChrome}
4376 workbenchChrome={sidebarWorkbench}
4377 tabs={visibleTabs}
4378 activeTabId={visibleTabId}
4379 revealActiveSignal={tabRevealSignal}
4380 commandCompact={true}
4381 sidebarTogglePressed={sidebarTogglePressed}
4382 sidebarExpandBlocked={sidebarExpandBlocked}
4383 sidebarCollapsed={sidebarCollapsed}
4384 sidebarToggleTitle={sidebarToggleTitle}
4385 workspacePanelMaximized={workspacePanelMaximized}
4386 workspacePanelRenderable={workspacePanelRenderable}
4387 workspaceTogglePressed={workspaceTogglePressed}
4388 workspacePanelLabel={workspacePanelRenderable ? t("rightDock.collapse") : t("rightDock.expand")}
4389 onToggleSidebar={toggleSidebar}
4390 onToggleWorkspacePanel={toggleWorkspacePanel}
4391 onTabChange={(id) => void handleTabChange(id)}
4392 onTabClose={(id) => void handleTabClose(id)}
4393 onTabsClose={(ids, nextActiveTabId) => void handleTabsClose(ids, nextActiveTabId)}
4394 onTabsReorder={(ids) => void handleTabsReorder(ids)}
4395 onNewTab={() => void handleNewTab()}
4396 onOpenPalette={() => void openPalette()}
4397 />
4398 )}
4399 <a className="skip-to-composer" href="#composer-input">
4400 {t("shortcuts.skipToComposer")}
4401 </a>
4402
4403 <aside className={sidebarClassName} aria-label={t("sidebar.navigation")}>
4404 {sidebarWorkbench ? (
4405 <>
4406 <div className="sidebar__head" aria-hidden={sidebarCollapsed}>
4407 <div className="sidebar__brand sidebar__brand--workbench">
4408 <img src={logoWordmark} alt="Reasonix" className="sidebar__brand-logo sidebar__brand-logo--workbench" draggable={false} />
4409 </div>
4410 </div>
4411
4412 <div className="sidebar__quick-actions">
4413 <button
4414 className="sidebar__quick-action"
4415 type="button"
4416 onClick={() => {
4417 void handleNewTab();
4418 }}
4419 >
4420 <MessageSquare size={18} aria-hidden="true" />
4421 <span>{t("topbar.newSession")}</span>
4422 </button>
4423 </div>
4424 </>
4425 ) : (
4426 <>
4427 <div className="sidebar__brand" aria-hidden={sidebarCollapsed}>
4428 <img src={logoWordmark} alt="Reasonix" className="sidebar__brand-logo" draggable={false} />
4429 </div>
4430
4431 <button
4432 className="sidebar__new"
4433 onClick={() => {
4434 void handleNewTab();
4435 }}
4436 >
4437 <SquarePen size={18} />
4438 <span>{sidebarCreation ? t("creation.sidebar.newChat") : t("topbar.newSession")}</span>
4439 </button>
4440 </>
4441 )}
4442
4443 {sidebarCreation && (
4444 <section className="sidebar-feature-zone" aria-label={t("settings.title")}>
4445 <div className="sidebar-feature-zone__title">{t("creation.sidebar.features")}</div>
4446 <div className="sidebar-feature-zone__items">
4447 <button
4448 className="sidebar-feature-zone__item"
4449 type="button"
4450 onClick={() => {
4451 closeTransientOverlays();
4452 setSettingsTarget("skills");
4453 }}
4454 >
4455 <Command size={14} aria-hidden="true" />
4456 <span>{t("creation.sidebar.skills")}</span>
4457 </button>
4458 <button
4459 className="sidebar-feature-zone__item"
4460 type="button"
4461 onClick={() => {
4462 closeTransientOverlays();
4463 setSettingsTarget("memory");
4464 }}
4465 >
4466 <Brain size={14} aria-hidden="true" />
4467 <span>{t("settings.tab.memory")}</span>
4468 </button>
4469 <button
4470 className="sidebar-feature-zone__item"
4471 type="button"
4472 onClick={() => {
4473 closeTransientOverlays();
4474 setSettingsTarget("bots");
4475 }}
4476 >
4477 <MessageSquare size={14} aria-hidden="true" />
4478 <span>{t("creation.sidebar.messageChannels")}</span>
4479 </button>
4480 <button
4481 className="sidebar-feature-zone__item"
4482 type="button"
4483 onClick={() => setHeartbeatOpen(true)}
4484 >
4485 <AlarmClock size={14} aria-hidden="true" />
4486 <span>{t("sidebar.automation")}</span>
4487 </button>
4488 </div>
4489 </section>
4490 )}
4491
4492 <section className="sidebar__section sidebar__section--projects">
4493 <ProjectTree
4494 activeScope={activeTab?.scope}
4495 activeWorkspaceRoot={activeTab?.workspaceRoot}
4496 activeTopicId={activeTab?.topicId}
4497 activeSessionPath={activeTab?.sessionPath}
4498 imTopicSources={imTopicSources}
4499 onOpenTopic={handleOpenTopic}
4500 onCreateTopic={(scope, workspaceRoot) => openBlankSession(scope, scope === "project" ? workspaceRoot : "")}
4501 onCreateDeliveryWorktree={(workspaceRoot) => enqueueNavigation({ kind: "delivery-worktree", workspaceRoot })}
4502 onTopicsChanged={refreshProjectsAndTabs}
4503 onRenameTopic={renameTopic}
4504 refreshSignal={projectRevision}
4505 onAddProject={async () => {
4506 await switchFolder();
4507 }}
4508 timeFilter={topicTimeFilter}
4509 onTimeFilterChange={setTopicTimeFilter}
4510 variant={sidebarWorkbench ? "workbench" : sidebarCreation ? "creation" : "classic"}
4511 searchExpanded={!sidebarCreation || sidebarSearchOpen}
4512 searchFocusSignal={sidebarSearchFocusSignal}
4513 showShortcutBadges={showTopicBadges}
4514 shortcutPlatform={desktopPlatform}
4515 onVisibleTopicsChange={handleVisibleTopicsChange}
4516 />
4517 </section>
4518
4519 {sidebarWorkbench ? (
4520 <nav className="sidebar__nav sidebar__nav--footer">
4521 <div className="sidebar__utility-row" aria-label={t("sidebar.utilityActions")}>
4522 <Tooltip label={t("sidebar.trash")} fill side="top">
4523 <button
4524 className="sidebar__utility-button"
4525 type="button"
4526 onClick={() => void openTrash()}
4527 >
4528 <Trash2 size={16} aria-hidden="true" />
4529 <span className="sr-only">{t("sidebar.trash")}</span>
4530 </button>
4531 </Tooltip>
4532 <Tooltip label={t("heartbeat.scheduler")} fill side="top">
4533 <button
4534 className="sidebar__utility-button"
4535 type="button"
4536 onClick={() => setHeartbeatOpen(true)}
4537 >
4538 <AlarmClock size={16} aria-hidden="true" />
4539 <span className="sr-only">{t("sidebar.automation")}</span>
4540 </button>
4541 </Tooltip>
4542 <Tooltip label={t("topbar.settings")} fill side="top">
4543 <button
4544 className="sidebar__utility-button"
4545 type="button"
4546 onClick={() => {
4547 closeTransientOverlays();
4548 setSettingsTarget("general");
4549 }}
4550 >
4551 <SettingsIcon size={16} aria-hidden="true" />
4552 <span className="sr-only">{t("topbar.settings")}</span>
4553 </button>
4554 </Tooltip>
4555 </div>
4556 </nav>
4557 ) : (
4558 <nav className="sidebar__nav">
4559 {sidebarCreation && (
4560 <Tooltip label={t("projectTree.searchPlaceholder")} fill side="right" disabled={sidebarNavTooltipDisabled}>
4561 <button
4562 className={`sidebar__navitem sidebar__navitem--search${sidebarSearchOpen ? " sidebar__navitem--active" : ""}`}
4563 type="button"
4564 aria-label={t("projectTree.searchPlaceholder")}
4565 aria-pressed={sidebarSearchOpen}
4566 onClick={() => {
4567 setSidebarSearchOpen((open) => !open);
4568 setSidebarSearchFocusSignal((signal) => signal + 1);
4569 }}
4570 >
4571 <Search size={15} />
4572 <span>{t("tabBar.commandSearchCompact")}</span>
4573 </button>
4574 </Tooltip>
4575 )}
4576 <Tooltip label={t("sidebar.trash")} fill side="right" disabled={sidebarNavTooltipDisabled}>
4577 <button
4578 className="sidebar__navitem"
4579 onClick={() => void openTrash()}
4580 >
4581 <Trash2 size={15} />
4582 <span>{t("sidebar.trash")}</span>
4583 </button>
4584 </Tooltip>
4585 {!sidebarCreation && (
4586 <Tooltip label={t("heartbeat.scheduler")} fill side="right" disabled={sidebarNavTooltipDisabled}>
4587 <button
4588 className="sidebar__navitem"
4589 onClick={() => setHeartbeatOpen(true)}
4590 >
4591 <AlarmClock size={15} />
4592 <span>{t("sidebar.automation")}</span>
4593 </button>
4594 </Tooltip>
4595 )}
4596 <Tooltip label={t("topbar.settings")} fill side="right" disabled={sidebarNavTooltipDisabled}>
4597 <button
4598 className="sidebar__navitem"
4599 onClick={() => {
4600 closeTransientOverlays();
4601 setSettingsTarget("general");
4602 }}
4603 >
4604 <SettingsIcon size={15} />
4605 <span>{t("topbar.settings")}</span>
4606 </button>
4607 </Tooltip>
4608 </nav>
4609 )}
4610
4611 </aside>
4612 <button
4613 className="sidebar-resizer"
4614 type="button"
4615 role="separator"
4616 aria-orientation="vertical"
4617 aria-label={t("sidebar.resize")}
4618 aria-valuemin={sidebarResizeMinWidth}
4619 aria-valuemax={SIDEBAR_MAX_WIDTH}
4620 aria-valuenow={sidebarRenderWidth}
4621 onPointerDown={startSidebarResize}
4622 onKeyDown={resizeSidebarWithKeyboard}
4623 onDoubleClick={() => setExpandedSidebarWidth(desktopLayoutStyle === "creation" ? defaultCreationSidebarWidth() : defaultSidebarWidth())}
4624 />
4625 {sidebarCreation && (
4626 <button
4627 className={`sidebar-collapse-toggle${sidebarCollapsed ? " sidebar-collapse-toggle--collapsed" : ""}${sidebarTogglePressed ? " sidebar-collapse-toggle--pressed" : ""}`}
4628 type="button"
4629 onClick={toggleSidebar}
4630 aria-label={sidebarToggleTitle}
4631 aria-pressed={!sidebarCollapsed}
4632 title={sidebarToggleTitle}
4633 >
4634 {sidebarCollapsed ? <PanelRight size={14} /> : <PanelLeft size={14} />}
4635 </button>
4636 )}
4637
4638 <section className={`chat-pane${creationEmptyHero ? " chat-pane--creation-empty" : ""}`}>
4639 <>
4640 <header className="topicbar">
4641 {workbenchChromeHidden && (
4642 <Tooltip label={sidebarToggleTitle}>
4643 <button
4644 className={[
4645 "topicbar__chrome-btn",
4646 sidebarExpandBlocked ? "topicbar__chrome-btn--blocked" : "",
4647 sidebarTogglePressed ? "topicbar__chrome-btn--pressed" : "",
4648 ].filter(Boolean).join(" ")}
4649 type="button"
4650 onClick={sidebarExpandBlocked ? undefined : toggleSidebar}
4651 aria-label={sidebarToggleTitle}
4652 aria-pressed={!sidebarCollapsed}
4653 aria-disabled={sidebarExpandBlocked}
4654 >
4655 <PanelLeft size={15} />
4656 </button>
4657 </Tooltip>
4658 )}
4659 <div className="topicbar__identity">
4660 <div className="topicbar__title-row">
4661 {topicbarEditing ? (
4662 <div className="topicbar__title-edit">
4663 <input
4664 autoFocus
4665 className="topicbar__title-input"
4666 aria-label={t("topicBar.renameSession")}
4667 size={sidebarCreation ? topicbarTitleEditSize : undefined}
4668 value={topicTitleDraft}
4669 onChange={(event) => setTopicTitleDraft(event.target.value)}
4670 onKeyDown={(event: KeyboardEvent<HTMLInputElement>) => {
4671 if (event.key === "Enter") {
4672 event.preventDefault();
4673 void commitActiveTopicRename();
4674 }
4675 if (event.key === "Escape") {
4676 event.preventDefault();
4677 cancelActiveTopicRename();
4678 }
4679 }}
4680 onBlur={() => void commitActiveTopicRename()}
4681 />
4682 </div>
4683 ) : sidebarCreation && topicbarCanRename ? (
4684 <h1 title={topicTitle(activeTab)}>
4685 <button
4686 className="topicbar__title-button"
4687 type="button"
4688 onClick={startActiveTopicRename}
4689 aria-label={t("topicBar.renameSession")}
4690 >
4691 {topicbarTitle}
4692 </button>
4693 </h1>
4694 ) : (
4695 <h1 title={sidebarImDetailConnection ? topicbarTitle : topicTitle(activeTab)}>{topicbarTitle}</h1>
4696 )}
4697 {!sidebarCreation && (
4698 <Tooltip label={t("topicBar.renameSession")}>
4699 <button
4700 className="topicbar__icon-btn"
4701 type="button"
4702 disabled={!topicbarCanRename || topicbarEditing}
4703 onClick={startActiveTopicRename}
4704 aria-label={t("topicBar.renameSession")}
4705 >
4706 <Pencil size={14} />
4707 </button>
4708 </Tooltip>
4709 )}
4710 </div>
4711 {topicbarSubtitleVisible && (
4712 <div className="topicbar__subtitle" title={topicbarSubtitleTitle}>
4713 {topicbarWorkspaceLabel && <span>{topicbarWorkspaceLabel}</span>}
4714 {activeTab?.isolatedWorktree && <WorktreeBadge size={11} />}
4715 {topicbarImSourcePlatform && (
4716 <span className={`topicbar__source-chip topicbar__source-chip--${topicbarImSourcePlatform}`}>
4717 {topicbarImSourceLabel}
4718 </span>
4719 )}
4720 </div>
4721 )}
4722 </div>
4723 <div className="topicbar__spacer" />
4724 <div className="topicbar__actions">
4725 {sidebarCreation && !sidebarImDetailConnection && activeTab?.scope === "project" && (
4726 <ExternalOpener tabId={activeTab.id} dismissSignal={transientOverlayDismissSignal} />
4727 )}
4728 {!sidebarImDetailConnection && (
4729 <>
4730 <Tooltip label={t("topicBar.copyAll")}>
4731 <CopyButton
4732 getText={getSessionMarkdown}
4733 label={t("topicBar.copyAll")}
4734 className="topicbar__action-btn topicbar__action-btn--icon topicbar__action-btn--utility"
4735 showInlineLabel={false}
4736 />
4737 </Tooltip>
4738 <div className={`topicbar__export${topicExportOpen ? " topicbar__export--open" : ""}`}>
4739 <Tooltip label={t("topicBar.export")}>
4740 <button
4741 className="topicbar__action-btn topicbar__action-btn--icon topicbar__action-btn--utility"
4742 type="button"
4743 disabled={!sessionHasContent}
4744 aria-label={t("topicBar.export")}
4745 aria-haspopup="menu"
4746 aria-expanded={topicExportOpen}
4747 onClick={() => setTopicExportOpen((open) => !open)}
4748 >
4749 <Download size={14} />
4750 </button>
4751 </Tooltip>
4752 {topicExportOpen && (
4753 <div className="topicbar__export-menu" role="menu">
4754 <button type="button" role="menuitem" onClick={() => void exportSession("markdown")}>
4755 <FileText size={13} />
4756 <span>{t("topicBar.exportMarkdown")}</span>
4757 </button>
4758 <button type="button" role="menuitem" onClick={() => void exportSession("json")}>
4759 <FileJson size={13} />
4760 <span>{t("topicBar.exportJson")}</span>
4761 </button>
4762 <button type="button" role="menuitem" onClick={() => void exportSession("pdf")}>
4763 <FileDown size={13} />
4764 <span>{t("topicBar.exportPdf")}</span>
4765 </button>
4766 <button type="button" role="menuitem" onClick={() => void exportSession("image")}>
4767 <FileImage size={13} />
4768 <span>{t("topicBar.exportImage")}</span>
4769 </button>
4770 </div>
4771 )}
4772 </div>
4773 </>
4774 )}
4775 {!sidebarCreation && (
4776 <Tooltip label={t("workspace.changedTab")}>
4777 <button
4778 className="topicbar__action-btn topicbar__action-btn--label"
4779 type="button"
4780 aria-label={t("workspace.changedTab")}
4781 aria-pressed={workspacePanelRenderable && rightDockMode === "changed"}
4782 onClick={() => openRightDockMode("changed")}
4783 >
4784 <GitBranch size={14} />
4785 <span>{t("workspace.changedTab")}</span>
4786 </button>
4787 </Tooltip>
4788 )}
4789 {!sidebarImDetailConnection && (
4790 <Tooltip label={t("rightDock.terminal")}>
4791 <button
4792 className="topicbar__action-btn topicbar__action-btn--icon topicbar__action-btn--utility"
4793 type="button"
4794 aria-label={t("rightDock.terminal")}
4795 aria-pressed={terminalPanelOpen}
4796 onClick={toggleTerminalPanel}
4797 >
4798 <TerminalSquare size={14} />
4799 </button>
4800 </Tooltip>
4801 )}
4802 {!sidebarCreation && !sidebarImDetailConnection && activeTab?.scope === "project" && (
4803 <ExternalOpener tabId={activeTab.id} dismissSignal={transientOverlayDismissSignal} />
4804 )}
4805 <Tooltip label={t("shortcuts.cheatsheetTitle")}>
4806 <button
4807 className="topicbar__action-btn topicbar__action-btn--icon topicbar__action-btn--utility"
4808 type="button"
4809 aria-label={t("shortcuts.cheatsheetTitle")}
4810 onClick={() => {
4811 closeTransientOverlays();
4812 setSettingsFocus(null);
4813 setSettingsTarget("shortcuts");
4814 }}
4815 >
4816 <CircleHelp size={14} />
4817 </button>
4818 </Tooltip>
4819 <Tooltip label={t("topicBar.command")}>
4820 <button
4821 className={
4822 sidebarCreation
4823 ? "topicbar__action-btn topicbar__action-btn--icon topicbar__action-btn--utility"
4824 : "topicbar__action-btn topicbar__action-btn--label topicbar__action-btn--accent"
4825 }
4826 type="button"
4827 aria-label={t("topicBar.command")}
4828 onClick={() => void openPalette()}
4829 >
4830 <Command size={14} />
4831 {!sidebarCreation && <span>{t("topicBar.command")}</span>}
4832 </button>
4833 </Tooltip>
4834 {(sidebarCreation || workbenchChromeHidden) && (
4835 <Tooltip label={workspacePanelRenderable ? t("rightDock.collapse") : t("rightDock.expand")}>
4836 <button
4837 className={[
4838 "topicbar__chrome-btn",
4839 "topicbar__chrome-btn--workspace",
4840 workspacePanelRenderable ? "topicbar__chrome-btn--active" : "",
4841 workspaceTogglePressed ? "topicbar__chrome-btn--pressed" : "",
4842 ].filter(Boolean).join(" ")}
4843 type="button"
4844 onClick={toggleWorkspacePanel}
4845 aria-label={workspacePanelRenderable ? t("rightDock.collapse") : t("rightDock.expand")}
4846 aria-pressed={workspacePanelRenderable}
4847 >
4848 <PanelRight size={15} />
4849 </button>
4850 </Tooltip>
4851 )}
4852 <Tooltip label="Session summary">
4853 <button
4854 className={`topicbar__action-btn topicbar__action-btn--icon topicbar__action-btn--utility${tasksOpen ? " topicbar__action-btn--active" : ""}`}
4855 type="button"
4856 aria-label="Session summary"
4857 aria-expanded={tasksOpen}
4858 onClick={() => setTasksOpen((open) => !open)}
4859 >
4860 <Activity size={14} />
4861 </button>
4862 </Tooltip>
4863 {tasksOpen && (
4864 <div className="taskmonitor-popover" role="dialog" aria-label="Session summary">
4865 <Suspense fallback={null}>
4866 <TaskMonitorPanel
4867 key={`${activeTab?.id || activeTabId || "none"}:${activeTab?.workspaceRoot || "global"}:${activeTab?.sessionPath || ""}`}
4868 tabID={activeTab?.id || activeTabId || ""}
4869 initialOpen
4870 popover
4871 summaryMode
4872 onClose={() => setTasksOpen(false)}
4873 onOpenSession={openTaskMonitorSession}
4874 />
4875 </Suspense>
4876 </div>
4877 )}
4878 </div>
4879 </header>
4880
4881 {state.meta?.startupErr && (
4882 <div className="banner banner--error">{t("topbar.startupError", { msg: state.meta.startupErr })}</div>
4883 )}
4884 {configLoadWarnings.length > 0 && (
4885 <div className="banner banner--warning banner--actionable">
4886 <span className="banner__msg" title={configLoadWarnings.join("\n")}>
4887 {t("config.loadWarning", { msg: configLoadWarnings[0] })}
4888 </span>
4889 <span className="banner__spacer" />
4890 <button
4891 type="button"
4892 className="btn btn--small"
4893 onClick={() => void app.OpenUserConfigPath?.().catch(() => {})}
4894 >
4895 {t("config.openConfig")}
4896 </button>
4897 <button
4898 type="button"
4899 className="btn btn--small"
4900 onClick={() => {
4901 void (async () => {
4902 try {
4903 const view = await app.ReloadUserConfig?.();
4904 if (view?.configWarnings) setConfigLoadWarnings(view.configWarnings);
4905 else setConfigLoadWarnings([]);
4906 } catch {
4907 /* keep banner */
4908 }
4909 })();
4910 }}
4911 >
4912 {t("config.reloadConfig")}
4913 </button>
4914 <span className="banner__hint">{t("config.doctorHint")}</span>
4915 <button type="button" className="btn btn--small" onClick={() => setConfigLoadWarnings([])}>
4916 {t("updater.dismiss")}
4917 </button>
4918 </div>
4919 )}
4920 {providerSetupNeeded && !needsOnboarding && (
4921 <div className="banner banner--warning banner--actionable">
4922 <span className="banner__msg">{t("onboarding.inlinePrompt")}</span>
4923 <span className="banner__spacer" />
4924 <button type="button" className="btn btn--small" onClick={() => {
4925 setSettingsFocus({ target: "model-access" });
4926 setSettingsTarget("models");
4927 }}>
4928 {t("onboarding.configureProvider")}
4929 </button>
4930 </div>
4931 )}
4932
4933 <UpdateBanner
4934 enabled={startupUpdateChecksEnabled === true}
4935 onShowReleaseNotes={(latest) => {
4936 const version = latest.replace(/^(?:desktop-)?v/, "");
4937 void openExternal(`https://reasonix.io/changelog/v${version}/`);
4938 }}
4939 />
4940
4941 <main className="main">
4942 {sidebarImDetailConnection ? (
4943 <SidebarImConnectionDetail
4944 connection={sidebarImDetailConnection}
4945 onClose={() => setSidebarImDetailConnectionId("")}
4946 onOpenSettings={openBotSettings}
4947 onManageAllowlist={() => openBotAllowlistSettings(sidebarImDetailConnection.connectionId)}
4948 onOpenSession={() => void openSidebarImConnectionSession(sidebarImDetailConnection)}
4949 />
4950 ) : noticePreviewMockEnabled() ? (
4951 <NoticePreviewPanel />
4952 ) : (
4953 <Transcript
4954 items={displayItems}
4955 live={state.live}
4956 liveStore={liveStore}
4957 tabId={activeTabId}
4958 footerHeight={footerHeight}
4959 onPrompt={handleTranscriptPrompt}
4960 onDeliveryContinue={() => void handleDeliveryContinue()}
4961 onEditPrompt={handleEditPrompt}
4962 onRewind={handleMessageAction}
4963 checkpoints={state.checkpoints}
4964 actionPending={state.messageAction != null}
4965 rewindDisabled={Boolean(activeTab?.readOnly) || !controllerReady || hydratePlaceholderActive || rewindState != null || rewindCommitting || state.running || state.messageAction != null || state.approval != null || state.ask != null || clearContextPending}
4966 running={state.running || rewindCommitting}
4967 turnStartAt={state.turnStartAt}
4968 welcomeVariant={sidebarCreation ? "creation" : "default"}
4969 creationMode={sidebarCreation}
4970 actionHoverMenus={sidebarCreation && !hydratePlaceholderActive}
4971 rewindSignal={rewindSignal}
4972 revealSignal={transcriptRevealSignal}
4973 hydrating={transcriptHydrating}
4974 hasOlderHistory={state.historyHasOlder && !rewindState}
4975 olderHistoryCount={state.historyStartTurn}
4976 loadingOlderHistory={state.historyOlderLoading}
4977 onLoadOlderHistory={() => activeTabId && loadOlderHistory(activeTabId)}
4978 invocationMetadata={activeTabId ? invocationMetadataByTab[activeTabId] : undefined}
4979 />
4980 )}
4981 </main>
4982
4983 {!sidebarImDetailConnection && (
4984 <footer className={["footer", terminalPanelOpen && !sidebarCreation ? "footer--compact" : "", decisionSurface ? "footer--decision" : ""].filter(Boolean).join(" ")} ref={footerRef}>
4985 {showTodos && (
4986 <TodoPanel
4987 key={scopedTodoBatch}
4988 stateKey={scopedTodoBatch}
4989 todos={todos}
4990 onDismiss={dismissTodos}
4991 />
4992 )}
4993 {rewindState && (
4994 <Suspense fallback={null}><UndoRewindBanner
4995 meta={{
4996 turns: rewindState.turnDiff,
4997 filesRestored: rewindState.filesRestored ?? [],
4998 filesRemoved: rewindState.filesRemoved ?? [],
4999 onUndo: () => {
5000 const tabId = activeTabId;
5001 if (!tabId) return;
5002 const tx = rewindState.transactionId;
5003 const undo = tx && rewindState.undoAvailable ? undoRewindForTab(tabId, tx) : Promise.resolve(true);
5004 void undo.then((ok) => {
5005 if (!ok) return;
5006 setRewindStateForTab(tabId, null);
5007 setComposerInsertRequestsByTab((current) => ({
5008 ...current,
5009 [tabId]: { id: Date.now(), text: "", mode: "replace" },
5010 }));
5011 setRewindSignal((v) => v + 1);
5012 setDockRefreshKey((v) => v + 1);
5013 setProjectRevision((v) => v + 1);
5014 });
5015 },
5016 }}
5017 /></Suspense>
5018 )}
5019 {decisionSurface === "tool_approval" || decisionSurface === "plan_approval"
5020 ? state.approval && (
5021 <ApprovalModal
5022 key={`${activeTabId ?? ""}:${state.approval.id}`}
5023 approval={state.approval}
5024 cwd={state.meta?.cwd}
5025 tabId={activeTabId}
5026 workspaceScopeKey={workspaceScopeKey}
5027 insertRequest={activePlanRevisionInsertRequest}
5028 onRevisionActiveChange={handleRevisionActiveChange}
5029 onAnswer={async (allow, session, persist) => {
5030 // Approving an exit_plan_mode plan leaves plan mode; await the
5031 // mode switch before sending the approval so the controller
5032 // observes the updated state before it unblocks.
5033 if (state.approval!.tool === "exit_plan_mode") {
5034 if (allow) {
5035 await applyCollaborationMode("normal");
5036 resolvePlanDecision(state.approval!.id, "start_execution");
5037 } else {
5038 resolvePlanDecision(state.approval!.id, "revise_plan");
5039 }
5040 return;
5041 }
5042 approve(state.approval!.id, allow, session, persist);
5043 }}
5044 onResolveRecovery={(action, feedback) => {
5045 resolveRecovery(state.approval!.id, action, feedback ?? "");
5046 }}
5047 onRevisePlan={(text) => {
5048 if (activeTabId) {
5049 setPendingPlanRevisionsByTab((current) => ({ ...current, [activeTabId]: text }));
5050 }
5051 resolvePlanDecision(state.approval!.id, "revise_plan");
5052 }}
5053 onExitPlan={async () => {
5054 await applyCollaborationMode("normal");
5055 resolvePlanDecision(state.approval!.id, "exit_plan");
5056 }}
5057 onStop={() => {
5058 cancel();
5059 }}
5060 toolApprovalMode={toolApprovalMode}
5061 />
5062 )
5063 : decisionSurface === "ask"
5064 ? state.ask && (
5065 <AskCard
5066 key={`${activeTabId ?? ""}:${state.ask.id}`}
5067 ask={state.ask}
5068 onAnswer={answerQuestion}
5069 onDismiss={() => answerQuestion(state.ask!.id, [])}
5070 onStop={() => {
5071 cancel();
5072 }}
5073 />
5074 )
5075 : decisionSurface === "extension_form"
5076 ? state.extensionForm && (
5077 <ExtensionFormDialog
5078 key={`${activeTabId ?? ""}:${state.extensionForm.pluginId}:${state.extensionForm.surfaceId}`}
5079 surface={state.extensionForm}
5080 busy={extensionFormBusy}
5081 onSubmit={(values) => void submitExtensionForm(values)}
5082 onCancel={() => void cancelExtensionForm()}
5083 />
5084 )
5085 : decisionSurface === "workspace_conflict" && workspaceConflict ? (
5086 <RuntimeDecisionCard
5087 id="workspace-conflict"
5088 title={t("runtime.workspaceConflictTitle")}
5089 badge={t("runtime.workspaceConflictBadge")}
5090 meta={workspaceConflict.state === "local"
5091 ? t("runtime.workspaceConflictLocal", { title: workspaceConflict.ownerTitle || t("runtime.unknownTask") })
5092 : t("runtime.workspaceConflictExternal")}
5093 note={t("runtime.workspaceConflictNote")}
5094 onCancel={() => {
5095 cancel();
5096 setWorkspaceConflict(null);
5097 }}
5098 actions={[
5099 ...(workspaceConflict.canReveal ? [{
5100 key: "1", label: t("runtime.revealWriter"), description: t("runtime.revealWriterDesc"),
5101 onClick: () => void revealWorkspaceWriter(),
5102 }] : []),
5103 ...(workspaceConflict.canCreateWorktree ? [{
5104 key: "2", label: t("runtime.openWorktree"), description: t("runtime.openWorktreeDesc"),
5105 onClick: () => void continueInDeliveryWorktree(),
5106 }] : []),
5107 ]}
5108 secondaryAction={{
5109 key: "Esc", label: t("runtime.cancelWait"), description: t("runtime.cancelWaitDesc"),
5110 onClick: () => { cancel(); setWorkspaceConflict(null); },
5111 }}
5112 />
5113 )
5114 : decisionSurface === "mode_jobs" && pendingModeSwitch ? (
5115 <RuntimeDecisionCard
5116 id="mode-jobs"
5117 title={t("runtime.modeJobsTitle", { mode: t(runtimeProfileShortKey(pendingModeSwitch.target)) })}
5118 badge={t("status.jobs", { n: pendingModeSwitch.work.jobs.length })}
5119 meta={t("runtime.modeJobsMeta")}
5120 note={pendingModeSwitch.work.jobs.map((job) => job.label || job.kind).join(" · ")}
5121 onCancel={() => setPendingModeSwitch(null)}
5122 actions={[
5123 {
5124 key: "1", label: pendingModeSwitch.stopping
5125 ? t("status.jobStopping")
5126 : t("runtime.stopAndSwitch", { n: pendingModeSwitch.work.jobs.length }),
5127 description: t("runtime.stopAndSwitchDesc", { mode: t(runtimeProfileShortKey(pendingModeSwitch.target)) }),
5128 onClick: () => void stopJobsAndSwitchMode(),
5129 danger: true, disabled: pendingModeSwitch.stopping,
5130 },
5131 ]}
5132 secondaryAction={{
5133 key: "Esc", label: t("runtime.keepMode"), description: t("runtime.keepModeDesc"),
5134 onClick: () => setPendingModeSwitch(null), disabled: pendingModeSwitch.stopping,
5135 }}
5136 />
5137 )
5138 : decisionSurface === "close_active" && pendingClose ? (
5139 <RuntimeDecisionCard
5140 id="close-active"
5141 title={t("runtime.closeTitle")}
5142 badge={t("status.jobs", { n: pendingClose.work.jobs.length })}
5143 meta={t("runtime.closeMeta")}
5144 onCancel={() => setPendingClose(null)}
5145 actions={[
5146 {
5147 key: "1", label: t("runtime.keepRunning"), description: t("runtime.keepRunningDesc"),
5148 onClick: () => void resolvePendingClose("keep_running"), disabled: pendingClose.stopping,
5149 },
5150 {
5151 key: "2", label: pendingClose.stopping ? t("status.jobStopping") : t("runtime.stopAndClose"),
5152 description: t("runtime.stopAndCloseDesc"), onClick: () => void resolvePendingClose("stop_and_close"),
5153 danger: true, disabled: pendingClose.stopping,
5154 },
5155 ]}
5156 secondaryAction={{
5157 key: "Esc", label: t("runtime.returnToTask"), description: t("runtime.closeCancelDesc"),
5158 onClick: () => setPendingClose(null), disabled: pendingClose.stopping,
5159 }}
5160 />
5161 )
5162 : decisionSurface === "clear_context" ? (
5163 <ClearContextCard
5164 onCancel={cancelClearContext}
5165 onConfirm={() => {
5166 void confirmClearContext();
5167 }}
5168 />
5169 ) : null}
5170 {/* Composer stays mounted under a decision so per-session draft
5171 caches (text, attachments, paste blocks, guidance) survive. */}
5172 <div
5173 className={[
5174 "composer-decision-host",
5175 decisionSurface ? "composer-decision-host--hidden" : "",
5176 creationEmptyHero ? "composer-decision-host--creation-hero" : "",
5177 ].filter(Boolean).join(" ")}
5178 hidden={Boolean(decisionSurface) || undefined}
5179 inert={decisionSurface ? true : undefined}
5180 aria-hidden={decisionSurface ? true : undefined}
5181 >
5182 {creationEmptyHero && (
5183 <h2 className="welcome-creation__headline">{t("welcome.creation.title")}</h2>
5184 )}
5185 <Composer
5186 running={state.running || rewindCommitting}
5187 collaborationMode={collaborationMode}
5188 toolApprovalMode={toolApprovalMode}
5189 tokenMode={tokenMode}
5190 goal={goal}
5191 goalStatus={state.meta?.goalStatus}
5192 goalRuntime={state.meta?.goalRuntime}
5193 cwd={state.meta?.cwd}
5194 modelLabel={state.meta?.label ?? t("status.connecting")}
5195 imageInputEnabled={state.meta?.imageInputEnabled !== false}
5196 tabId={activeTabId}
5197 effort={state.effort}
5198 onSend={handleSend}
5199 onInvocationMetadataChange={handleInvocationMetadataChange}
5200 onSteer={handleSteer}
5201 onCancel={cancel}
5202 onCycleMode={cycleMode}
5203 onSetMode={applyMode}
5204 onSetCollaborationMode={setCollaborationModeFromUi}
5205 onSetToolApprovalMode={applyToolApprovalMode}
5206 onToggleYoloApprovalMode={toggleYoloApprovalMode}
5207 onClearGoal={clearGoalFromUi}
5208 onPauseGoal={pauseGoalFromUi}
5209 onResumeGoal={resumeGoalFromUi}
5210 onSwitchModel={switchModelFromUi}
5211 onSetEffort={setEffort}
5212 onSetTokenMode={applyTokenMode}
5213 insertRequest={composerInsertRequest}
5214 selectedTextRequest={selectedTextRequest}
5215 readOnly={Boolean(activeTab?.readOnly)}
5216 disabled={runtimeTransitioning || rewindCommitting || state.messageAction != null || Boolean(decisionSurface)}
5217 submitDisabled={!controllerReady}
5218 decisionPending={rewindCommitting || state.messageAction != null || Boolean(decisionSurface)}
5219 ready={controllerReady}
5220 turnStartAt={state.turnStartAt}
5221 turnWaitAccumMs={state.turnWaitAccumMs}
5222 promptWaitStartedAt={state.promptWaitStartedAt}
5223 turnTokens={state.turnTokens}
5224 turnArgChars={state.turnArgChars}
5225 retry={state.retry}
5226 suspendedByDecision={Boolean(decisionSurface)}
5227 transientDismissSignal={transientOverlayDismissSignal}
5228 sessionKey={composerSessionKey}
5229 workspaceScopeKey={workspaceScopeKey}
5230 fileRefRefreshKey={composerFileRefRefreshKey}
5231 guidanceConsumedKey={latestGuidanceConsumed?.key}
5232 guidanceConsumedText={latestGuidanceConsumed?.text}
5233 guidanceQueuePreviewItems={guidanceQueueMockItems}
5234 showContextWindowRing={sidebarCreation}
5235 heroMode={creationEmptyHero}
5236 context={state.context}
5237 turnCost={state.turnCost}
5238 currency={state.sessionCurrency}
5239 cacheHitTokens={state.usage?.cacheHitTokens}
5240 cacheMissTokens={state.usage?.cacheMissTokens}
5241 balance={state.balance}
5242 />
5243 </div>
5244 </footer>
5245 )}
5246 </>
5247 </section>
5248
5249 {workspacePanelGridOpen && (
5250 <button
5251 className="workspace-panel-resizer"
5252 type="button"
5253 role="separator"
5254 aria-orientation="vertical"
5255 aria-label={t("rightDock.resize")}
5256 aria-valuemin={workspacePanelResizeMinWidth}
5257 aria-valuemax={Math.max(workspacePanelMaxWidth, workspacePanelRenderWidth)}
5258 aria-valuenow={workspacePanelRenderWidth}
5259 onPointerDown={startWorkspacePanelResize}
5260 onKeyDown={resizeWorkspacePanelWithKeyboard}
5261 onDoubleClick={() => setSavedWorkspacePanelWidth(workspacePanelResetWidth)}
5262 />
5263 )}
5264
5265 {workspacePanelRenderable && (
5266 <aside
5267 className={[
5268 "workbench-dock",
5269 `workbench-dock--${rightDockMode}`,
5270 ].join(" ")}
5271 aria-label={t("rightDock.workbench")}
5272 >
5273 <div className="workbench-dock__tools">
5274 <div className="workbench-dock__tabs" role="tablist" aria-label={t("rightDock.views")}>
5275 {SHOW_CONTEXT_DOCK && desktopLayoutStyle !== "creation" && (
5276 <button
5277 type="button"
5278 role="tab"
5279 aria-selected={rightDockMode === "context"}
5280 className={`workbench-dock__tab${rightDockMode === "context" ? " workbench-dock__tab--active" : ""}`}
5281 onClick={() => openRightDockMode("context")}
5282 >
5283 <Activity size={13} />
5284 <span className="workbench-dock__tab-label">{t("rightDock.overview")}</span>
5285 </button>
5286 )}
5287 <button
5288 type="button"
5289 role="tab"
5290 aria-selected={rightDockMode === "files"}
5291 className={`workbench-dock__tab${rightDockMode === "files" ? " workbench-dock__tab--active" : ""}`}
5292 onClick={() => openRightDockMode("files")}
5293 >
5294 <FileText size={13} />
5295 <span className="workbench-dock__tab-label">{t("workspace.filesTab")}</span>
5296 </button>
5297 <button
5298 type="button"
5299 role="tab"
5300 aria-selected={rightDockMode === "changed"}
5301 className={`workbench-dock__tab${rightDockMode === "changed" ? " workbench-dock__tab--active" : ""}`}
5302 onClick={() => openRightDockMode("changed")}
5303 >
5304 <GitBranch size={13} />
5305 <span className="workbench-dock__tab-label">{t("workspace.changedTab")}</span>
5306 </button>
5307 {remoteHosts.length > 0 && (
5308 <button
5309 type="button"
5310 role="tab"
5311 aria-selected={rightDockMode === "remote"}
5312 className={`workbench-dock__tab${rightDockMode === "remote" ? " workbench-dock__tab--active" : ""}`}
5313 onClick={openRemoteDock}
5314 >
5315 <Server size={13} />
5316 <span className="workbench-dock__tab-label">{t("rightDock.remote")}</span>
5317 </button>
5318 )}
5319 </div>
5320 </div>
5321 <div className="workbench-dock__body">
5322 {rightDockMode === "remote" ? (
5323 <Suspense fallback={null}>
5324 <RemotePanel onClose={() => setWorkspacePanel(false)} />
5325 </Suspense>
5326 ) : rightDockMode === "context" && desktopLayoutStyle !== "creation" ? (
5327 <ContextPanel
5328 tabId={activeTabId}
5329 context={state.context}
5330 usage={state.usage}
5331 sessionTokens={state.sessionTokens}
5332 sessionCost={state.sessionCost}
5333 sessionCurrency={state.sessionCurrency}
5334 sessionTurns={sessionTurns}
5335 turnTokens={state.turnTotalTokens}
5336 turnCost={state.turnCost}
5337 balance={state.balance}
5338 sessionGen={state.sessionGen}
5339 refreshKey={dockRefreshKey + state.contextPanelSeq}
5340 usageSeq={state.usageSeq}
5341 />
5342 ) : (
5343 <Suspense fallback={null}>
5344 <WorkspacePanel
5345 open={workspacePanelRenderable}
5346 tabId={activeTabId}
5347 cwd={state.meta?.cwd}
5348 workspaceScopeKey={workspaceScopeKey}
5349 workspaceMemoryKey={workspaceTreeMemoryKey}
5350 workspaceMemoryVisitId={workspaceTreeMemoryVisitId}
5351 maximized={workspacePanelMaximized}
5352 panelWidth={workspacePanelRenderWidth}
5353 onClose={() => setWorkspacePanel(false)}
5354 onToggleMaximized={() => {
5355 closeTransientOverlays();
5356 setWorkspacePanelMaximized((value) => !value);
5357 }}
5358 onPreviewModeChange={handleWorkspacePreviewModeChange}
5359 onAddToChat={addWorkspaceTextToComposer}
5360 onAddCodeToChat={addWorkspaceCodeToComposer}
5361 onRequestPanelWidth={ensureWorkspacePanelWidth}
5362 onFileTreeRefresh={refreshComposerFileRefs}
5363 onSessionRevertCommitted={handleSessionRevertCommitted}
5364 onOpenInTerminal={openTerminalForPath}
5365 refreshKey={dockRefreshKey}
5366 initialViewMode={rightDockMode === "changed" ? "changed" : "files"}
5367 showViewTabs={false}
5368 creationMode={sidebarCreation}
5369 />
5370 </Suspense>
5371 )}
5372 </div>
5373 </aside>
5374 )}
5375 <>
5376 <aside
5377 className="terminal-drawer"
5378 aria-label={t("terminal.title")}
5379 >
5380 {terminalContentVisible && (
5381 <Suspense fallback={<div className="terminal-empty"><span className="terminal-empty__spinner" />{t("terminal.loading")}</div>}>
5382 <TerminalPanel
5383 tabId={activeTabId ?? ""}
5384 cwd={state.meta?.cwd}
5385 readOnly={Boolean(activeTab?.readOnly)}
5386 onClose={() => {
5387 setTerminalPanelOpen(false);
5388 saveTerminalPanelOpen(false);
5389 }}
5390 onAddOutput={(sessionId) => void addTerminalOutputToComposer(sessionId)}
5391 />
5392 </Suspense>
5393 )}
5394 </aside>
5395 <button
5396 className="terminal-drawer-resizer"
5397 type="button"
5398 role="separator"
5399 aria-orientation="horizontal"
5400 aria-label={t("terminal.resize")}
5401 aria-valuemin={TERMINAL_MIN_HEIGHT}
5402 aria-valuemax={terminalResizeMaxHeight}
5403 aria-valuenow={liveTerminalHeight ?? terminalRenderHeight}
5404 aria-hidden={!terminalPanelOpen}
5405 tabIndex={terminalPanelOpen ? 0 : -1}
5406 onPointerDown={startTerminalResize}
5407 onKeyDown={resizeTerminalWithKeyboard}
5408 onDoubleClick={() => {
5409 setSavedTerminalHeight(TERMINAL_DEFAULT_HEIGHT);
5410 }}
5411 />
5412 </>
5413
5414 {!sidebarImDetailConnection && (
5415 <StatusBar
5416 context={state.context}
5417 usage={state.usage}
5418 balance={state.balance}
5419 running={state.running || rewindCommitting}
5420 jobs={state.jobs}
5421 onCancelJob={cancelJob}
5422 backgroundRuntimes={backgroundRuntimes}
5423 onCancelRuntimeJob={cancelRuntimeJob}
5424 onRevealRuntime={revealBackgroundRuntime}
5425 sessionTurns={sessionTurns}
5426 sessionTokens={state.sessionTokens}
5427 turnTokens={state.turnTotalTokens}
5428 turnCost={state.turnCost}
5429 cost={state.sessionCost}
5430 currency={state.sessionCurrency}
5431 modelLabel={state.meta?.label}
5432 labelStyle={statusBarStyle}
5433 items={statusBarItems}
5434 extensionStatuses={extensionStatusList}
5435 workspacePath={state.meta?.workspacePath || state.meta?.workspaceRoot || state.meta?.cwd}
5436 workspaceName={state.meta?.workspaceName}
5437 gitBranch={state.meta?.gitBranch}
5438 onConnectRemote={connectAndOpenRemoteWorkspace}
5439 onDisconnectRemote={(hostId) => void app.DisconnectRemoteHost(hostId).catch(() => {})}
5440 onManageRemote={() => setSettingsTarget("remote")}
5441 onOpenRemote={requestRemoteExplorer}
5442 onOpenRemoteWorkspace={openRemoteWorkspaceFromStatus}
5443 remoteHosts={remoteHosts}
5444 remoteStatuses={remoteStatuses}
5445 />
5446 )}
5447 </div>
5448
5449 {histView !== null && (
5450 <Suspense fallback={null}>
5451 <HistoryPanel
5452 kind={histView.kind}
5453 sessions={histView.sessions}
5454 running={state.running}
5455 onResume={onResumeSession}
5456 onPreview={previewSession}
5457 onDelete={onDeleteSession}
5458 onRename={onRenameSession}
5459 onRestore={onRestoreTrashedSession}
5460 onPurge={onPurgeTrashedSession}
5461 onPurgeAll={onPurgeAllTrashedSessions}
5462 onPurgeRecoveryCopies={onPurgeRecoveryCopies}
5463 onDeleteMany={onDeleteManySessions}
5464 onClose={closeHistory}
5465 />
5466 </Suspense>
5467 )}
5468
5469 {settingsTarget !== null && (
5470 <Suspense fallback={null}>
5471 <SettingsPanel
5472 initialTab={settingsTarget}
5473 initialFocus={settingsFocus ?? undefined}
5474 agentRunning={state.running}
5475 desktopPlatform={desktopPlatform}
5476 onUseSubagent={prefillSubagentCommand}
5477 onClose={() => {
5478 setSettingsFocus(null);
5479 setSettingsTarget(null);
5480 }}
5481 onChanged={(settings) => {
5482 void refreshMeta();
5483 void refreshProviderSetupState().catch(() => {});
5484 if (settings) {
5485 applyDesktopPreferences(settings);
5486 void refreshSidebarImConnectionsFromSettings(settings).catch((e) => console.warn("bot sidebar refresh failed", e));
5487 return;
5488 }
5489 void reloadSidebarImConnections().catch((e) => console.warn("bot sidebar refresh failed", e));
5490 void app.DesktopStartupSettings()
5491 .then(applyDesktopPreferences)
5492 .catch((e) => console.warn("desktop preferences refresh failed", e));
5493 }}
5494 />
5495 </Suspense>
5496 )}
5497
5498 <RemoteHostKeyDialog />
5499 <RemoteSecretDialog />
5500
5501 <CommandPalette
5502 open={paletteOpen}
5503 onClose={() => setPaletteOpen(false)}
5504 items={paletteItems}
5505 placeholder={t("palette.placeholder")}
5506 emptyText={t("palette.empty")}
5507 />
5508
5509 <ShortcutsCheatsheet
5510 open={shortcutsOpen}
5511 platform={desktopPlatform}
5512 onClose={() => setShortcutsOpen(false)}
5513 t={t}
5514 />
5515
5516 {startupSplashVisible && (
5517 <StartupSplash hold={startupSplashHold} onDone={() => setStartupSplashVisible(false)} />
5518 )}
5519
5520 {needsOnboarding && (
5521 <OnboardingOverlay
5522 onComplete={() => {
5523 setProviderSetupNeeded(false);
5524 setNeedsOnboarding(false);
5525 }}
5526 onChooseProvider={() => {
5527 setNeedsOnboarding(false);
5528 setSettingsFocus({ target: "model-access" });
5529 setSettingsTarget("models");
5530 }}
5531 onSkip={() => {
5532 dismissOnboarding();
5533 setNeedsOnboarding(false);
5534 }}
5535 />
5536 )}
5537
5538 <HeartbeatPanel open={heartbeatOpen} onClose={() => setHeartbeatOpen(false)} onOpenTopic={(scope, workspaceRoot, topicId) => {
5539 void handleOpenTopic(scope, workspaceRoot, topicId);
5540 }} />
5541 <TranscriptSelectionMenu
5542 enabled={Boolean(activeTabId && !activeTab?.readOnly && !decisionSurface && !sidebarImDetailConnection && !hydratePlaceholderActive)}
5543 resetKey={activeTabId ?? ""}
5544 onAddToChat={addSelectedTextToComposer}
5545 />
5546 {windowsFramelessChrome && (
5547 <WindowsWindowControls
5548 maximised={mainWindowMaximised}
5549 syncMaximised={syncMainWindowMaximised}
5550 />
5551 )}
5552 </div>
5553 </UpdaterProvider>
5554 </ShellExpandProvider>
5555 );
5556 }
5557
5557 lines Plain Text