@xgspz/cli-ui 0.0.0-stage → 0.6.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (169) hide show
  1. package/CHANGELOG.md +156 -0
  2. package/README.md +512 -2
  3. package/dist/cli-ui-embed-D-3DvyTN.js +50279 -0
  4. package/dist/cli-ui-index-C34qFb4u.js +11209 -0
  5. package/dist/cli-ui-jszip.min-BObcE2nn.js +26 -0
  6. package/dist/cli-ui-jszip.min-DojbZPiE.js +2324 -0
  7. package/dist/cli-ui-xlsx-JVLtx0qR.js +93 -0
  8. package/dist/cli-ui.css +32 -0
  9. package/dist/cli-ui.js +17 -0
  10. package/package.json +60 -4
  11. package/src/board/BrowserPane.tsx +969 -0
  12. package/src/board/ChangesPane.tsx +185 -0
  13. package/src/board/CliBoard.tsx +484 -0
  14. package/src/board/FileViewer.tsx +721 -0
  15. package/src/board/FilesPane.tsx +262 -0
  16. package/src/board/HtmlReadingPreview.tsx +52 -0
  17. package/src/board/MarkdownPreview.tsx +134 -0
  18. package/src/board/PanelControls.tsx +40 -0
  19. package/src/board/blocks.tsx +97 -0
  20. package/src/board/context.ts +65 -0
  21. package/src/board/diffLines.ts +46 -0
  22. package/src/board/fileKind.ts +250 -0
  23. package/src/board/fileOps.ts +85 -0
  24. package/src/board/fileScope.ts +32 -0
  25. package/src/board/fileSync.ts +351 -0
  26. package/src/board/fileSyncCache.ts +250 -0
  27. package/src/board/fileSyncProcessor.ts +72 -0
  28. package/src/board/fileSyncQueue.ts +56 -0
  29. package/src/board/fileSyncTransport.ts +85 -0
  30. package/src/board/fileWatch.ts +211 -0
  31. package/src/board/folderDownload.ts +211 -0
  32. package/src/board/frameQuote.ts +52 -0
  33. package/src/board/htmlReading.ts +148 -0
  34. package/src/board/panels.ts +33 -0
  35. package/src/board/textLoad.ts +76 -0
  36. package/src/board/useBoardUpload.tsx +51 -0
  37. package/src/board/useBoardVisibility.ts +35 -0
  38. package/src/board/workspace.ts +200 -0
  39. package/src/board/zip.ts +85 -0
  40. package/src/chat/CliChat.tsx +932 -0
  41. package/src/chat/Composer.tsx +239 -0
  42. package/src/chat/GroupChat.tsx +113 -0
  43. package/src/chat/RunControls.tsx +196 -0
  44. package/src/chat/configuration.ts +15 -0
  45. package/src/chat/draft.ts +49 -0
  46. package/src/chat/export.ts +34 -0
  47. package/src/chat/queueEdit.ts +27 -0
  48. package/src/chat/useExecutionConfig.ts +93 -0
  49. package/src/components.tsx +13 -0
  50. package/src/core/blocks.ts +205 -0
  51. package/src/core/callbacks.ts +8 -0
  52. package/src/core/client.ts +822 -0
  53. package/src/core/config.ts +252 -0
  54. package/src/core/conversation.ts +577 -0
  55. package/src/core/documentUpload.ts +196 -0
  56. package/src/core/execution.ts +24 -0
  57. package/src/core/id.ts +15 -0
  58. package/src/core/identity.ts +21 -0
  59. package/src/core/provider.tsx +141 -0
  60. package/src/core/sha256.ts +21 -0
  61. package/src/core/sse.ts +51 -0
  62. package/src/core/store.ts +1103 -0
  63. package/src/core/subagents.ts +78 -0
  64. package/src/core/textDecode.ts +24 -0
  65. package/src/core/types.ts +505 -0
  66. package/src/core/xlsx.ts +142 -0
  67. package/src/elements.tsx +376 -0
  68. package/src/embed.ts +56 -0
  69. package/src/index.ts +56 -0
  70. package/src/layout/CliSplit.tsx +53 -0
  71. package/src/layout/split.ts +251 -0
  72. package/src/msglist/CliMsgList.tsx +422 -0
  73. package/src/operator/branchFlow.ts +35 -0
  74. package/src/operator/index.ts +9 -0
  75. package/src/operator/liveStream.ts +1 -0
  76. package/src/operator/node/AgentConnect.tsx +619 -0
  77. package/src/operator/node/AppearancePanel.tsx +35 -0
  78. package/src/operator/node/BranchHistory.tsx +67 -0
  79. package/src/operator/node/BranchNavigator.tsx +36 -0
  80. package/src/operator/node/BrowserPane.tsx +128 -0
  81. package/src/operator/node/BrowserPopup.tsx +22 -0
  82. package/src/operator/node/Chat.tsx +538 -0
  83. package/src/operator/node/CommandPalette.tsx +213 -0
  84. package/src/operator/node/Composer.tsx +316 -0
  85. package/src/operator/node/InsightsView.tsx +831 -0
  86. package/src/operator/node/Layout.tsx +192 -0
  87. package/src/operator/node/Markdown.tsx +61 -0
  88. package/src/operator/node/Modal.tsx +179 -0
  89. package/src/operator/node/NeedsYouMenu.tsx +54 -0
  90. package/src/operator/node/OnboardingWizard.tsx +248 -0
  91. package/src/operator/node/Orchestrator.tsx +644 -0
  92. package/src/operator/node/ProjectLanding.tsx +86 -0
  93. package/src/operator/node/ProjectsColumn.tsx +137 -0
  94. package/src/operator/node/ProviderAuth.tsx +559 -0
  95. package/src/operator/node/RunControls.tsx +2 -0
  96. package/src/operator/node/RunSwitch.tsx +113 -0
  97. package/src/operator/node/Services.tsx +245 -0
  98. package/src/operator/node/SessionRail.tsx +299 -0
  99. package/src/operator/node/SessionView.tsx +292 -0
  100. package/src/operator/node/SettingsView.tsx +690 -0
  101. package/src/operator/node/TaskBoard.tsx +352 -0
  102. package/src/operator/node/TaskChanges.tsx +526 -0
  103. package/src/operator/node/TaskHero.tsx +50 -0
  104. package/src/operator/node/TasksColumn.tsx +200 -0
  105. package/src/operator/node/Terminal.tsx +143 -0
  106. package/src/operator/node/Transcript.tsx +485 -0
  107. package/src/operator/node/ViewFile.tsx +79 -0
  108. package/src/operator/node/Welcome.tsx +101 -0
  109. package/src/operator/node/WorktreePrune.tsx +333 -0
  110. package/src/operator/node/agents.ts +58 -0
  111. package/src/operator/node/api.ts +57 -0
  112. package/src/operator/node/boardBackend.ts +63 -0
  113. package/src/operator/node/boardEvents.ts +40 -0
  114. package/src/operator/node/boardInteraction.ts +15 -0
  115. package/src/operator/node/boardState.ts +74 -0
  116. package/src/operator/node/boardWorkspace.ts +15 -0
  117. package/src/operator/node/boundedPoll.ts +47 -0
  118. package/src/operator/node/branchFlow.ts +1 -0
  119. package/src/operator/node/composerDraft.ts +78 -0
  120. package/src/operator/node/fileLinks.ts +81 -0
  121. package/src/operator/node/format.ts +292 -0
  122. package/src/operator/node/github.tsx +232 -0
  123. package/src/operator/node/icons.tsx +88 -0
  124. package/src/operator/node/liveStream.ts +323 -0
  125. package/src/operator/node/modals.tsx +766 -0
  126. package/src/operator/node/navHistory.ts +70 -0
  127. package/src/operator/node/persist.ts +26 -0
  128. package/src/operator/node/protocol/agents/optIn.ts +9 -0
  129. package/src/operator/node/protocol/agents/types.ts +394 -0
  130. package/src/operator/node/protocol/approvalFailure.ts +77 -0
  131. package/src/operator/node/protocol/authFailure.ts +66 -0
  132. package/src/operator/node/protocol/customModels.ts +23 -0
  133. package/src/operator/node/protocol/events.ts +13 -0
  134. package/src/operator/node/protocol/features.ts +70 -0
  135. package/src/operator/node/protocol/internalUsage.ts +1 -0
  136. package/src/operator/node/protocol/promptLimits.ts +52 -0
  137. package/src/operator/node/protocol/storageRetention.ts +13 -0
  138. package/src/operator/node/protocol/types.ts +335 -0
  139. package/src/operator/node/protocol/usageLimit.ts +72 -0
  140. package/src/operator/node/shared.tsx +89 -0
  141. package/src/operator/node/transcriptModel.ts +129 -0
  142. package/src/operator/node/types.ts +305 -0
  143. package/src/operator/node/useGlobalEvents.ts +97 -0
  144. package/src/operator/node/useOrchestrator.ts +697 -0
  145. package/src/operator/node/usePrefs.ts +114 -0
  146. package/src/operator/node/useRecaps.ts +102 -0
  147. package/src/operator/node/useTaskStream.ts +286 -0
  148. package/src/operator.base.css +1949 -0
  149. package/src/operator.css +1949 -0
  150. package/src/styles.css +579 -0
  151. package/src/ui/ErrorBoundary.tsx +38 -0
  152. package/src/ui/Markdown.tsx +60 -0
  153. package/src/ui/Popover.tsx +95 -0
  154. package/src/ui/QueuedMessages.tsx +110 -0
  155. package/src/ui/ReferenceCard.tsx +28 -0
  156. package/src/ui/SubagentChat.tsx +233 -0
  157. package/src/ui/TextBlock.tsx +51 -0
  158. package/src/ui/Transcript.tsx +435 -0
  159. package/src/ui/UploadProgress.tsx +46 -0
  160. package/src/ui/WorkspaceDrawer.tsx +64 -0
  161. package/src/ui/WorkspaceTabs.tsx +60 -0
  162. package/src/ui/fileLinks.ts +52 -0
  163. package/src/ui/format.ts +103 -0
  164. package/src/ui/icons.tsx +92 -0
  165. package/src/ui/reference.ts +40 -0
  166. package/src/ui/shared.tsx +38 -0
  167. package/src/ui/transcriptModel.ts +151 -0
  168. package/src/ui/useChatScrollbar.ts +29 -0
  169. package/src/ui/useComposerAutosize.ts +54 -0
