@xgspz/cli-ui 0.0.0-stage → 0.6.15
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.
- package/CHANGELOG.md +165 -0
- package/README.md +517 -2
- package/dist/cli-ui-embed-vIXVNs6k.js +50295 -0
- package/dist/cli-ui-index-R24x28OX.js +11209 -0
- package/dist/cli-ui-jszip.min-DUghzByz.js +2324 -0
- package/dist/cli-ui-jszip.min-DfH6rdQD.js +26 -0
- package/dist/cli-ui-xlsx-CP0lJhff.js +93 -0
- package/dist/cli-ui.css +32 -0
- package/dist/cli-ui.js +17 -0
- package/package.json +60 -4
- package/src/board/BrowserPane.tsx +969 -0
- package/src/board/ChangesPane.tsx +185 -0
- package/src/board/CliBoard.tsx +484 -0
- package/src/board/FileViewer.tsx +721 -0
- package/src/board/FilesPane.tsx +262 -0
- package/src/board/HtmlReadingPreview.tsx +52 -0
- package/src/board/MarkdownPreview.tsx +134 -0
- package/src/board/PanelControls.tsx +40 -0
- package/src/board/blocks.tsx +97 -0
- package/src/board/context.ts +65 -0
- package/src/board/diffLines.ts +46 -0
- package/src/board/fileKind.ts +250 -0
- package/src/board/fileOps.ts +85 -0
- package/src/board/fileScope.ts +32 -0
- package/src/board/fileSync.ts +351 -0
- package/src/board/fileSyncCache.ts +250 -0
- package/src/board/fileSyncProcessor.ts +72 -0
- package/src/board/fileSyncQueue.ts +56 -0
- package/src/board/fileSyncTransport.ts +85 -0
- package/src/board/fileWatch.ts +211 -0
- package/src/board/folderDownload.ts +211 -0
- package/src/board/frameQuote.ts +52 -0
- package/src/board/htmlReading.ts +148 -0
- package/src/board/panels.ts +33 -0
- package/src/board/textLoad.ts +76 -0
- package/src/board/useBoardUpload.tsx +51 -0
- package/src/board/useBoardVisibility.ts +35 -0
- package/src/board/workspace.ts +200 -0
- package/src/board/zip.ts +85 -0
- package/src/chat/CliChat.tsx +960 -0
- package/src/chat/Composer.tsx +239 -0
- package/src/chat/GroupChat.tsx +113 -0
- package/src/chat/RunControls.tsx +196 -0
- package/src/chat/configuration.ts +15 -0
- package/src/chat/draft.ts +49 -0
- package/src/chat/export.ts +34 -0
- package/src/chat/queueEdit.ts +27 -0
- package/src/chat/useExecutionConfig.ts +93 -0
- package/src/components.tsx +13 -0
- package/src/core/blocks.ts +205 -0
- package/src/core/callbacks.ts +8 -0
- package/src/core/client.ts +822 -0
- package/src/core/config.ts +252 -0
- package/src/core/conversation.ts +581 -0
- package/src/core/documentUpload.ts +196 -0
- package/src/core/execution.ts +24 -0
- package/src/core/id.ts +15 -0
- package/src/core/identity.ts +21 -0
- package/src/core/provider.tsx +141 -0
- package/src/core/sha256.ts +21 -0
- package/src/core/sse.ts +51 -0
- package/src/core/store.ts +1109 -0
- package/src/core/subagents.ts +78 -0
- package/src/core/textDecode.ts +24 -0
- package/src/core/types.ts +507 -0
- package/src/core/xlsx.ts +142 -0
- package/src/elements.tsx +376 -0
- package/src/embed.ts +56 -0
- package/src/index.ts +56 -0
- package/src/layout/CliSplit.tsx +53 -0
- package/src/layout/split.ts +251 -0
- package/src/msglist/CliMsgList.tsx +422 -0
- package/src/operator/branchFlow.ts +35 -0
- package/src/operator/index.ts +9 -0
- package/src/operator/liveStream.ts +1 -0
- package/src/operator/node/AgentConnect.tsx +619 -0
- package/src/operator/node/AppearancePanel.tsx +35 -0
- package/src/operator/node/BranchHistory.tsx +67 -0
- package/src/operator/node/BranchNavigator.tsx +36 -0
- package/src/operator/node/BrowserPane.tsx +128 -0
- package/src/operator/node/BrowserPopup.tsx +22 -0
- package/src/operator/node/Chat.tsx +538 -0
- package/src/operator/node/CommandPalette.tsx +213 -0
- package/src/operator/node/Composer.tsx +316 -0
- package/src/operator/node/InsightsView.tsx +831 -0
- package/src/operator/node/Layout.tsx +192 -0
- package/src/operator/node/Markdown.tsx +61 -0
- package/src/operator/node/Modal.tsx +179 -0
- package/src/operator/node/NeedsYouMenu.tsx +54 -0
- package/src/operator/node/OnboardingWizard.tsx +248 -0
- package/src/operator/node/Orchestrator.tsx +644 -0
- package/src/operator/node/ProjectLanding.tsx +86 -0
- package/src/operator/node/ProjectsColumn.tsx +137 -0
- package/src/operator/node/ProviderAuth.tsx +559 -0
- package/src/operator/node/RunControls.tsx +2 -0
- package/src/operator/node/RunSwitch.tsx +113 -0
- package/src/operator/node/Services.tsx +245 -0
- package/src/operator/node/SessionRail.tsx +299 -0
- package/src/operator/node/SessionView.tsx +292 -0
- package/src/operator/node/SettingsView.tsx +690 -0
- package/src/operator/node/TaskBoard.tsx +352 -0
- package/src/operator/node/TaskChanges.tsx +526 -0
- package/src/operator/node/TaskHero.tsx +50 -0
- package/src/operator/node/TasksColumn.tsx +200 -0
- package/src/operator/node/Terminal.tsx +143 -0
- package/src/operator/node/Transcript.tsx +485 -0
- package/src/operator/node/ViewFile.tsx +79 -0
- package/src/operator/node/Welcome.tsx +101 -0
- package/src/operator/node/WorktreePrune.tsx +333 -0
- package/src/operator/node/agents.ts +58 -0
- package/src/operator/node/api.ts +57 -0
- package/src/operator/node/boardBackend.ts +63 -0
- package/src/operator/node/boardEvents.ts +40 -0
- package/src/operator/node/boardInteraction.ts +15 -0
- package/src/operator/node/boardState.ts +74 -0
- package/src/operator/node/boardWorkspace.ts +15 -0
- package/src/operator/node/boundedPoll.ts +47 -0
- package/src/operator/node/branchFlow.ts +1 -0
- package/src/operator/node/composerDraft.ts +78 -0
- package/src/operator/node/fileLinks.ts +81 -0
- package/src/operator/node/format.ts +292 -0
- package/src/operator/node/github.tsx +232 -0
- package/src/operator/node/icons.tsx +88 -0
- package/src/operator/node/liveStream.ts +323 -0
- package/src/operator/node/modals.tsx +766 -0
- package/src/operator/node/navHistory.ts +70 -0
- package/src/operator/node/persist.ts +26 -0
- package/src/operator/node/protocol/agents/optIn.ts +9 -0
- package/src/operator/node/protocol/agents/types.ts +394 -0
- package/src/operator/node/protocol/approvalFailure.ts +77 -0
- package/src/operator/node/protocol/authFailure.ts +66 -0
- package/src/operator/node/protocol/customModels.ts +23 -0
- package/src/operator/node/protocol/events.ts +13 -0
- package/src/operator/node/protocol/features.ts +70 -0
- package/src/operator/node/protocol/internalUsage.ts +1 -0
- package/src/operator/node/protocol/promptLimits.ts +52 -0
- package/src/operator/node/protocol/storageRetention.ts +13 -0
- package/src/operator/node/protocol/types.ts +335 -0
- package/src/operator/node/protocol/usageLimit.ts +72 -0
- package/src/operator/node/shared.tsx +89 -0
- package/src/operator/node/transcriptModel.ts +129 -0
- package/src/operator/node/types.ts +305 -0
- package/src/operator/node/useGlobalEvents.ts +97 -0
- package/src/operator/node/useOrchestrator.ts +697 -0
- package/src/operator/node/usePrefs.ts +114 -0
- package/src/operator/node/useRecaps.ts +102 -0
- package/src/operator/node/useTaskStream.ts +286 -0
- package/src/operator.base.css +1952 -0
- package/src/operator.css +1952 -0
- package/src/styles.css +579 -0
- package/src/ui/ErrorBoundary.tsx +38 -0
- package/src/ui/Markdown.tsx +60 -0
- package/src/ui/Popover.tsx +95 -0
- package/src/ui/QueuedMessages.tsx +110 -0
- package/src/ui/ReferenceCard.tsx +28 -0
- package/src/ui/SubagentChat.tsx +233 -0
- package/src/ui/TextBlock.tsx +51 -0
- package/src/ui/Transcript.tsx +435 -0
- package/src/ui/UploadProgress.tsx +46 -0
- package/src/ui/WorkspaceDrawer.tsx +64 -0
- package/src/ui/WorkspaceTabs.tsx +60 -0
- package/src/ui/fileLinks.ts +52 -0
- package/src/ui/format.ts +103 -0
- package/src/ui/icons.tsx +92 -0
- package/src/ui/reference.ts +40 -0
- package/src/ui/shared.tsx +38 -0
- package/src/ui/transcriptModel.ts +151 -0
- package/src/ui/useChatScrollbar.ts +29 -0
- package/src/ui/useComposerAutosize.ts +54 -0
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
// 转录里的一行。移植自 operator-oss/app/orchestrator/Transcript.tsx,类名、结构、文案保持一致;
|
|
2
|
+
// 去掉了对话任务用不到的部分(/clear、重新登录、浏览器交还……这些动作 center 不提供)。
|
|
3
|
+
import { subagentFields } from "../core/subagents";
|
|
4
|
+
import { SubagentEntry } from "./SubagentChat";
|
|
5
|
+
import { memo, useContext, useId, useState } from "react";
|
|
6
|
+
import { FileOpenContext, FileLinkContext } from "./fileLinks";
|
|
7
|
+
import type { AskAnswers, AskQuestion, InputRequest, Msg, ToolData, ToolPeek } from "../core/types";
|
|
8
|
+
import { splitMessage } from "../core/blocks";
|
|
9
|
+
import { Icon } from "./icons";
|
|
10
|
+
import { Markdown } from "./Markdown";
|
|
11
|
+
import { clockTime, diffCls, splitAttachments, type MsgAttachment } from "./format";
|
|
12
|
+
import { Avatar } from "./shared";
|
|
13
|
+
import { toolDataOf } from "./transcriptModel";
|
|
14
|
+
import { TextBlockCard, isLongMessage } from "./TextBlock";
|
|
15
|
+
import { ReferenceCard } from "./ReferenceCard";
|
|
16
|
+
import { splitQuotedMessage } from "./reference";
|
|
17
|
+
|
|
18
|
+
// Operator 写进转录的几类系统提示(取开头一段做匹配):按「上下文超限」那种醒目样式显示。
|
|
19
|
+
const LOUD_NOTICES = [
|
|
20
|
+
"本次会话的上下文已超出模型上限",
|
|
21
|
+
"该智能体的连接已失效",
|
|
22
|
+
"该智能体的用量已达上限",
|
|
23
|
+
"智能体的审批策略拦截了这一轮",
|
|
24
|
+
];
|
|
25
|
+
const APPROVAL_BLOCKED = LOUD_NOTICES[3];
|
|
26
|
+
|
|
27
|
+
function PeekView({ peek, expandable, onExpand }: { peek: ToolPeek; expandable: boolean; onExpand: () => void }) {
|
|
28
|
+
const corner = <span className="tcorner">⎿</span>;
|
|
29
|
+
if (peek.kind === "count") {
|
|
30
|
+
return (
|
|
31
|
+
<button className="tpeek tpeek-count" style={{ cursor: expandable ? "pointer" : "default" }} onClick={() => expandable && onExpand()}>
|
|
32
|
+
{corner}<span className="tpeek-txt">{peek.text}</span>
|
|
33
|
+
{expandable && <span className="tpeek-more">展开</span>}
|
|
34
|
+
</button>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
if (peek.kind === "todos") {
|
|
38
|
+
return (
|
|
39
|
+
<div className="tpeek tpeek-todos">
|
|
40
|
+
{peek.items.map((t, i) => (
|
|
41
|
+
<div className={`tdo ${t.status}`} key={i}>
|
|
42
|
+
<span className="tdo-box">{t.status === "completed" ? "✔" : t.status === "in_progress" ? "▣" : "▢"}</span>
|
|
43
|
+
<span className="tdo-txt">{t.text}</span>
|
|
44
|
+
</div>
|
|
45
|
+
))}
|
|
46
|
+
</div>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
if (peek.kind === "diff") {
|
|
50
|
+
return (
|
|
51
|
+
<div className="tpeek tpeek-diff">
|
|
52
|
+
<div className="tpeek-sum">{corner}<span className="dstat add">+{peek.added}</span><span className="dstat del">−{peek.removed}</span>{peek.label && <span className="tpeek-txt">{peek.label}</span>}</div>
|
|
53
|
+
<pre className="tpeek-pre diff">{peek.lines.map((l, i) => <div className={`dl ${diffCls(l.sign)}`} key={i}>{l.sign} {l.text}</div>)}</pre>
|
|
54
|
+
{peek.truncated ? <button className="tpeek-more btn-link" onClick={onExpand}>还有 {peek.truncated} 行</button> : null}
|
|
55
|
+
</div>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
return (
|
|
59
|
+
<div className="tpeek tpeek-lines">
|
|
60
|
+
{peek.label && <div className="tpeek-sum">{corner}<span className="tpeek-txt">{peek.label}</span></div>}
|
|
61
|
+
<pre className="tpeek-pre">{peek.lines.join("\n") || "(无输出)"}</pre>
|
|
62
|
+
{peek.truncated ? <button className="tpeek-more btn-link" onClick={onExpand}>还有 {peek.truncated} 行</button> : null}
|
|
63
|
+
</div>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function ToolView({ data }: { data: ToolData }) {
|
|
68
|
+
const [open, setOpen] = useState<boolean | null>(null);
|
|
69
|
+
const bodyId = useId();
|
|
70
|
+
const hasDiff = !!data.diff?.length;
|
|
71
|
+
const expandable = !!(data.detail || hasDiff || data.result !== undefined);
|
|
72
|
+
// 失败的工具自动展开输出,和 Claude Code 一样。
|
|
73
|
+
const showBody = open ?? (!!data.isError && data.result !== undefined);
|
|
74
|
+
return (
|
|
75
|
+
<div className="tool">
|
|
76
|
+
<button className="tool-h" title={data.title} aria-expanded={expandable ? showBody : undefined} aria-controls={expandable ? bodyId : undefined} style={{ cursor: expandable ? "pointer" : "default" }} onClick={() => expandable && setOpen(!showBody)}>
|
|
77
|
+
{expandable && <span className={`tchev ${showBody ? "open" : ""}`}>{Icon.chevRight()}</span>}
|
|
78
|
+
<span className="tg">{data.title}</span>
|
|
79
|
+
{data.result !== undefined && <span className={data.isError ? "tx" : "tcheck"}>{data.isError ? Icon.x() : Icon.check()}</span>}
|
|
80
|
+
</button>
|
|
81
|
+
{data.peek && !showBody && <PeekView peek={data.peek} expandable={expandable} onExpand={() => setOpen(true)} />}
|
|
82
|
+
{showBody && (
|
|
83
|
+
<div className="tool-body" id={bodyId}>
|
|
84
|
+
{data.detail && <pre className="tool-pre">{data.detail}</pre>}
|
|
85
|
+
{hasDiff && <pre className="tool-pre diff">{data.diff!.map((l, i) => <div className={`dl ${diffCls(l.sign)}`} key={i}>{l.sign} {l.text}</div>)}</pre>}
|
|
86
|
+
{data.result !== undefined && (
|
|
87
|
+
<>
|
|
88
|
+
{(data.detail || hasDiff) && <div className="tool-divider">结果</div>}
|
|
89
|
+
<pre className={`tool-pre ${data.isError ? "err" : ""}`}>{data.result || "(无输出)"}</pre>
|
|
90
|
+
</>
|
|
91
|
+
)}
|
|
92
|
+
</div>
|
|
93
|
+
)}
|
|
94
|
+
</div>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// AskUserQuestion 卡片。对话任务里回答 = 发下一条消息(节点上的回合已经结束),所以提交后由上层组成文字发出。
|
|
99
|
+
function AskView({ data, agentLabel, onAnswer }: { data: ToolData; agentLabel: string; onAnswer?: (answers: AskAnswers) => Promise<void> | void }) {
|
|
100
|
+
const questions = data.ask?.questions ?? [];
|
|
101
|
+
const existing = data.ask?.answers;
|
|
102
|
+
const [state, setState] = useState(() => questions.map(() => ({ picked: [] as string[], other: "" })));
|
|
103
|
+
const [submitted, setSubmitted] = useState(false);
|
|
104
|
+
|
|
105
|
+
if (existing) {
|
|
106
|
+
return (
|
|
107
|
+
<div className="ask answered">
|
|
108
|
+
<div className="ask-head">{Icon.spark()} 你的回答</div>
|
|
109
|
+
{questions.map((q, i) => (
|
|
110
|
+
<div className="ask-q" key={i}>
|
|
111
|
+
<div className="ask-qh"><span className="ask-chip">{q.header}</span>{q.question}</div>
|
|
112
|
+
<div className="ask-picked">{(existing[i] ?? []).join("、") || "—"}</div>
|
|
113
|
+
</div>
|
|
114
|
+
))}
|
|
115
|
+
</div>
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const toggle = (qi: number, label: string, multi: boolean) =>
|
|
120
|
+
setState((s) => s.map((st, i) => {
|
|
121
|
+
if (i !== qi) return st;
|
|
122
|
+
if (multi) {
|
|
123
|
+
const has = st.picked.includes(label);
|
|
124
|
+
return { ...st, picked: has ? st.picked.filter((l) => l !== label) : [...st.picked, label] };
|
|
125
|
+
}
|
|
126
|
+
return { picked: [label], other: "" };
|
|
127
|
+
}));
|
|
128
|
+
const setOther = (qi: number, v: string) =>
|
|
129
|
+
setState((s) => s.map((st, i) => (i === qi ? (questions[i].multiSelect ? { ...st, other: v } : { picked: [], other: v }) : st)));
|
|
130
|
+
|
|
131
|
+
const answers: AskAnswers = state.map((st) => [...st.picked, ...(st.other.trim() ? [st.other.trim()] : [])]);
|
|
132
|
+
const complete = answers.every((a) => a.length > 0);
|
|
133
|
+
// 发送失败时恢复成可提交(选择保留),不让卡片卡在「发送中…」。
|
|
134
|
+
const submit = () => {
|
|
135
|
+
if (!complete || submitted || !onAnswer) return;
|
|
136
|
+
setSubmitted(true);
|
|
137
|
+
void Promise.resolve(onAnswer(answers)).catch(() => setSubmitted(false));
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
return (
|
|
141
|
+
<div className="ask">
|
|
142
|
+
<div className="ask-head">{Icon.spark()} {agentLabel} 需要你的输入</div>
|
|
143
|
+
{questions.map((q, i) => (
|
|
144
|
+
<div className="ask-q" key={i}>
|
|
145
|
+
<div className="ask-qh"><span className="ask-chip">{q.header}</span>{q.question}{q.multiSelect && <span className="ask-multi">可多选</span>}</div>
|
|
146
|
+
<div className="ask-opts">
|
|
147
|
+
{q.options.map((o) => (
|
|
148
|
+
<button key={o.label} className={`ask-opt ${state[i].picked.includes(o.label) ? "on" : ""}`} onClick={() => toggle(i, o.label, !!q.multiSelect)} disabled={submitted}>
|
|
149
|
+
<span className="ask-opt-l">{o.label}</span>
|
|
150
|
+
{o.description && <span className="ask-opt-d">{o.description}</span>}
|
|
151
|
+
</button>
|
|
152
|
+
))}
|
|
153
|
+
<input className="ask-other" placeholder="其他…" value={state[i].other} disabled={submitted} onChange={(e) => setOther(i, e.target.value)} />
|
|
154
|
+
</div>
|
|
155
|
+
</div>
|
|
156
|
+
))}
|
|
157
|
+
<div className="ask-foot">
|
|
158
|
+
<button className="btn btn-accent btn-sm" onClick={submit} disabled={!complete || submitted || !onAnswer}>{submitted ? "发送中…" : "发送回答"}</button>
|
|
159
|
+
</div>
|
|
160
|
+
</div>
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// browser_ask_user 卡片:移植自 Operator 的 InputView(已答 / 已取消 / 等待中)。
|
|
165
|
+
// 能不能在这里提交取决于数据后端:Operator 后端可以直接把值交给浏览器脚本(onSubmit);
|
|
166
|
+
// center 的对话任务在 Agent 等输入时会结束那一轮,卡片随即变成「已取消」,这里不提供提交,只显示等待中的字段。
|
|
167
|
+
function InputView({ input, agentLabel, onSubmit, onBrowser }: { input: InputRequest; agentLabel: string; onSubmit?: (id: string, values: string[]) => Promise<string | null>; onBrowser?: () => void }) {
|
|
168
|
+
const [vals, setVals] = useState<string[]>(() => input.fields.map(() => ""));
|
|
169
|
+
const [shown, setShown] = useState<boolean[]>(() => input.fields.map(() => false));
|
|
170
|
+
const [busy, setBusy] = useState(false);
|
|
171
|
+
const [err, setErr] = useState<string | null>(null);
|
|
172
|
+
|
|
173
|
+
if (input.status !== "pending") {
|
|
174
|
+
return (
|
|
175
|
+
<div className="ask answered">
|
|
176
|
+
<div className="ask-head">{Icon.lock()} {input.status === "answered" ? "你提供的信息" : "已取消 —— 这一轮被停止了"}</div>
|
|
177
|
+
<div className="ask-q"><div className="ask-qh">{input.message}</div></div>
|
|
178
|
+
{input.status === "answered" && input.fields.map((f, i) => (
|
|
179
|
+
<div className="ask-q" key={i}>
|
|
180
|
+
<div className="ask-qh"><span className="ask-chip">{f.label}</span></div>
|
|
181
|
+
<div className="ask-picked">{f.secret ? "••••••(只交给浏览器脚本,没有保存,也没有发给模型)" : input.values?.[i] ?? "—"}</div>
|
|
182
|
+
</div>
|
|
183
|
+
))}
|
|
184
|
+
</div>
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const canSubmit = !!onSubmit;
|
|
189
|
+
const set = (i: number, v: string) => setVals((a) => a.map((x, j) => (j === i ? v : x)));
|
|
190
|
+
const complete = vals.every((v) => v.trim());
|
|
191
|
+
const submit = async () => {
|
|
192
|
+
if (!complete || busy || !onSubmit) return;
|
|
193
|
+
setBusy(true);
|
|
194
|
+
setErr(null);
|
|
195
|
+
try {
|
|
196
|
+
const e = await onSubmit(input.id, vals.map((v, i) => input.fields[i].secret ? v : v.trim()));
|
|
197
|
+
if (e) { setErr(e); setBusy(false); }
|
|
198
|
+
} catch (error) {
|
|
199
|
+
setErr(error instanceof Error ? error.message : "提交失败,请重试");
|
|
200
|
+
setBusy(false);
|
|
201
|
+
}
|
|
202
|
+
};
|
|
203
|
+
return (
|
|
204
|
+
<div className="ask">
|
|
205
|
+
<div className="ask-head">{Icon.lock()} {agentLabel} 需要你提供信息 · 浏览器正停在这一页等待</div>
|
|
206
|
+
<div className="ask-q"><div className="ask-qh" style={{ whiteSpace: "pre-wrap" }}>{input.message}</div></div>
|
|
207
|
+
{input.fields.map((f, i) => (
|
|
208
|
+
<div className="ask-q" key={i}>
|
|
209
|
+
<div className="ask-qh"><span className="ask-chip">{f.label}</span>{f.secret && <span className="ask-multi">保密</span>}</div>
|
|
210
|
+
<div className="ask-opts">
|
|
211
|
+
{f.options?.map((o) => (
|
|
212
|
+
<button key={o} className={`ask-opt ${vals[i] === o ? "on" : ""}`} onClick={() => set(i, o)} disabled={busy || !canSubmit}>
|
|
213
|
+
<span className="ask-opt-l">{o}</span>
|
|
214
|
+
</button>
|
|
215
|
+
))}
|
|
216
|
+
<div className="input-row">
|
|
217
|
+
<input
|
|
218
|
+
className="ask-other"
|
|
219
|
+
type={f.secret && !shown[i] ? "password" : "text"}
|
|
220
|
+
autoComplete={f.secret ? "new-password" : "off"}
|
|
221
|
+
placeholder={f.options?.length ? "其他…" : f.placeholder || `输入${f.label}`}
|
|
222
|
+
value={f.options?.includes(vals[i]) ? "" : vals[i]}
|
|
223
|
+
disabled={busy || !canSubmit}
|
|
224
|
+
onChange={(e) => set(i, e.target.value)}
|
|
225
|
+
onKeyDown={(e) => { if (e.key === "Enter" && !e.nativeEvent.isComposing) void submit(); }}
|
|
226
|
+
/>
|
|
227
|
+
{f.secret && canSubmit && (
|
|
228
|
+
<button className="btn btn-ghost btn-sm" type="button" onClick={() => setShown((a) => a.map((x, j) => (j === i ? !x : x)))}>
|
|
229
|
+
{shown[i] ? "隐藏" : "显示"}
|
|
230
|
+
</button>
|
|
231
|
+
)}
|
|
232
|
+
</div>
|
|
233
|
+
</div>
|
|
234
|
+
</div>
|
|
235
|
+
))}
|
|
236
|
+
<div className="ask-foot" style={{ gap: 8, alignItems: "center" }}>
|
|
237
|
+
{err && <span className="input-err">{err}</span>}
|
|
238
|
+
{!err && canSubmit && input.fields.some((f) => f.secret) && <span className="input-note">密码只交给浏览器脚本,不保存、不发给模型</span>}
|
|
239
|
+
{!canSubmit && <span className="input-note">请在执行节点的 Operator 界面里提交(密码只交给浏览器脚本,不经对话发送)</span>}
|
|
240
|
+
<span style={{ flex: 1 }} />
|
|
241
|
+
{onBrowser && <button className="btn btn-line btn-sm" onClick={onBrowser}>查看浏览器</button>}
|
|
242
|
+
<button className="btn btn-accent btn-sm" onClick={() => void submit()} disabled={!complete || busy || !canSubmit}>{busy ? "发送中…" : "提交"}</button>
|
|
243
|
+
</div>
|
|
244
|
+
</div>
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// 浏览器交接提示(Operator 的 browser_handoff / 页面权限请求写进转录的「🖐 …」行):要用户在页面里亲自操作。
|
|
249
|
+
// 与 Operator 一致:有浏览器能力时带「查看浏览器」「我已完成,交还给 AI」两个按钮,没有时只显示文字。
|
|
250
|
+
export const HANDOFF_NOTICE_PREFIX = "🖐 ";
|
|
251
|
+
function HandoffLine({ text, onBrowser, onHandback }: { text: string; onBrowser?: () => void; onHandback?: () => Promise<string | null> }) {
|
|
252
|
+
const [state, setState] = useState<"idle" | "busy" | "done">("idle");
|
|
253
|
+
const [err, setErr] = useState<string | null>(null);
|
|
254
|
+
return (
|
|
255
|
+
<div className="msg system info">
|
|
256
|
+
<div className="msg-body">
|
|
257
|
+
{text}
|
|
258
|
+
{(onBrowser || onHandback) && (
|
|
259
|
+
<div style={{ display: "flex", gap: 8, marginTop: 8, flexWrap: "wrap", alignItems: "center" }}>
|
|
260
|
+
{onBrowser && <button className="btn btn-line btn-sm" onClick={onBrowser}>查看浏览器</button>}
|
|
261
|
+
{onHandback && (
|
|
262
|
+
<button className="btn btn-accent btn-sm" disabled={state !== "idle"} onClick={async () => {
|
|
263
|
+
setState("busy");
|
|
264
|
+
const e = await onHandback();
|
|
265
|
+
if (e) { setErr(e); setState("idle"); } else setState("done");
|
|
266
|
+
}}>{state === "done" ? "已交还" : state === "busy" ? "交还中…" : "我已完成,交还给 AI"}</button>
|
|
267
|
+
)}
|
|
268
|
+
{err && <span className="input-err">{err}</span>}
|
|
269
|
+
</div>
|
|
270
|
+
)}
|
|
271
|
+
</div>
|
|
272
|
+
</div>
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// 附件条:Operator 的用户消息里,图片 / 大段粘贴以「[Attached image|file: 路径]」标记行出现。
|
|
277
|
+
// 能拿到文件地址时(attachmentUrl)显示缩略图 / 可点的文件片,拿不到时显示不可点的片。
|
|
278
|
+
function AttachmentStrip({ items, attachmentUrl }: { items: MsgAttachment[]; attachmentUrl?: (a: MsgAttachment) => string | null }) {
|
|
279
|
+
const open = useContext(FileOpenContext), resolve = useContext(FileLinkContext);
|
|
280
|
+
if (!items.length) return null;
|
|
281
|
+
return (
|
|
282
|
+
<div className="msg-attachments">
|
|
283
|
+
{items.map((a, i) => {
|
|
284
|
+
const url = attachmentUrl?.(a) ?? resolve?.(a.path, a.kind === "image" ? "image" : "link") ?? null;
|
|
285
|
+
if (a.kind === "image" && url) {
|
|
286
|
+
return <a key={i} href={url} target="_blank" rel="noreferrer" title="查看原图"><img src={url} alt="附件图片" loading="lazy" /></a>;
|
|
287
|
+
}
|
|
288
|
+
const label = a.kind === "image" ? "附件图片" : "附件文件";
|
|
289
|
+
return url
|
|
290
|
+
? <a key={i} href={url} target="_blank" rel="noreferrer" className="file-chip" title={`打开 ${a.name}`} onClick={event => { if (!event.metaKey && !event.ctrlKey && !event.shiftKey && !event.altKey && event.button === 0 && open?.(a.path)) event.preventDefault(); }}>{Icon.clip()} <span>{a.kind === "file" ? a.name : label}</span></a>
|
|
291
|
+
: open && a.kind === "file" ? <button key={i} className="file-chip" onClick={() => open(a.path)} title={`打开 ${a.name}`}>{Icon.clip()} <span>{a.name}</span></button> : <span key={i} className="file-chip" title={a.name}>{Icon.clip()} <span>{label}</span></span>;
|
|
292
|
+
})}
|
|
293
|
+
</div>
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** 用户消息正文:普通文字按 markdown 渲染,附件 / 长粘贴是可折叠卡片,很长的纯文字也折叠。 */
|
|
298
|
+
function UserBody({ content, attachmentUrl, viewing = true }: { content: string; attachmentUrl?: (a: MsgAttachment) => string | null; viewing?: boolean }) {
|
|
299
|
+
const { text, attachments } = splitAttachments(content);
|
|
300
|
+
// 看板上正在看的东西(缺省随消息带上的那块):配置成不显示时转录里也不显示。
|
|
301
|
+
const parts = (text ? splitMessage(text) : []).filter((p) => viewing || p.kind !== "block" || p.block.kind !== "viewing");
|
|
302
|
+
return (
|
|
303
|
+
<>
|
|
304
|
+
{parts.map((part, i) => part.kind === "block"
|
|
305
|
+
? part.block.kind === "context" ? <ReferenceCard key={i} title="参考内容" text={part.block.text.replace(/^用户在右侧看板里引用了下面这些,供这条消息参考:\n/, "")} /> : <TextBlockCard key={i} block={part.block} />
|
|
306
|
+
: <QuotedText key={i} text={part.text} />)}
|
|
307
|
+
<AttachmentStrip items={attachments} attachmentUrl={attachmentUrl} />
|
|
308
|
+
</>
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function QuotedText({ text }: { text: string }) {
|
|
313
|
+
const quoted = splitQuotedMessage(text);
|
|
314
|
+
return quoted ? <><ReferenceCard title={quoted.quote.author} text={quoted.quote.text} />{quoted.text && <LongText text={quoted.text} />}</> : <LongText text={text} />;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function LongText({ text }: { text: string }) {
|
|
318
|
+
const [open, setOpen] = useState(false);
|
|
319
|
+
if (!isLongMessage(text)) return <Markdown>{text}</Markdown>;
|
|
320
|
+
return (
|
|
321
|
+
<div className={`cli-long ${open ? "open" : ""}`}>
|
|
322
|
+
{open ? (text.length > 50_000 ? <pre className="cli-long-pre">{text}</pre> : <Markdown>{text}</Markdown>) : <div className="cli-long-pre clamp">{text.slice(0, 1_200)}</div>}
|
|
323
|
+
<button type="button" className="cli-long-btn" onClick={() => setOpen((o) => !o)}>
|
|
324
|
+
{open ? "收起" : `展开全部(${text.length.toLocaleString("zh-CN")} 字)`}
|
|
325
|
+
</button>
|
|
326
|
+
</div>
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** 消息行的显示选项(来自组件配置)。要引用稳定,否则 memo 失效。 */
|
|
331
|
+
export interface MessageViewOptions {
|
|
332
|
+
timestamps: boolean; initialBadge: boolean; userLabel: string; agentName: string;
|
|
333
|
+
/** 用户消息里「看板正在看」那块显示成卡片;false = 不显示(照样发给了智能体)。缺省显示。 */
|
|
334
|
+
viewingBlock?: boolean;
|
|
335
|
+
}
|
|
336
|
+
const DEFAULT_VIEW: MessageViewOptions = { timestamps: true, initialBadge: true, userLabel: "你", agentName: "智能体" };
|
|
337
|
+
|
|
338
|
+
export interface MessageHandlers {
|
|
339
|
+
onQuote?: (msg:Msg,selected?:string)=>void;
|
|
340
|
+
/** browser_ask_user 卡片提交(返回错误文案或 null)。后端不支持时不给,卡片只读。 */
|
|
341
|
+
onInput?: (inputId: string, values: string[]) => Promise<string | null>;
|
|
342
|
+
/** 「查看浏览器」:宿主有浏览器面板时给。 */
|
|
343
|
+
onShowBrowser?: () => void;
|
|
344
|
+
/** 浏览器交接后「我已完成,交还给 AI」(返回错误文案或 null)。 */
|
|
345
|
+
onHandback?: () => Promise<string | null>;
|
|
346
|
+
/** 附件标记行的文件地址(Operator 后端才有)。 */
|
|
347
|
+
attachmentUrl?: (a: MsgAttachment) => string | null;
|
|
348
|
+
/** 与 Operator 相同的签名:askId 是提问的 tool_use id。 */
|
|
349
|
+
onAnswer?: (askId: string, questions: AskQuestion[], answers: AskAnswers) => Promise<void> | void;
|
|
350
|
+
onCancelQueued?: (msg: Msg) => void;
|
|
351
|
+
onRetry?: (msgId: string) => void;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// memo:实时回合里每个事件都会让上层重渲染,但消息对象只在内容变化时替换,没变的行直接跳过。
|
|
355
|
+
// 调用方要传引用稳定的回调,否则 memo 失效。
|
|
356
|
+
export const MessageView = memo(function MessageView({ m, initial: isInitial, hideWho, running, agent, agentLabel = "智能体", handlers, view = DEFAULT_VIEW }: {
|
|
357
|
+
m: Msg; initial: boolean; hideWho: boolean; running?: boolean; agent?: string | null; agentLabel?: string; handlers: MessageHandlers; view?: MessageViewOptions;
|
|
358
|
+
}) {
|
|
359
|
+
const initial = isInitial && view.initialBadge;
|
|
360
|
+
const time = view.timestamps && m.ts != null ? <span className="msg-time">{clockTime(m.ts)}</span> : null;
|
|
361
|
+
if (m.role === "queued") {
|
|
362
|
+
return (
|
|
363
|
+
<div className="msg user queued">
|
|
364
|
+
<div className="who"><Avatar who="user" /> {view.userLabel}<span className="badge queued-badge">排队中</span>{time}</div>
|
|
365
|
+
<div className="msg-body">
|
|
366
|
+
<UserBody content={m.content} attachmentUrl={handlers.attachmentUrl} viewing={view.viewingBlock} />
|
|
367
|
+
{m.note && <div className="cli-queued-note">⏳ {m.note}</div>}
|
|
368
|
+
{handlers.onCancelQueued && <button className="queued-x" title="从队列中移除" aria-label="从队列中移除" onClick={() => handlers.onCancelQueued!(m)}>{Icon.x()}</button>}
|
|
369
|
+
</div>
|
|
370
|
+
</div>
|
|
371
|
+
);
|
|
372
|
+
}
|
|
373
|
+
if (m.role === "tool") {
|
|
374
|
+
const data = toolDataOf(m);
|
|
375
|
+
const child = subagentFields(m);
|
|
376
|
+
if (child.subagentMessage) return null;
|
|
377
|
+
if (child.subagent) return <SubagentEntry info={child.subagent} generation={m.generation} />;
|
|
378
|
+
if (data.input) return <div className="msg msg-tool"><InputView input={data.input} agentLabel={agentLabel} onSubmit={handlers.onInput} onBrowser={handlers.onShowBrowser} /></div>;
|
|
379
|
+
if (data.ask) return <div className="msg msg-tool"><AskView data={data} agentLabel={agentLabel} onAnswer={handlers.onAnswer ? (answers) => handlers.onAnswer!(data.ask?.id || m.toolId || "", data.ask?.questions ?? [], answers) : undefined} /></div>;
|
|
380
|
+
return <div className="msg msg-tool"><ToolView data={data} /></div>;
|
|
381
|
+
}
|
|
382
|
+
if (m.role === "system" && m.content.startsWith(HANDOFF_NOTICE_PREFIX)) {
|
|
383
|
+
return <HandoffLine text={m.content} onBrowser={handlers.onShowBrowser} onHandback={handlers.onHandback} />;
|
|
384
|
+
}
|
|
385
|
+
if (m.role === "system") {
|
|
386
|
+
if (LOUD_NOTICES.some((notice) => m.content.includes(notice))) {
|
|
387
|
+
return (
|
|
388
|
+
<div className="msg system overflow">
|
|
389
|
+
<div className="msg-body">
|
|
390
|
+
{m.content}
|
|
391
|
+
{m.content.includes(APPROVAL_BLOCKED) && handlers.onRetry && (
|
|
392
|
+
<div className="overflow-actions">
|
|
393
|
+
<button className="btn btn-sm" onClick={() => handlers.onRetry!(m.id)} disabled={running} title="重新发送失败的消息">{Icon.bolt()} 重试</button>
|
|
394
|
+
</div>
|
|
395
|
+
)}
|
|
396
|
+
</div>
|
|
397
|
+
</div>
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
const info = /^[✓ℹ⇄]/.test(m.content);
|
|
401
|
+
const glyphed = info || m.content.startsWith("⚠");
|
|
402
|
+
return <div className={`msg system${info ? " info" : ""}`}><div className="msg-body">{glyphed ? m.content : `⚠ ${m.content}`}</div></div>;
|
|
403
|
+
}
|
|
404
|
+
const isUser = m.role === "user";
|
|
405
|
+
return (
|
|
406
|
+
<div className={`msg ${isUser ? "user" : "assistant"} ${initial ? "initial" : ""}`}>
|
|
407
|
+
{!hideWho && (
|
|
408
|
+
<div className="who">
|
|
409
|
+
<Avatar who={isUser ? "user" : "cc"} agent={agent} />
|
|
410
|
+
{isUser ? view.userLabel : view.agentName}
|
|
411
|
+
{initial && <span className="badge">初始提示</span>}
|
|
412
|
+
{time}
|
|
413
|
+
</div>
|
|
414
|
+
)}
|
|
415
|
+
<div className="msg-body">
|
|
416
|
+
{initial && <div className="initial-tag">{Icon.spark()} 已随项目上下文发送</div>}
|
|
417
|
+
{isUser ? <UserBody content={m.content} attachmentUrl={handlers.attachmentUrl} viewing={view.viewingBlock} /> : m.content && <Markdown>{m.content}</Markdown>}
|
|
418
|
+
</div>
|
|
419
|
+
{handlers.onQuote && m.content.trim() && <div className="msg-actions"><button aria-label="引用消息" title="引用消息或选中文字" onMouseDown={e=>e.preventDefault()} onClick={e=>{const selection=window.getSelection();const body=e.currentTarget.closest('.msg')?.querySelector('.msg-body');const selected=selection&&body?.contains(selection.anchorNode)&&body.contains(selection.focusNode)?selection.toString().trim():'';handlers.onQuote?.(m,selected||undefined);}}>{Icon.doc()} 引用</button></div>}
|
|
420
|
+
</div>
|
|
421
|
+
);
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
export function SessionBreak({ summary }: { summary: string }) {
|
|
425
|
+
return (
|
|
426
|
+
<div className="sbreak">
|
|
427
|
+
<span className="ln" />
|
|
428
|
+
<div className="card">
|
|
429
|
+
<div className="cl">{Icon.clear()} 上下文已清空 · 摘要已保存</div>
|
|
430
|
+
<div className="ct">{summary}</div>
|
|
431
|
+
</div>
|
|
432
|
+
<span className="ln" />
|
|
433
|
+
</div>
|
|
434
|
+
);
|
|
435
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { fmtSize } from "../board/fileKind";
|
|
2
|
+
import { Icon } from "./icons";
|
|
3
|
+
|
|
4
|
+
export type UploadProgressStatus = "queued" | "checking" | "uploading" | "verifying" | "ready" | "cancelled" | "error";
|
|
5
|
+
export interface UploadProgressProps {
|
|
6
|
+
name: string;
|
|
7
|
+
status: UploadProgressStatus;
|
|
8
|
+
bytes?: number;
|
|
9
|
+
total?: number;
|
|
10
|
+
error?: string;
|
|
11
|
+
onCancel?: () => void;
|
|
12
|
+
onRetry?: () => void;
|
|
13
|
+
onRemove?: () => void;
|
|
14
|
+
disabled?: boolean;
|
|
15
|
+
className?: string;
|
|
16
|
+
}
|
|
17
|
+
const labels: Record<UploadProgressStatus, string> = { queued: "等待上传", checking: "校验中", uploading: "上传中", verifying: "确认中", ready: "已上传", cancelled: "已取消", error: "上传失败" };
|
|
18
|
+
|
|
19
|
+
/** Presentation only: the upload owner controls status, retries and cancellation. */
|
|
20
|
+
export function UploadProgress({ name, status, bytes, total, error, onCancel, onRetry, onRemove, disabled, className }: UploadProgressProps) {
|
|
21
|
+
const size = typeof total === "number" && Number.isFinite(total) && total >= 0 ? Math.floor(total) : undefined;
|
|
22
|
+
const received = typeof bytes === "number" && Number.isFinite(bytes) ? Math.max(0, Math.min(Math.floor(bytes), size ?? Infinity)) : undefined;
|
|
23
|
+
const active = status === "queued" || status === "checking" || status === "uploading" || status === "verifying";
|
|
24
|
+
const percentage = status === "ready" ? 100 : status === "uploading" && received !== undefined && size !== undefined && size > 0 ? Math.floor(received / size * 100) : undefined;
|
|
25
|
+
const count = status === "ready" && size !== undefined ? fmtSize(size) : received !== undefined ? `${fmtSize(received)}${size !== undefined ? ` / ${fmtSize(size)}` : ""}` : size !== undefined ? fmtSize(size) : "";
|
|
26
|
+
const label = labels[status];
|
|
27
|
+
return <div className={`cli-upload-progress${className ? ` ${className}` : ""}`} data-upload-status={status}>
|
|
28
|
+
<span className="cli-upload-icon" aria-hidden="true">{Icon.doc()}</span>
|
|
29
|
+
<div className="cli-upload-body">
|
|
30
|
+
<div className="cli-upload-head">
|
|
31
|
+
<span className="cli-upload-name" title={name}>{name}</span>
|
|
32
|
+
<span className="cli-upload-status" role="status" aria-live="polite" aria-atomic="true"><span className="cli-upload-sr">{name}:</span>{label}</span>
|
|
33
|
+
</div>
|
|
34
|
+
{(count || percentage !== undefined) && <div className="cli-upload-meta"><span>{count}</span>{percentage !== undefined && <span aria-hidden="true">{percentage}%</span>}</div>}
|
|
35
|
+
{(active || status === "ready") && <div className={`cli-upload-track${percentage === undefined ? " indeterminate" : ""}`} role="progressbar" aria-label={`上传进度:${name}`} aria-valuemin={0} aria-valuemax={100} aria-valuenow={percentage} aria-valuetext={`${label}${count ? `,${count}` : ""}`}>
|
|
36
|
+
<span className="cli-upload-fill" style={percentage === undefined ? undefined : { width: `${percentage}%` }} />
|
|
37
|
+
</div>}
|
|
38
|
+
{status === "error" && error && <div className="cli-upload-error" role="alert">{error}</div>}
|
|
39
|
+
</div>
|
|
40
|
+
{((active && onCancel) || (!active && (onRemove || (status === "error" || status === "cancelled") && onRetry))) && <div className="cli-upload-actions">
|
|
41
|
+
{active && onCancel && <button type="button" disabled={disabled} className="cli-upload-action" title="取消上传" aria-label={`取消上传 ${name}`} onClick={onCancel}>{Icon.x()}</button>}
|
|
42
|
+
{(status === "error" || status === "cancelled") && onRetry && <button type="button" disabled={disabled} className="cli-upload-action cli-upload-retry" aria-label={`重试上传 ${name}`} onClick={onRetry}>重试</button>}
|
|
43
|
+
{!active && onRemove && <button type="button" disabled={disabled} className="cli-upload-action" title="移除上传记录" aria-label={`移除上传记录 ${name}`} onClick={onRemove}>{Icon.x()}</button>}
|
|
44
|
+
</div>}
|
|
45
|
+
</div>;
|
|
46
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { useEffect, useLayoutEffect, useRef, useState, type ReactNode, type RefObject } from "react";
|
|
2
|
+
import { Icon } from "./icons";
|
|
3
|
+
|
|
4
|
+
const useLayout = typeof window === "undefined" ? useEffect : useLayoutEffect;
|
|
5
|
+
|
|
6
|
+
export function useWorkspaceDrawer(ref: RefObject<HTMLElement | null>, forced = false) {
|
|
7
|
+
const [narrow, setNarrow] = useState(false);
|
|
8
|
+
useLayout(() => {
|
|
9
|
+
const element = ref.current;
|
|
10
|
+
if (!element) return;
|
|
11
|
+
const measure = () => { const width = element.getBoundingClientRect().width; if (width > 0) setNarrow(width <= 640); };
|
|
12
|
+
measure();
|
|
13
|
+
const resize = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(measure);
|
|
14
|
+
resize?.observe(element);
|
|
15
|
+
window.addEventListener("resize", measure);
|
|
16
|
+
return () => { resize?.disconnect(); window.removeEventListener("resize", measure); };
|
|
17
|
+
}, [ref]);
|
|
18
|
+
return forced || narrow;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Keep the board in the same React position when it moves between desktop and mobile layouts. */
|
|
22
|
+
export function WorkspaceDrawer({ enabled, open, onClose, children }: {
|
|
23
|
+
enabled: boolean; open: boolean; onClose: () => void; children: ReactNode;
|
|
24
|
+
}) {
|
|
25
|
+
const panel = useRef<HTMLDivElement>(null);
|
|
26
|
+
const closeButton = useRef<HTMLButtonElement>(null);
|
|
27
|
+
const close = useRef(onClose);
|
|
28
|
+
close.current = onClose;
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
if (!enabled || !open) return;
|
|
31
|
+
const previous = document.activeElement as HTMLElement | null;
|
|
32
|
+
closeButton.current?.focus({ preventScroll: true });
|
|
33
|
+
const onKey = (event: KeyboardEvent) => {
|
|
34
|
+
if (event.defaultPrevented) return;
|
|
35
|
+
// Menus rendered into document.body handle their own Escape/Tab first.
|
|
36
|
+
const dialog = (document.activeElement as HTMLElement | null)?.closest('[role="dialog"]');
|
|
37
|
+
if (dialog && dialog !== panel.current) return;
|
|
38
|
+
if (event.key === "Escape") { event.preventDefault(); close.current(); return; }
|
|
39
|
+
if (event.key !== "Tab" || !panel.current) return;
|
|
40
|
+
const items = [...panel.current.querySelectorAll<HTMLElement>('button:not(:disabled),a[href],input:not(:disabled),textarea:not(:disabled),select:not(:disabled),[tabindex="0"]')]
|
|
41
|
+
.filter(item => !item.closest('[hidden],[inert]') && getComputedStyle(item).display !== "none" && getComputedStyle(item).visibility !== "hidden");
|
|
42
|
+
const first = items[0], last = items.at(-1);
|
|
43
|
+
if (!first || !last) return;
|
|
44
|
+
if (!panel.current.contains(document.activeElement) || (event.shiftKey && document.activeElement === first)) { event.preventDefault(); (event.shiftKey ? last : first).focus({ preventScroll: true }); }
|
|
45
|
+
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus({ preventScroll: true }); }
|
|
46
|
+
};
|
|
47
|
+
window.addEventListener("keydown", onKey);
|
|
48
|
+
return () => {
|
|
49
|
+
window.removeEventListener("keydown", onKey);
|
|
50
|
+
const current = document.activeElement;
|
|
51
|
+
if ((current === document.body || panel.current?.contains(current)) && previous?.isConnected && !previous.closest('[hidden],[inert]')) previous.focus({ preventScroll: true });
|
|
52
|
+
};
|
|
53
|
+
}, [enabled, open]);
|
|
54
|
+
return <div className={enabled ? "workspace-drawer-layer" : "workspace-board-slot"} hidden={enabled && !open}>
|
|
55
|
+
<button type="button" className="workspace-drawer-scrim" aria-label="收起工作区遮罩" tabIndex={-1} hidden={!enabled} onClick={onClose} />
|
|
56
|
+
<div ref={panel} className={enabled ? "workspace-drawer-panel" : "workspace-board-slot"} role={enabled && open ? "dialog" : undefined} aria-modal={enabled && open ? true : undefined} aria-label={enabled ? "工作区看板" : undefined}>
|
|
57
|
+
<div className="workspace-drawer-heading" hidden={!enabled}>
|
|
58
|
+
<span>工作区</span>
|
|
59
|
+
<button ref={closeButton} type="button" className="workspace-drawer-close" aria-label="关闭工作区" title="关闭工作区" onClick={onClose}>{Icon.x()}</button>
|
|
60
|
+
</div>
|
|
61
|
+
{children}
|
|
62
|
+
</div>
|
|
63
|
+
</div>;
|
|
64
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { useLayoutEffect, useRef, type ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
/** A scrollable header shared by the conversation and its existing board tabs. */
|
|
4
|
+
export function WorkspaceTabs({ title, subtitle, chatActive, onChat, onTabsHost }: {
|
|
5
|
+
title: string;
|
|
6
|
+
subtitle?: ReactNode;
|
|
7
|
+
chatActive: boolean;
|
|
8
|
+
onChat: () => void;
|
|
9
|
+
onTabsHost: (host: HTMLDivElement | null) => void;
|
|
10
|
+
}) {
|
|
11
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
12
|
+
useLayoutEffect(() => {
|
|
13
|
+
const box = ref.current;
|
|
14
|
+
if (!box) return;
|
|
15
|
+
let focused: HTMLElement | null = null;
|
|
16
|
+
const onFocus = (event: FocusEvent) => { focused = event.target as HTMLElement; };
|
|
17
|
+
const tabs = () => [...box.querySelectorAll<HTMLButtonElement>('button[role="tab"]')].filter(tab => !tab.disabled);
|
|
18
|
+
const reveal = (tab: HTMLElement) => {
|
|
19
|
+
const outer = box.getBoundingClientRect(), inner = tab.getBoundingClientRect();
|
|
20
|
+
if (inner.left < outer.left) box.scrollLeft -= outer.left - inner.left + 8;
|
|
21
|
+
else if (inner.right > outer.right) box.scrollLeft += inner.right - outer.right + 8;
|
|
22
|
+
};
|
|
23
|
+
const sync = () => {
|
|
24
|
+
const items = tabs(), active = items.find(tab => tab.getAttribute("aria-selected") === "true") ?? items[0];
|
|
25
|
+
for (const item of items) item.tabIndex = item === active ? 0 : -1;
|
|
26
|
+
if (active) reveal(active);
|
|
27
|
+
if (active && focused && !focused.isConnected && document.activeElement === document.body) active.focus({ preventScroll: true });
|
|
28
|
+
};
|
|
29
|
+
// Board tabs are portals: listen on the DOM container so both adapters share keyboard behavior.
|
|
30
|
+
const onKey = (event: KeyboardEvent) => {
|
|
31
|
+
const items = tabs(), index = items.indexOf(event.target as HTMLButtonElement);
|
|
32
|
+
if (index < 0 || event.altKey || event.metaKey || event.ctrlKey) return;
|
|
33
|
+
let next: number;
|
|
34
|
+
if (event.key === "ArrowRight") next = (index + 1) % items.length;
|
|
35
|
+
else if (event.key === "ArrowLeft") next = (index - 1 + items.length) % items.length;
|
|
36
|
+
else if (event.key === "Home") next = 0;
|
|
37
|
+
else if (event.key === "End") next = items.length - 1;
|
|
38
|
+
else return;
|
|
39
|
+
event.preventDefault();
|
|
40
|
+
for (const item of items) item.tabIndex = item === items[next] ? 0 : -1;
|
|
41
|
+
items[next].focus({ preventScroll: true });
|
|
42
|
+
reveal(items[next]);
|
|
43
|
+
};
|
|
44
|
+
sync();
|
|
45
|
+
box.addEventListener("keydown", onKey);
|
|
46
|
+
box.addEventListener("focusin", onFocus);
|
|
47
|
+
const mutations = new MutationObserver(sync);
|
|
48
|
+
mutations.observe(box, { childList: true, subtree: true, attributes: true, attributeFilter: ["aria-selected", "disabled"] });
|
|
49
|
+
const resize = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(sync);
|
|
50
|
+
resize?.observe(box);
|
|
51
|
+
return () => { box.removeEventListener("keydown", onKey); box.removeEventListener("focusin", onFocus); mutations.disconnect(); resize?.disconnect(); };
|
|
52
|
+
}, []);
|
|
53
|
+
return <div ref={ref} className="sh-main workspace-tabs" role="tablist" aria-label="对话与工作区">
|
|
54
|
+
<button type="button" className={`workspace-chat-tab${chatActive ? " on" : ""}`} role="tab" aria-selected={chatActive} aria-label={`对话:${title}`} title={title} onClick={onChat}>
|
|
55
|
+
{subtitle && <span className="workspace-chat-subtitle">{subtitle}</span>}
|
|
56
|
+
<span className="workspace-chat-title"><span className="workspace-chat-kind">对话</span><span>{title}</span></span>
|
|
57
|
+
</button>
|
|
58
|
+
<div className="workspace-board-tabs-host" ref={onTabsHost} />
|
|
59
|
+
</div>;
|
|
60
|
+
}
|