| 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 |