@@ -0,0 +1,292 @@
1
+ "use client";
2
+
3
+ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
4
+ import type { Status, Priority, AskQuestion, AskAnswers } from "./protocol/types";
5
+ import { OperatorBoardContext, OperatorBoardQuoteContext, type OperatorBoardOptions, type OperatorBoardRequest, type OperatorBoardTarget } from "./boardInteraction";
6
+ import { readDraft, writeDraft, type Quote } from "./composerDraft";
7
+ import { clientFeatures } from "./protocol/features";
8
+ import { Icon } from "./icons";
9
+ import { type ResolveResult } from "./TaskChanges";
10
+ import { fmtTokens, fmtCost, fmtJobCost, isAwaiting, buildSessions, usageSplit, costDisplay } from "./format";
11
+ import {
12
+ SLABEL, SSUB, AWAIT_LABEL, STATUSES, PLABEL, PRIORITIES,
13
+ RAIL_W,
14
+ type ProjectRow, type TaskRow, type Msg, type SyncStatusResp, type AgentsBundle, type InternalUsageEstimate,
15
+ } from "./types";
16
+ import { capsFor, agentLabel, findAgent } from "./agents";
17
+ import { StatusDot, Popover } from "./shared";
18
+ import { Chat } from "./Chat";
19
+ import { TaskHero } from "./TaskHero";
20
+ import { RunControls } from "./RunControls";
21
+ import { CliBoard } from "../../components";
22
+ import type { ComponentProps } from "react";
23
+ import { useOperatorContextEvents, useOperatorFileWatch } from "./boardEvents";
24
+ import { readBoardState, publishBoardState } from "./boardState";
25
+ import { registerOperatorBoard } from "./boardInteraction";
26
+ import { useConfigurationBusy } from "../../chat/configuration";
27
+ import type { SessionRail as NodeSessionRail } from "./SessionRail";
28
+ const SessionRail=(props:ComponentProps<typeof NodeSessionRail>)=><CliBoard operator={props}/>;
29
+ import { ColResize } from "./Layout";
30
+ import { WorkspaceTabs } from "../../ui/WorkspaceTabs";
31
+ import { WorkspaceDrawer, useWorkspaceDrawer } from "../../ui/WorkspaceDrawer";
32
+ import { jget, downloadExport } from "./api";
33
+ import { resolveChatConfig, type CliChatConfig } from "../../core/config";
34
+
35
+ // Non-blocking banner shown when a reopened task's worktree is behind its base
36
+ // branch. Computed (read-only) on open; the actual git op fires only when the user
37
+ // clicks. Fast-forward-able tasks show nothing here — they catch up silently on the
38
+ // next message — so the banner only appears for tier 2 (clean merge → Sync) and
39
+ // tier 3 (conflicts → Fix with AI).
40
+ function SyncBanner({ taskId, running, pinned, onResolveWithAI, onSwitchToChat }: {
41
+ taskId: string; running: boolean; pinned: boolean;
42
+ onResolveWithAI: (taskId: string) => Promise<ResolveResult>;
43
+ onSwitchToChat: () => void;
44
+ }) {
45
+ const [st, setSt] = useState<SyncStatusResp | null>(null);
46
+ const [busy, setBusy] = useState(false);
47
+
48
+ const load = useCallback(async () => {
49
+ try { const r = await fetch(`/api/tasks/${taskId}/sync`, { cache: "no-store" }); setSt(await r.json()); }
50
+ catch { setSt(null); }
51
+ }, [taskId]);
52
+
53
+ // Recompute on open and whenever a turn finishes (a turn may have fast-forwarded
54
+ // or otherwise moved the branch). Skip while running to avoid mid-merge reads.
55
+ useEffect(() => { if (!running) load(); }, [running, load]);
56
+
57
+ if (!st || !st.isolated || !st.behind) return null;
58
+ if (st.canFastForward && !pinned) return null; // only ordinary tasks auto-follow main
59
+
60
+ const conflicts = st.conflicts?.length ?? 0;
61
+
62
+ const doSync = async () => {
63
+ setBusy(true);
64
+ try {
65
+ const r = await fetch(`/api/tasks/${taskId}/sync`, { method: "POST" });
66
+ const res = await r.json();
67
+ // Prediction said clean but the real merge conflicted — escalate to Fix with AI.
68
+ if (res?.conflicts?.length) { const fix = await onResolveWithAI(taskId); if (fix.ok && !fix.merged) onSwitchToChat(); }
69
+ } finally { setBusy(false); load(); }
70
+ };
71
+
72
+ const doFix = async () => {
73
+ setBusy(true);
74
+ try { const res = await onResolveWithAI(taskId); if (res.ok && !res.merged) onSwitchToChat(); }
75
+ finally { setBusy(false); load(); }
76
+ };
77
+
78
+ return (
79
+ <div className={`sync-banner${conflicts ? " conflict" : ""}`}>
80
+ <span className="sync-msg">
81
+ {conflicts > 0
82
+ ? `落后 ${st.baseBranch} ${st.behind} 个提交 · ${conflicts} 个文件存在冲突`
83
+ : `落后 ${st.baseBranch} ${st.behind} 个提交`}
84
+ </span>
85
+ <span className="sync-spacer" />
86
+ {conflicts > 0 ? (
87
+ <button className="tc-btn primary" onClick={doFix} disabled={busy || running}>{busy ? "…" : "用 AI 解决"}</button>
88
+ ) : (
89
+ <button className="tc-btn primary" onClick={doSync} disabled={busy || running}>{busy ? "同步中…" : "同步"}</button>
90
+ )}
91
+ </div>
92
+ );
93
+ }
94
+
95
+ export function SessionView(props: Parameters<typeof SessionViewInner>[0]) { return <SessionViewInner key={props.task.id} {...props}/>; }
96
+ function SessionViewInner({ project, task, tasks, onSelectTask, onFork, agents, messages, running, blockedBy, transcriptLoading, older=0, onLoadOlder, onSend, onStart, onStop, onClear, clearConfirming, onConfirmClear, onCancelClear, onEdit, onReconnect, onSetStatus, onSetPriority, onSetModel, onSetAgent, onSetReasoning, onSetPermission, onSetSendContext, onResolveWithAI, onMerged, onPrCreated, onAnswer, onCancelQueued, onBack, mobile, railW, onRailWidth, onRailReset, railCollapsed, onRailCollapse, onRailExpand, board, composer }: {
97
+ board?: OperatorBoardOptions;
98
+ composer?: CliChatConfig["composer"];
99
+ tasks: TaskRow[]; onSelectTask: (id: string) => void; onFork: (text: string, sourceIds: string[], requestId: string) => Promise<boolean>;
100
+ project: ProjectRow; task: TaskRow; agents: AgentsBundle; messages: Msg[]; running: boolean; blockedBy?: string[]; transcriptLoading?: boolean;
101
+ older?: number; onLoadOlder?: (beforeApply?: () => void) => Promise<boolean>;
102
+ onSend: (t: string) => Promise<boolean>; onStart: () => void; onStop: () => Promise<boolean>; onClear: () => void; onEdit: () => void;
103
+ clearConfirming?: boolean; onConfirmClear?: () => void; onCancelClear?: () => void;
104
+ // Deep-link to Settings → Agents, for the transcript's "your login died" recovery button.
105
+ onReconnect?: () => void;
106
+ onSetStatus: (s: Status) => void; onSetPriority: (p: Priority) => void; onSetModel: (m: string | null) => Promise<void>;
107
+ onSetAgent: (id:string)=>Promise<void>;
108
+ onSetReasoning: (r: string | null) => Promise<void>; onSetPermission: (p: string | null) => Promise<void>;
109
+ onSetSendContext: (v: boolean) => void;
110
+ onResolveWithAI: (taskId: string) => Promise<ResolveResult>;
111
+ onMerged?: () => void;
112
+ onPrCreated?: (url: string) => void;
113
+ onAnswer: (askId: string, questions: AskQuestion[], answers: AskAnswers) => void;
114
+ /** 撤回排队消息。返回 false = 它已经开始执行(没撤回),排队消息的「编辑」据此决定是否放回输入框。 */
115
+ onCancelQueued: (pendingId: string) => void | boolean | Promise<void | boolean>;
116
+ onBack?: () => void; mobile?: boolean;
117
+ railW: number; onRailWidth: (w: number) => void; onRailReset: () => void;
118
+ railCollapsed: boolean; onRailCollapse: () => void; onRailExpand: () => void;
119
+ }) {
120
+ const sessionRef = useRef<HTMLDivElement>(null);
121
+ useOperatorFileWatch(task.id,board?.watchFiles!==false,board,undefined,board?.fileWatchIntervalMs);
122
+ useOperatorContextEvents(task.id,true,board);
123
+ const previousAuto=useRef(board?.autoContext);
124
+ useLayoutEffect(()=>{
125
+ if(board?.autoContext===undefined&&previousAuto.current===undefined)return;
126
+ previousAuto.current=board?.autoContext;const view=readBoardState(task.id),enabled=board?.autoContext!==false;
127
+ if(view&&view.autoContext!==enabled)publishBoardState(task.id,{...view,autoContext:enabled});
128
+ },[task.id,board?.autoContext]);
129
+
130
+ const [compact, setCompact] = useState(false);
131
+ const configBusy = useConfigurationBusy(task.id);
132
+ useLayoutEffect(() => {
133
+ const el = sessionRef.current;
134
+ if (!el) return;
135
+ setCompact(el.getBoundingClientRect().width < Math.max(760, railW + 400));
136
+ const observer = new ResizeObserver(([entry]) => setCompact(entry.contentRect.width < Math.max(760, railW + 400)));
137
+ observer.observe(el);
138
+ return () => observer.disconnect();
139
+ }, [railW]);
140
+ const singlePane = mobile || compact;
141
+ const drawerMode = useWorkspaceDrawer(sessionRef, !!mobile);
142
+ const tabMode = !drawerMode && (singlePane || railCollapsed);
143
+ const [tabsHost, setTabsHost] = useState<HTMLDivElement|null>(null);
144
+ const [statusOpen, setStatusOpen] = useState(false);
145
+ const [moreOpen, setMoreOpen] = useState(false);
146
+ const [view, setView] = useState<"chat" | "changes">("chat");
147
+ const [boardRequest,setBoardRequest]=useState<OperatorBoardRequest|null>(null);
148
+ const boardSequence=useRef(0);
149
+ const acknowledgeBoardRequest=useCallback((sequence:number)=>setBoardRequest(previous=>previous?.sequence===sequence?null:previous),[]);
150
+ const routing=useRef({tabMode,drawerMode,task,tasks,board});
151
+ routing.current={tabMode,drawerMode,task,tasks,board};
152
+ const openBoard=useCallback((target:OperatorBoardTarget)=>{
153
+ const context=routing.current;
154
+ if(target.kind==='browser'&&(target.taskId!==context.task.id||!clientFeatures().browser))return false;
155
+ if(target.kind==='file'&&(!target.path||![context.task,...context.tasks].some(t=>t.id===target.taskId&&t.project_id===context.task.project_id)))return false;
156
+ if(context.board?.availableTabs&&!context.board.availableTabs.includes(target.kind==='file'?'files':'browser'))return false;
157
+ setBoardRequest({sequence:++boardSequence.current,target});
158
+ if(context.tabMode||context.drawerMode)setView('changes');
159
+ return true;
160
+ },[]);
161
+ useEffect(()=>registerOperatorBoard(target=>target.taskId===task.id?openBoard(target):false),[task.id,openBoard]);
162
+ useEffect(()=>{
163
+ const show=(event:Event)=>{if((event as CustomEvent<{taskId?:string}>).detail?.taskId===task.id)openBoard({kind:'browser',taskId:task.id});};
164
+ window.addEventListener('orch:show-browser',show);return()=>window.removeEventListener('orch:show-browser',show);
165
+ },[task.id,openBoard]);
166
+ const quoteFromBoard=useCallback((quote:Quote)=>{
167
+ const context=routing.current;
168
+ writeDraft(context.task.id,{...readDraft(context.task.id),quote});
169
+ if(context.tabMode||context.drawerMode)setView('chat');
170
+ requestAnimationFrame(()=>sessionRef.current?.querySelector<HTMLTextAreaElement>('.composer textarea')?.focus({preventScroll:true}));
171
+ },[]);
172
+ const [clearEstimate, setClearEstimate] = useState<InternalUsageEstimate | null>(null);
173
+ const sessions = useMemo(() => buildSessions(messages), [messages]);
174
+ const hasSession = task.started === 1 || messages.length > 0 || !!task.parent_task_id;
175
+ const awaiting = isAwaiting(task);
176
+ useEffect(() => {
177
+ if (!clearConfirming) { setClearEstimate(null); return; }
178
+ let alive = true;
179
+ jget<{ estimate: InternalUsageEstimate | null }>(`/api/tasks/${task.id}/clear`)
180
+ .then((r) => { if (alive) setClearEstimate(r.estimate); })
181
+ .catch(() => { if (alive) setClearEstimate(null); });
182
+ return () => { alive = false; };
183
+ }, [clearConfirming, task.id]);
184
+ // Run-control pickers + feature gates come from this task's agent capabilities,
185
+ // never a hardcoded list — so the options always match the agent it runs under.
186
+ const caps = capsFor(agents, task.agent);
187
+ const composerConfig = resolveChatConfig({ composer }).composer;
188
+ const controls = { task, caps, agents:agents.agents.map(a=>({id:a.id,label:a.label,disabled:!a.authenticated||a.installed===false})), onSetAgent, defaults: findAgent(agents, task.agent)?.defaults, disabled: running, onSetModel, onSetReasoning, onSetPermission, showAgent: composerConfig.agentSelector, showPermission: composerConfig.executionMode };
189
+ // Usage chip: tokens split into fresh work vs re-read cache (the raw total is
190
+ // mostly cache reads and wildly overstates what ran), and a dollar figure whose
191
+ // presentation follows how this agent is signed in — a subscription login's
192
+ // figure is an API-price equivalent covered by plan quota, not a bill. Both
193
+ // derivations live in ./format so the wording has one home.
194
+ const usage = usageSplit(task);
195
+ const cost = costDisplay(findAgent(agents, task.agent));
196
+ // PR number for the header chip, parsed from the stored URL (…/pull/42).
197
+ const prNum = task.pr_url?.match(/\/pull\/(\d+)/)?.[1];
198
+ return (
199
+ <OperatorBoardContext.Provider value={openBoard}><OperatorBoardQuoteContext.Provider value={quoteFromBoard}>
200
+ <div className={`session${singlePane ? " compact-session" : ""}`} ref={sessionRef}>
201
+ <div className="sess-head">
202
+ {onBack && <button className="mobile-back" onClick={onBack} title="返回任务列表" aria-label="返回任务列表">{Icon.chevRight({ style: { transform: "rotate(180deg)" } })}</button>}
203
+ {hasSession&&tabMode ? <WorkspaceTabs title={task.title} subtitle={project.name} chatActive={view==='chat'} onChat={()=>setView('chat')} onTabsHost={setTabsHost}/> : <div className="sh-main">
204
+ <div className="crumb">
205
+ <span className="pic" style={{ width: 16, height: 16, borderRadius: 5, background: project.color, display: "grid", placeItems: "center", color: "#fff", fontSize: 9, fontWeight: 700 }}>{project.name[0]}</span>
206
+ <span className="session-project-name" title={project.name}>{project.name}</span>
207
+
208
+ </div>
209
+ <div className="sh-title">
210
+ <span className="sht-text" title={task.title}>{task.title}</span>
211
+ <button className="icon-btn sh-edit" title="查看并编辑任务详情" aria-label="查看并编辑任务详情" onClick={onEdit}>{Icon.edit()}</button>
212
+ </div>
213
+ </div>}
214
+ <div className="sh-tools">
215
+ {hasSession&&drawerMode&&<button className="icon-btn workspace-open" title="打开工作区" aria-label="打开工作区" aria-haspopup="dialog" aria-expanded={view==='changes'} onClick={()=>setView('changes')}>{Icon.folder()}</button>}
216
+ <div style={{ position: "relative" }}>
217
+ <button className={`status-ctl ${awaiting ? "awaiting" : ""}`} onClick={(e) => { e.stopPropagation(); setStatusOpen((o) => !o); setMoreOpen(false); }}>
218
+ <StatusDot status={task.status} running={running} awaiting={awaiting} />
219
+ <span className="cv">{awaiting ? AWAIT_LABEL : SLABEL[task.status]}</span>
220
+ {Icon.chevDown()}
221
+ </button>
222
+ {statusOpen && (
223
+ <Popover onClose={() => setStatusOpen(false)}>
224
+ {STATUSES.map((s) => (
225
+ <button key={s} className="pop-item" onClick={() => { onSetStatus(s); setStatusOpen(false); }}>
226
+ <StatusDot status={s} />
227
+ <div><div>{SLABEL[s]}</div><div className="pi-sub">{SSUB[s]}</div></div>
228
+ {task.status === s && <span className="pi-check">{Icon.check()}</span>}
229
+ </button>
230
+ ))}
231
+ </Popover>
232
+ )}
233
+ </div>
234
+ <div style={{ position: "relative" }}>
235
+ <button className="icon-btn session-more" aria-label="更多任务操作" aria-expanded={moreOpen} onClick={(e) => { e.stopPropagation(); setMoreOpen(!moreOpen); setStatusOpen(false); }}>{Icon.dots()}</button>
236
+ {moreOpen && <Popover onClose={() => setMoreOpen(false)}>
237
+ <div className="pop-sec">{agentLabel(agents, task.agent)} · {fmtTokens(usage.fresh)} tokens{cost.show && task.cost_usd > 0 ? ` · ${cost.approx ? "~" : ""}${fmtCost(task.cost_usd)}` : ""}</div>
238
+ <div className="pop-note session-origin" title={task.work_branch || ""}>{Icon.git()}<span>{task.parent_task_id ? `来自 ${task.fork_source_title || "来源任务"}` : "独立任务分支"}</span></div>
239
+ <button className="pop-item" onClick={() => { setMoreOpen(false); onEdit(); }}>{Icon.edit()} 编辑任务详情</button>
240
+ <button className="pop-item" onClick={() => { setMoreOpen(false); downloadExport(`/api/tasks/${task.id}/export`); }}>{Icon.download()} 导出执行过程</button>
241
+ {task.pr_url && <a className="pop-item" href={task.pr_url} target="_blank" rel="noreferrer">{Icon.github()} 打开 PR{prNum ? ` #${prNum}` : ""}</a>}
242
+ <div className="divider" /><div className="pop-sec">优先级</div>
243
+ {PRIORITIES.map((p) => <button className="pop-item" key={p} aria-pressed={task.priority === p} onClick={() => { onSetPriority(p); setMoreOpen(false); }}><span className={`pri ${p}`}>{PLABEL[p]}</span>{task.priority === p && Icon.check()}</button>)}
244
+ <div className="divider" />
245
+ <button className="pop-item" onClick={() => { setMoreOpen(false); onClear(); }} disabled={running || task.started !== 1}>{Icon.clear()} 保存摘要,开启新上下文</button>
246
+ </Popover>}
247
+ </div>
248
+ </div>
249
+ </div>
250
+
251
+ {hasSession && (
252
+ <SyncBanner taskId={task.id} running={running} pinned={!!task.fork_commit} onResolveWithAI={onResolveWithAI} onSwitchToChat={() => setView("chat")} />
253
+ )}
254
+
255
+ {clearConfirming && (
256
+ <div className="clear-confirm">
257
+ <span>保存摘要并开启新的上下文?</span>
258
+ {clearEstimate && <span className="job-cost-hint">预计消耗 {fmtJobCost(clearEstimate)}</span>}
259
+ <span className="spacer" />
260
+ <button className="btn btn-ghost btn-sm" onClick={onCancelClear}>取消</button>
261
+ <button className="btn btn-accent btn-sm" onClick={onConfirmClear}>{Icon.clear()} 确认 /clear</button>
262
+ </div>
263
+ )}
264
+
265
+ {!hasSession ? (
266
+ <>{composerConfig.show && <div className="hero-controls"><RunControls {...controls} /></div>}<TaskHero task={task} project={project} onStart={onStart} onEdit={onEdit} onSetSendContext={onSetSendContext} running={running || configBusy} blockedBy={blockedBy} /></>
267
+ ) : (
268
+ // Keep the chat at the same React position across all widths. Moving
269
+ // it between different parents remounted the composer during resize
270
+ // and could drop a keystroke, an upload, or an in-flight setting save.
271
+ <div className={tabMode||drawerMode ? "sess-responsive" : "sess-split"} style={tabMode||drawerMode ? undefined : { gridTemplateColumns: `minmax(0,1fr) 0px ${railW}px` }}>
272
+ <div className={`sess-main${tabMode||drawerMode ? " compact-chat" : ""}`} data-chat-layout={tabMode||drawerMode?"centered":"wide"} hidden={tabMode && view !== "chat"}><Chat project={project} task={task} tasks={tasks} agents={agents} messages={messages}
273
+ running={running} blockedBy={blockedBy} transcriptLoading={transcriptLoading} older={older} onLoadOlder={onLoadOlder}
274
+ controls={controls} configBusy={configBusy} composer={composerConfig} active={!tabMode&&!drawerMode || view === "chat"}
275
+ onSelectTask={onSelectTask} onFork={onFork} onSend={onSend} onStop={onStop}
276
+ onStart={onStart} onEdit={onEdit} onClear={onClear} onSetSendContext={onSetSendContext}
277
+ onReconnect={onReconnect} onAnswer={onAnswer} onCancelQueued={onCancelQueued} /></div>
278
+ {!tabMode&&!drawerMode && <ColResize side="right" min={RAIL_W.min} max={RAIL_W.max} onWidth={onRailWidth} onReset={onRailReset} />}
279
+ <WorkspaceDrawer enabled={drawerMode} open={view==='changes'} onClose={()=>setView('chat')}>
280
+ <SessionRail {...board} openRequest={boardRequest} onRequestHandled={acknowledgeBoardRequest} project={project} task={task} sessions={sessions} running={running}
281
+ onResolveWithAI={onResolveWithAI} onMerged={onMerged} onPrCreated={onPrCreated} onClear={onClear}
282
+ inlineLayout={tabMode?{tabsHost,active:view==='changes',onActivate:()=>setView('changes'),onChat:()=>setView('chat')}:undefined}
283
+ visible={!drawerMode||view==='changes'}
284
+ onExpand={tabMode&&!singlePane?onRailExpand:undefined}
285
+ onCollapse={tabMode||drawerMode ? () => setView("chat") : onRailCollapse} onSwitchToChat={() => setView("chat")} />
286
+ </WorkspaceDrawer>
287
+ </div>
288
+ )}
289
+ </div>
290
+ </OperatorBoardQuoteContext.Provider></OperatorBoardContext.Provider>
291
+ );
292
+ }