@spzxg/cli-ui 0.6.1 → 0.6.5
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 +26 -0
- package/README.md +145 -13
- package/dist/cli-ui-embed-BmLN2Jjg.js +43290 -0
- package/dist/{cli-ui-index-BFpRZXn4.js → cli-ui-index-D4qfPHMd.js} +2 -2
- package/dist/{cli-ui-jszip.min-B9tqMA-e.js → cli-ui-jszip.min-DC9898pC.js} +1 -1
- package/dist/{cli-ui-jszip.min-DIavJzUG.js → cli-ui-jszip.min-DpEl0G1z.js} +2 -2
- package/dist/{cli-ui-xlsx-BKHhqcoa.js → cli-ui-xlsx-zs7oEtPj.js} +1 -1
- package/dist/cli-ui.css +32 -1
- package/dist/cli-ui.js +1 -1
- package/package.json +14 -8
- package/src/board/BrowserPane.tsx +160 -74
- package/src/board/ChangesPane.tsx +2 -2
- package/src/board/CliBoard.tsx +79 -50
- package/src/board/FileViewer.tsx +32 -22
- package/src/board/FilesPane.tsx +32 -15
- package/src/board/PanelControls.tsx +28 -0
- package/src/board/blocks.tsx +5 -5
- package/src/board/context.ts +65 -0
- package/src/board/fileOps.ts +26 -3
- package/src/board/fileWatch.ts +88 -0
- package/src/board/folderDownload.ts +30 -14
- package/src/board/panels.ts +33 -0
- package/src/board/textLoad.ts +2 -2
- package/src/board/workspace.ts +38 -9
- package/src/chat/CliChat.tsx +113 -53
- package/src/chat/Composer.tsx +68 -57
- package/src/chat/RunControls.tsx +99 -0
- package/src/chat/configuration.ts +15 -0
- package/src/chat/draft.ts +41 -0
- package/src/chat/export.ts +2 -2
- package/src/components.tsx +13 -0
- package/src/core/blocks.ts +2 -2
- package/src/core/callbacks.ts +8 -0
- package/src/core/client.ts +16 -6
- package/src/core/config.ts +11 -15
- package/src/core/conversation.ts +4 -4
- package/src/core/id.ts +15 -0
- package/src/core/identity.ts +33 -0
- package/src/core/provider.tsx +24 -14
- package/src/core/store.ts +83 -26
- package/src/core/types.ts +34 -0
- package/src/elements.tsx +135 -29
- package/src/embed.ts +6 -8
- package/src/index.ts +24 -17
- package/src/layout/CliSplit.tsx +1 -1
- package/src/msglist/CliMsgList.tsx +10 -10
- 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 +624 -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 +517 -0
- package/src/operator/node/CommandPalette.tsx +213 -0
- package/src/operator/node/Composer.tsx +287 -0
- package/src/operator/node/InsightsView.tsx +831 -0
- package/src/operator/node/Layout.tsx +190 -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 +250 -0
- package/src/operator/node/Orchestrator.tsx +634 -0
- package/src/operator/node/ProjectLanding.tsx +86 -0
- package/src/operator/node/ProjectsColumn.tsx +137 -0
- package/src/operator/node/ProviderAuth.tsx +561 -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 +260 -0
- package/src/operator/node/SessionView.tsx +288 -0
- package/src/operator/node/SettingsView.tsx +690 -0
- package/src/operator/node/TaskBoard.tsx +352 -0
- package/src/operator/node/TaskChanges.tsx +530 -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 +463 -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 +40 -0
- package/src/operator/node/boardBackend.ts +80 -0
- package/src/operator/node/boardEvents.ts +22 -0
- package/src/operator/node/boardInteraction.ts +15 -0
- package/src/operator/node/boardState.ts +57 -0
- package/src/operator/node/boardWorkspace.ts +12 -0
- package/src/operator/node/branchFlow.ts +1 -0
- package/src/operator/node/composerDraft.ts +52 -0
- package/src/operator/node/fileLinks.ts +81 -0
- package/src/operator/node/format.ts +290 -0
- package/src/operator/node/github.tsx +234 -0
- package/src/operator/node/icons.tsx +88 -0
- package/src/operator/node/liveStream.ts +270 -0
- package/src/operator/node/modals.tsx +720 -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 +332 -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 +296 -0
- package/src/operator/node/useGlobalEvents.ts +97 -0
- package/src/operator/node/useOrchestrator.ts +696 -0
- package/src/operator/node/usePrefs.ts +114 -0
- package/src/operator/node/useRecaps.ts +91 -0
- package/src/operator/node/useTaskStream.ts +279 -0
- package/src/operator.base.css +1798 -0
- package/src/operator.css +186 -16
- package/src/styles.css +51 -0
- package/src/ui/Markdown.tsx +7 -2
- package/src/ui/Popover.tsx +77 -0
- package/src/ui/TextBlock.tsx +3 -3
- package/src/ui/Transcript.tsx +10 -8
- package/src/ui/fileLinks.ts +6 -0
- package/src/ui/format.ts +1 -1
- package/src/ui/icons.tsx +1 -0
- package/src/ui/shared.tsx +1 -1
- package/src/ui/transcriptModel.ts +2 -2
- package/dist/cli-ui-embed-Dk2QGHxn.js +0 -41146
|
@@ -0,0 +1,463 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useContext } from "react";
|
|
3
|
+
import { OperatorBoardContext } from "./boardInteraction";
|
|
4
|
+
import { FileLinkContext } from "./fileLinks";
|
|
5
|
+
|
|
6
|
+
import { memo, useState } from "react";
|
|
7
|
+
import type { ToolData, ToolPeek, AskQuestion, AskAnswers, InputRequest } from "./protocol/types";
|
|
8
|
+
import { Icon } from "./icons";
|
|
9
|
+
import { Markdown } from "./Markdown";
|
|
10
|
+
import { splitMessage } from "../../core/blocks";
|
|
11
|
+
import { withoutBoardContext } from "../../board/context";
|
|
12
|
+
import { clockTime, diffCls, splitAttachments, type MsgAttachment } from "./format";
|
|
13
|
+
import { CONTEXT_OVERFLOW_NOTICE } from "./protocol/promptLimits";
|
|
14
|
+
import { AUTH_EXPIRED_NOTICE } from "./protocol/authFailure";
|
|
15
|
+
import { USAGE_LIMIT_NOTICE } from "./protocol/usageLimit";
|
|
16
|
+
import { APPROVAL_BLOCKED_NOTICE } from "./protocol/approvalFailure";
|
|
17
|
+
import type { Msg } from "./types";
|
|
18
|
+
import { clientFeatures } from "./protocol/features";
|
|
19
|
+
import { Avatar } from "./shared";
|
|
20
|
+
import { toolDataOf } from "./transcriptModel";
|
|
21
|
+
|
|
22
|
+
// The always-visible "peek" tier — Claude Code's `⎿` line. Counts show no
|
|
23
|
+
// content; diffs/snippets show a capped hunk with a clickable "+N more" that
|
|
24
|
+
// opens the full body. TodoWrite renders its checklist inline.
|
|
25
|
+
function PeekView({ peek, expandable, onExpand }: { peek: ToolPeek; expandable: boolean; onExpand: () => void }) {
|
|
26
|
+
const corner = <span className="tcorner">⎿</span>;
|
|
27
|
+
if (peek.kind === "count") {
|
|
28
|
+
return (
|
|
29
|
+
<button className="tpeek tpeek-count" style={{ cursor: expandable ? "pointer" : "default" }} onClick={() => expandable && onExpand()}>
|
|
30
|
+
{corner}<span className="tpeek-txt">{peek.text}</span>
|
|
31
|
+
{expandable && <span className="tpeek-more">展开</span>}
|
|
32
|
+
</button>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
if (peek.kind === "todos") {
|
|
36
|
+
return (
|
|
37
|
+
<div className="tpeek tpeek-todos">
|
|
38
|
+
{peek.items.map((t, i) => (
|
|
39
|
+
<div className={`tdo ${t.status}`} key={i}>
|
|
40
|
+
<span className="tdo-box">{t.status === "completed" ? "✔" : t.status === "in_progress" ? "▣" : "▢"}</span>
|
|
41
|
+
<span className="tdo-txt">{t.text}</span>
|
|
42
|
+
</div>
|
|
43
|
+
))}
|
|
44
|
+
</div>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
if (peek.kind === "diff") {
|
|
48
|
+
return (
|
|
49
|
+
<div className="tpeek tpeek-diff">
|
|
50
|
+
<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>
|
|
51
|
+
<pre className="tpeek-pre diff">{peek.lines.map((l, i) => <div className={`dl ${diffCls(l.sign)}`} key={i}>{l.sign} {l.text}</div>)}</pre>
|
|
52
|
+
{peek.truncated ? <button className="tpeek-more btn-link" onClick={onExpand}>还有 {peek.truncated} 行</button> : null}
|
|
53
|
+
</div>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
// lines (Bash output)
|
|
57
|
+
return (
|
|
58
|
+
<div className="tpeek tpeek-lines">
|
|
59
|
+
{peek.label && <div className="tpeek-sum">{corner}<span className="tpeek-txt">{peek.label}</span></div>}
|
|
60
|
+
<pre className="tpeek-pre">{peek.lines.join("\n") || "(无输出)"}</pre>
|
|
61
|
+
{peek.truncated ? <button className="tpeek-more btn-link" onClick={onExpand}>还有 {peek.truncated} 行</button> : null}
|
|
62
|
+
</div>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function ToolView({ data }: { data: ToolData }) {
|
|
67
|
+
const [open, setOpen] = useState(false);
|
|
68
|
+
const hasDiff = !!data.diff?.length;
|
|
69
|
+
const expandable = !!(data.detail || hasDiff || data.result !== undefined);
|
|
70
|
+
// Failures surface their output automatically, like Claude Code.
|
|
71
|
+
const showBody = open || (!!data.isError && data.result !== undefined);
|
|
72
|
+
return (
|
|
73
|
+
<div className="tool">
|
|
74
|
+
<button className="tool-h" style={{ cursor: expandable ? "pointer" : "default" }} onClick={() => expandable && setOpen((o) => !o)}>
|
|
75
|
+
{expandable && <span className={`tchev ${showBody ? "open" : ""}`}>{Icon.chevRight()}</span>}
|
|
76
|
+
<span className="tg">{data.title}</span>
|
|
77
|
+
{data.result !== undefined && <span className={data.isError ? "tx" : "tcheck"}>{data.isError ? Icon.x() : Icon.check()}</span>}
|
|
78
|
+
</button>
|
|
79
|
+
{data.peek && !showBody && <PeekView peek={data.peek} expandable={expandable} onExpand={() => setOpen(true)} />}
|
|
80
|
+
{showBody && (
|
|
81
|
+
<div className="tool-body">
|
|
82
|
+
{data.detail && <pre className="tool-pre">{data.detail}</pre>}
|
|
83
|
+
{hasDiff && (
|
|
84
|
+
<pre className="tool-pre diff">{data.diff!.map((l, i) => <div className={`dl ${diffCls(l.sign)}`} key={i}>{l.sign} {l.text}</div>)}</pre>
|
|
85
|
+
)}
|
|
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
|
+
// Interactive AskUserQuestion card: option pickers (+ an "Other" free-text per
|
|
99
|
+
// question) while pending; a read-only summary once answered.
|
|
100
|
+
function AskView({ data, agentLabel, onAnswer }: { data: ToolData; agentLabel: string; onAnswer: (answers: AskAnswers) => void }) {
|
|
101
|
+
const questions = data.ask?.questions ?? [];
|
|
102
|
+
const existing = data.ask?.answers;
|
|
103
|
+
const [state, setState] = useState(() => questions.map(() => ({ picked: [] as string[], other: "" })));
|
|
104
|
+
const [submitted, setSubmitted] = useState(false);
|
|
105
|
+
|
|
106
|
+
if (existing) {
|
|
107
|
+
return (
|
|
108
|
+
<div className="ask answered">
|
|
109
|
+
<div className="ask-head">{Icon.spark()} 你的回答</div>
|
|
110
|
+
{questions.map((q, i) => (
|
|
111
|
+
<div className="ask-q" key={i}>
|
|
112
|
+
<div className="ask-qh"><span className="ask-chip">{q.header}</span>{q.question}</div>
|
|
113
|
+
<div className="ask-picked">{(existing[i] ?? []).join("、") || "—"}</div>
|
|
114
|
+
</div>
|
|
115
|
+
))}
|
|
116
|
+
</div>
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const toggle = (qi: number, label: string, multi: boolean) =>
|
|
121
|
+
setState((s) => s.map((st, i) => {
|
|
122
|
+
if (i !== qi) return st;
|
|
123
|
+
if (multi) {
|
|
124
|
+
const has = st.picked.includes(label);
|
|
125
|
+
return { ...st, picked: has ? st.picked.filter((l) => l !== label) : [...st.picked, label] };
|
|
126
|
+
}
|
|
127
|
+
return { picked: [label], other: "" }; // single-select replaces, clears Other
|
|
128
|
+
}));
|
|
129
|
+
const setOther = (qi: number, v: string) =>
|
|
130
|
+
setState((s) => s.map((st, i) => (i === qi ? (questions[i].multiSelect ? { ...st, other: v } : { picked: [], other: v }) : st)));
|
|
131
|
+
|
|
132
|
+
const answers: AskAnswers = state.map((st) => [...st.picked, ...(st.other.trim() ? [st.other.trim()] : [])]);
|
|
133
|
+
const complete = answers.every((a) => a.length > 0);
|
|
134
|
+
const submit = () => { if (complete && !submitted) { setSubmitted(true); onAnswer(answers); } };
|
|
135
|
+
|
|
136
|
+
return (
|
|
137
|
+
<div className="ask">
|
|
138
|
+
<div className="ask-head">{Icon.spark()} {agentLabel} 需要你的输入</div>
|
|
139
|
+
{questions.map((q, i) => (
|
|
140
|
+
<div className="ask-q" key={i}>
|
|
141
|
+
<div className="ask-qh"><span className="ask-chip">{q.header}</span>{q.question}{q.multiSelect && <span className="ask-multi">可多选</span>}</div>
|
|
142
|
+
<div className="ask-opts">
|
|
143
|
+
{q.options.map((o) => (
|
|
144
|
+
<button key={o.label} className={`ask-opt ${state[i].picked.includes(o.label) ? "on" : ""}`} onClick={() => toggle(i, o.label, !!q.multiSelect)} disabled={submitted}>
|
|
145
|
+
<span className="ask-opt-l">{o.label}</span>
|
|
146
|
+
{o.description && <span className="ask-opt-d">{o.description}</span>}
|
|
147
|
+
</button>
|
|
148
|
+
))}
|
|
149
|
+
<input className="ask-other" placeholder="其他…" value={state[i].other} disabled={submitted} onChange={(e) => setOther(i, e.target.value)} />
|
|
150
|
+
</div>
|
|
151
|
+
</div>
|
|
152
|
+
))}
|
|
153
|
+
<div className="ask-foot">
|
|
154
|
+
<button className="btn btn-accent btn-sm" onClick={submit} disabled={!complete || submitted}>{submitted ? "发送中…" : "发送回答"}</button>
|
|
155
|
+
</div>
|
|
156
|
+
</div>
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Ask the session rail to show this task's browser (SessionRail / SessionView listen). */
|
|
161
|
+
export function showTaskBrowser(taskId?: string): void {
|
|
162
|
+
window.dispatchEvent(new CustomEvent("orch:show-browser", {detail:{taskId}}));
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// browser_ask_user card (lib/browserInput.ts): what the agent needs to carry on
|
|
166
|
+
// in the browser — codes, accounts, passwords, choices — typed or picked right
|
|
167
|
+
// here while the browser waits on the page. Passwords are masked and never kept.
|
|
168
|
+
function InputView({ input, agentLabel, onSubmit, onShowBrowser }: { onShowBrowser?:()=>void; input: InputRequest; agentLabel: string; onSubmit?: (id: string, values: string[]) => Promise<string | null> }) {
|
|
169
|
+
const [vals, setVals] = useState<string[]>(() => input.fields.map(() => ""));
|
|
170
|
+
const [shown, setShown] = useState<boolean[]>(() => input.fields.map(() => false));
|
|
171
|
+
const [busy, setBusy] = useState(false);
|
|
172
|
+
const [err, setErr] = useState<string | null>(null);
|
|
173
|
+
const browser = clientFeatures().browser;
|
|
174
|
+
|
|
175
|
+
if (input.status !== "pending") {
|
|
176
|
+
return (
|
|
177
|
+
<div className="ask answered">
|
|
178
|
+
<div className="ask-head">{Icon.lock()} {input.status === "answered" ? "你提供的信息" : "已取消 —— 这一轮被停止了"}</div>
|
|
179
|
+
<div className="ask-q"><div className="ask-qh">{input.message}</div></div>
|
|
180
|
+
{input.status === "answered" && input.fields.map((f, i) => (
|
|
181
|
+
<div className="ask-q" key={i}>
|
|
182
|
+
<div className="ask-qh"><span className="ask-chip">{f.label}</span></div>
|
|
183
|
+
<div className="ask-picked">{f.secret ? "••••••(只交给浏览器脚本,没有保存,也没有发给模型)" : input.values?.[i] ?? "—"}</div>
|
|
184
|
+
</div>
|
|
185
|
+
))}
|
|
186
|
+
</div>
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const set = (i: number, v: string) => setVals((a) => a.map((x, j) => (j === i ? v : x)));
|
|
191
|
+
const complete = vals.every((v) => v.trim());
|
|
192
|
+
const submit = async () => {
|
|
193
|
+
if (!complete || busy || !onSubmit) return;
|
|
194
|
+
setBusy(true);
|
|
195
|
+
setErr(null);
|
|
196
|
+
const e = await onSubmit(input.id, vals.map((v) => v.trim()));
|
|
197
|
+
if (e) { setErr(e); setBusy(false); }
|
|
198
|
+
};
|
|
199
|
+
return (
|
|
200
|
+
<div className="ask">
|
|
201
|
+
<div className="ask-head">{Icon.lock()} {agentLabel} 需要你提供信息 · 浏览器正停在这一页等待</div>
|
|
202
|
+
<div className="ask-q"><div className="ask-qh" style={{ whiteSpace: "pre-wrap" }}>{input.message}</div></div>
|
|
203
|
+
{input.fields.map((f, i) => (
|
|
204
|
+
<div className="ask-q" key={i}>
|
|
205
|
+
<div className="ask-qh"><span className="ask-chip">{f.label}</span>{f.secret && <span className="ask-multi">保密</span>}</div>
|
|
206
|
+
<div className="ask-opts">
|
|
207
|
+
{f.options?.map((o) => (
|
|
208
|
+
<button key={o} className={`ask-opt ${vals[i] === o ? "on" : ""}`} onClick={() => set(i, o)} disabled={busy}>
|
|
209
|
+
<span className="ask-opt-l">{o}</span>
|
|
210
|
+
</button>
|
|
211
|
+
))}
|
|
212
|
+
<div className="input-row">
|
|
213
|
+
<input
|
|
214
|
+
className="ask-other"
|
|
215
|
+
type={f.secret && !shown[i] ? "password" : "text"}
|
|
216
|
+
autoComplete={f.secret ? "new-password" : "off"}
|
|
217
|
+
placeholder={f.options?.length ? "其他…" : f.placeholder || `输入${f.label}`}
|
|
218
|
+
value={f.options?.includes(vals[i]) ? "" : vals[i]}
|
|
219
|
+
disabled={busy}
|
|
220
|
+
autoFocus={i === 0 && !f.options?.length}
|
|
221
|
+
onChange={(e) => set(i, e.target.value)}
|
|
222
|
+
onKeyDown={(e) => { if (e.key === "Enter" && !e.nativeEvent.isComposing) void submit(); }}
|
|
223
|
+
/>
|
|
224
|
+
{f.secret && (
|
|
225
|
+
<button className="btn btn-ghost btn-sm" type="button" onClick={() => setShown((a) => a.map((x, j) => (j === i ? !x : x)))}>
|
|
226
|
+
{shown[i] ? "隐藏" : "显示"}
|
|
227
|
+
</button>
|
|
228
|
+
)}
|
|
229
|
+
</div>
|
|
230
|
+
</div>
|
|
231
|
+
</div>
|
|
232
|
+
))}
|
|
233
|
+
<div className="ask-foot" style={{ gap: 8, alignItems: "center" }}>
|
|
234
|
+
{err && <span className="input-err">{err}</span>}
|
|
235
|
+
{input.fields.some((f) => f.secret) && !err && <span className="input-note">密码只交给浏览器脚本,不保存、不发给模型</span>}
|
|
236
|
+
<span style={{ flex: 1 }} />
|
|
237
|
+
{browser && <button className="btn btn-line btn-sm" onClick={onShowBrowser ?? (()=>showTaskBrowser())}>查看浏览器</button>}
|
|
238
|
+
<button className="btn btn-accent btn-sm" onClick={() => void submit()} disabled={!complete || busy}>{busy ? "发送中…" : "提交"}</button>
|
|
239
|
+
</div>
|
|
240
|
+
</div>
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// The transcript lines browser_handoff / a page prompt write (lib/browserHandoff.ts,
|
|
245
|
+
// lib/browser.ts — both start with 🖐): the user has to act in the page itself. Buttons to open the live browser and to hand
|
|
246
|
+
// it back without hunting for the rail.
|
|
247
|
+
export const HANDOFF_NOTICE_PREFIX = "🖐 ";
|
|
248
|
+
function HandoffLine({ text, onBrowser, onShowBrowser, readOnly }: { readOnly?:boolean; onShowBrowser?:()=>void; text: string; onBrowser?: (action: "handback") => Promise<string | null> }) {
|
|
249
|
+
const [state, setState] = useState<"idle" | "busy" | "done">("idle");
|
|
250
|
+
const [err, setErr] = useState<string | null>(null);
|
|
251
|
+
const browser = clientFeatures().browser;
|
|
252
|
+
return (
|
|
253
|
+
<div className="msg system info">
|
|
254
|
+
<div className="msg-body">
|
|
255
|
+
{text}
|
|
256
|
+
{browser && (
|
|
257
|
+
<div style={{ display: "flex", gap: 8, marginTop: 8, flexWrap: "wrap", alignItems: "center" }}>
|
|
258
|
+
{!readOnly&&<button className="btn btn-line btn-sm" onClick={onShowBrowser ?? (()=>showTaskBrowser())}>查看浏览器</button>}
|
|
259
|
+
{onBrowser && (
|
|
260
|
+
<button className="btn btn-accent btn-sm" disabled={state !== "idle"} onClick={async () => {
|
|
261
|
+
setState("busy");
|
|
262
|
+
const e = await onBrowser("handback");
|
|
263
|
+
if (e) { setErr(e); setState("idle"); } else setState("done");
|
|
264
|
+
}}>{state === "done" ? "已交还" : state === "busy" ? "交还中…" : "我已完成,交还给 AI"}</button>
|
|
265
|
+
)}
|
|
266
|
+
{err && <span className="input-err">{err}</span>}
|
|
267
|
+
</div>
|
|
268
|
+
)}
|
|
269
|
+
</div>
|
|
270
|
+
</div>
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Attachment chips parsed out of a user message's markers: image thumbnails
|
|
275
|
+
// (click opens full size) and text-file chips (a big paste diverted to a file;
|
|
276
|
+
// click opens it). Both are served from the task's uploads dir.
|
|
277
|
+
function AttachmentStrip({ items }: { items: MsgAttachment[] }) {
|
|
278
|
+
if (!items.length) return null;
|
|
279
|
+
return (
|
|
280
|
+
<div className="msg-attachments">
|
|
281
|
+
{items.map((a, i) =>
|
|
282
|
+
a.kind === "image" ? (
|
|
283
|
+
<a key={i} href={a.url} target="_blank" rel="noreferrer" title="查看原图">
|
|
284
|
+
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
285
|
+
<img src={a.url} alt="附件图片" loading="lazy" />
|
|
286
|
+
</a>
|
|
287
|
+
) : (
|
|
288
|
+
<a key={i} href={a.url} target="_blank" rel="noreferrer" className="file-chip" title={`打开 ${a.name}`}>
|
|
289
|
+
{Icon.clip()} <span>附件文件</span>
|
|
290
|
+
</a>
|
|
291
|
+
)
|
|
292
|
+
)}
|
|
293
|
+
</div>
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Memoized: during a live turn every SSE event re-renders the transcript's
|
|
298
|
+
// parents, but message objects are append-only (replaced only when their content
|
|
299
|
+
// changes), so unchanged messages skip re-rendering — and re-parsing their
|
|
300
|
+
// markdown — entirely. Callers must pass identity-stable handlers or the memo
|
|
301
|
+
// is defeated (SessionView wraps its handlers for exactly this reason).
|
|
302
|
+
function AutomaticBoardBackground({content}:{content:string}) {
|
|
303
|
+
const blocks=splitMessage(content).filter(part=>part.kind==='block'&&part.block.kind==='viewing');
|
|
304
|
+
if(!blocks.length)return null;
|
|
305
|
+
return <details className="cli-auto-background"><summary>自动看板背景</summary>{blocks.map((part,index)=>part.kind==='block'?<pre key={index}>{part.block.text}</pre>:null)}</details>;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
export const MessageView = memo(function MessageView({ m, initial, hideWho, running, agent, agentLabel = "智能体", onAnswer, onInput, onBrowser, onCancelQueued, onClear, onReconnect, onRetry, onQuote, readOnly }: { readOnly?: boolean; onQuote?: (m: Msg, selected?: string) => void; m: Msg; initial: boolean; hideWho: boolean; running?: boolean; agent?: string | null; agentLabel?: string; onAnswer?: (askId: string, questions: AskQuestion[], answers: AskAnswers) => void; onInput?: (inputId: string, values: string[]) => Promise<string | null>; onBrowser?: (action: "handback") => Promise<string | null>; onCancelQueued?: (pendingId: string) => void; onClear?: () => void; onReconnect?: () => void; onRetry?: (msgId: string) => void }) {
|
|
309
|
+
const openBoard=useContext(OperatorBoardContext);
|
|
310
|
+
const fileScope=useContext(FileLinkContext);
|
|
311
|
+
const onShowBrowser=()=>{if(fileScope&&openBoard)openBoard({kind:'browser',taskId:fileScope.taskId});else showTaskBrowser(fileScope?.taskId);};
|
|
312
|
+
if (readOnly) { onAnswer=undefined; onInput=undefined; onBrowser=undefined; onRetry=undefined; onCancelQueued=undefined; onClear=undefined; }
|
|
313
|
+
if (m.role === "queued") {
|
|
314
|
+
// A follow-up the user typed mid-turn, waiting its turn. Reads like a user
|
|
315
|
+
// bubble but dimmed, tagged "Queued", with an × to drop it before it runs.
|
|
316
|
+
const { text, attachments } = splitAttachments(withoutBoardContext(m.content));
|
|
317
|
+
return (
|
|
318
|
+
<div className="msg user queued">
|
|
319
|
+
<div className="who"><Avatar who="user" /> 你<span className="badge queued-badge">排队中</span>{m.ts != null && <span className="msg-time">{clockTime(m.ts)}</span>}</div>
|
|
320
|
+
<div className="msg-body">
|
|
321
|
+
{text && <Markdown>{text}</Markdown>}
|
|
322
|
+
<AutomaticBoardBackground content={m.content}/>
|
|
323
|
+
<AttachmentStrip items={attachments} />
|
|
324
|
+
{onCancelQueued && <button className="queued-x" title="从队列中移除" aria-label="从队列中移除" onClick={() => onCancelQueued(m.id)}>{Icon.x()}</button>}
|
|
325
|
+
</div>
|
|
326
|
+
</div>
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
if (m.role === "tool") {
|
|
330
|
+
const data = toolDataOf(m);
|
|
331
|
+
if (data.input) {
|
|
332
|
+
return <div className="msg msg-tool" inert={readOnly||undefined}><InputView input={data.input} agentLabel={agentLabel} onSubmit={onInput} onShowBrowser={onShowBrowser} /></div>;
|
|
333
|
+
}
|
|
334
|
+
if (data.ask) {
|
|
335
|
+
return <div className="msg msg-tool" inert={readOnly||undefined}><AskView data={data} agentLabel={agentLabel} onAnswer={(answers) => onAnswer?.(data.ask?.id || m.toolId || "", data.ask?.questions ?? [], answers)} /></div>;
|
|
336
|
+
}
|
|
337
|
+
return <div className="msg msg-tool"><ToolView data={data} /></div>;
|
|
338
|
+
}
|
|
339
|
+
if (m.role === "system" && m.content.startsWith(HANDOFF_NOTICE_PREFIX)) {
|
|
340
|
+
return <HandoffLine text={m.content} onBrowser={onBrowser} onShowBrowser={onShowBrowser} readOnly={readOnly} />;
|
|
341
|
+
}
|
|
342
|
+
if (m.role === "system") {
|
|
343
|
+
// A context-overflow failure: render the warning line plus a one-click path
|
|
344
|
+
// to /clear, which resets the poisoned session and starts a fresh window
|
|
345
|
+
// (carrying a summary over). The notice string is matched verbatim — it's
|
|
346
|
+
// the durable, reconnect-safe channel written by lib/runner.ts.
|
|
347
|
+
if (m.content.includes(CONTEXT_OVERFLOW_NOTICE)) {
|
|
348
|
+
return (
|
|
349
|
+
<div className="msg system overflow">
|
|
350
|
+
<div className="msg-body">
|
|
351
|
+
{m.content}
|
|
352
|
+
{onClear && (
|
|
353
|
+
<div className="overflow-actions">
|
|
354
|
+
<button className="btn btn-sm" onClick={onClear} disabled={running} title="保存摘要并开启全新的上下文窗口">
|
|
355
|
+
{Icon.clear()} 开启新上下文
|
|
356
|
+
</button>
|
|
357
|
+
</div>
|
|
358
|
+
)}
|
|
359
|
+
</div>
|
|
360
|
+
</div>
|
|
361
|
+
);
|
|
362
|
+
}
|
|
363
|
+
// The agent's login died: same shape as the overflow case — the warning line
|
|
364
|
+
// plus the one action that fixes it (Settings → Agents, where the connect
|
|
365
|
+
// flow lives). Instance-wide, so the titlebar banner says it too; this is
|
|
366
|
+
// the in-context copy for whoever is reading the failed task.
|
|
367
|
+
if (m.content.includes(AUTH_EXPIRED_NOTICE)) {
|
|
368
|
+
return (
|
|
369
|
+
<div className="msg system overflow">
|
|
370
|
+
<div className="msg-body">
|
|
371
|
+
{m.content}
|
|
372
|
+
{onReconnect && (
|
|
373
|
+
<div className="overflow-actions">
|
|
374
|
+
<button className="btn btn-sm" onClick={onReconnect} title={`重新登录 ${agentLabel}`}>
|
|
375
|
+
{Icon.bolt()} 重新连接 {agentLabel}
|
|
376
|
+
</button>
|
|
377
|
+
</div>
|
|
378
|
+
)}
|
|
379
|
+
</div>
|
|
380
|
+
</div>
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
// The agent's usage limit is spent: same shape as the two cases above, but
|
|
384
|
+
// the only recovery is waiting for the reset (the error line above the
|
|
385
|
+
// notice carries the reset time when the SDK reported one), so this renders
|
|
386
|
+
// the notice styled like the others with no action button.
|
|
387
|
+
if (m.content.includes(USAGE_LIMIT_NOTICE)) {
|
|
388
|
+
return (
|
|
389
|
+
<div className="msg system overflow">
|
|
390
|
+
<div className="msg-body">{m.content}</div>
|
|
391
|
+
</div>
|
|
392
|
+
);
|
|
393
|
+
}
|
|
394
|
+
// The approval policy blocked the turn (enterprise-managed Codex downgraded
|
|
395
|
+
// the driver's "never" to an approval-requiring policy that exec mode can't
|
|
396
|
+
// service): same shape as the cases above, with a Retry button — the driver
|
|
397
|
+
// already switched future turns to the compatible "on-request" policy, so
|
|
398
|
+
// resending the failed message is the recovery (see lib/approvalFailure.ts).
|
|
399
|
+
if (m.content.includes(APPROVAL_BLOCKED_NOTICE)) {
|
|
400
|
+
return (
|
|
401
|
+
<div className="msg system overflow">
|
|
402
|
+
<div className="msg-body">
|
|
403
|
+
{m.content}
|
|
404
|
+
{onRetry && (
|
|
405
|
+
<div className="overflow-actions">
|
|
406
|
+
<button className="btn btn-sm" onClick={() => onRetry(m.id)} disabled={running} title="重新发送失败的消息">
|
|
407
|
+
{Icon.bolt()} 重试
|
|
408
|
+
</button>
|
|
409
|
+
</div>
|
|
410
|
+
)}
|
|
411
|
+
</div>
|
|
412
|
+
</div>
|
|
413
|
+
);
|
|
414
|
+
}
|
|
415
|
+
// Notes that already carry their own glyph (✓/ℹ/⇄ — e.g. the "caught up to main"
|
|
416
|
+
// sync note) render quietly; anything else is a warning. The runner's error
|
|
417
|
+
// lines arrive with their own ⚠ already, so only glyph-less content gets one
|
|
418
|
+
// (prepending unconditionally used to render "⚠ ⚠ …").
|
|
419
|
+
const info = /^[✓ℹ⇄]/.test(m.content);
|
|
420
|
+
const glyphed = info || m.content.startsWith("⚠");
|
|
421
|
+
return <div className={`msg system${info ? " info" : ""}`}><div className="msg-body">{glyphed ? m.content : `⚠ ${m.content}`}</div></div>;
|
|
422
|
+
}
|
|
423
|
+
const isUser = m.role === "user";
|
|
424
|
+
// Only user messages carry attachment markers; assistant text passes through.
|
|
425
|
+
const { text, attachments } = isUser ? splitAttachments(withoutBoardContext(m.content)) : { text: m.content, attachments: [] };
|
|
426
|
+
return (
|
|
427
|
+
<div className={`msg ${isUser ? "user" : "assistant"} ${initial ? "initial" : ""}`}>
|
|
428
|
+
{!hideWho && (
|
|
429
|
+
<div className="who">
|
|
430
|
+
<Avatar who={isUser ? "user" : "cc"} agent={agent} />
|
|
431
|
+
{isUser ? "你" : "智能体"}
|
|
432
|
+
{initial && <span className="badge">初始提示</span>}
|
|
433
|
+
{m.ts != null && <span className="msg-time">{clockTime(m.ts)}</span>}
|
|
434
|
+
</div>
|
|
435
|
+
)}
|
|
436
|
+
<div className="msg-body">
|
|
437
|
+
{initial && <div className="initial-tag">{Icon.spark()} 已随项目上下文发送</div>}
|
|
438
|
+
{text && <Markdown>{text}</Markdown>}
|
|
439
|
+
{isUser&&<AutomaticBoardBackground content={m.content}/>}
|
|
440
|
+
<AttachmentStrip items={attachments} />
|
|
441
|
+
</div>
|
|
442
|
+
{onQuote && text.trim() && <div className="msg-actions"><button title="引用这条消息(选中文字可仅引用选中部分)" aria-label="引用消息" onMouseDown={(e) => e.preventDefault()} onClick={(e) => {
|
|
443
|
+
const selection = window.getSelection();
|
|
444
|
+
const body = e.currentTarget.closest(".msg")?.querySelector(".msg-body");
|
|
445
|
+
const selected = selection && body?.contains(selection.anchorNode) && body.contains(selection.focusNode) ? selection.toString().trim() : "";
|
|
446
|
+
onQuote(isUser?{...m,content:withoutBoardContext(m.content)}:m, selected || undefined);
|
|
447
|
+
}}>{Icon.doc()} 引用</button></div>}
|
|
448
|
+
</div>
|
|
449
|
+
);
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
export function SessionBreak({ summary }: { summary: string }) {
|
|
453
|
+
return (
|
|
454
|
+
<div className="sbreak">
|
|
455
|
+
<span className="ln" />
|
|
456
|
+
<div className="card">
|
|
457
|
+
<div className="cl">{Icon.clear()} 上下文已清空 · 摘要已保存</div>
|
|
458
|
+
<div className="ct">{summary}</div>
|
|
459
|
+
</div>
|
|
460
|
+
<span className="ln" />
|
|
461
|
+
</div>
|
|
462
|
+
);
|
|
463
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useMemo, useState } from "react";
|
|
4
|
+
import { Markdown } from "./Markdown";
|
|
5
|
+
import { FileLinkContext, taskFileApiUrl } from "./fileLinks";
|
|
6
|
+
import { loadPersist } from "./persist";
|
|
7
|
+
|
|
8
|
+
// Markdown 预览页:/view?task=<任务 id>&path=<文件路径>
|
|
9
|
+
//
|
|
10
|
+
// 智能体在对话里给出的 .md 报告从这里打开 —— 渲染成和对话里一样的排版,
|
|
11
|
+
// 文中指向其他文件的相对链接 / 图片按这个 md 所在的目录解析。原文和下载
|
|
12
|
+
// 走同一个文件接口(app/api/tasks/[id]/files)。
|
|
13
|
+
export default function ViewFile() {
|
|
14
|
+
const [theme,setTheme]=useState("dark");
|
|
15
|
+
const [q, setQ] = useState<{ task: string; path: string } | null>(null);
|
|
16
|
+
// Where the server actually found the file (it may fall back to another
|
|
17
|
+
// folder): relative links and images in the document resolve from there.
|
|
18
|
+
const [real, setReal] = useState<string | null>(null);
|
|
19
|
+
const [text, setText] = useState<string | null>(null);
|
|
20
|
+
const [error, setError] = useState<string | null>(null);
|
|
21
|
+
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
const p = loadPersist();
|
|
24
|
+
setTheme(p.appearance?.theme??"dark");
|
|
25
|
+
if (p.appearance?.theme) document.documentElement.setAttribute("data-theme", p.appearance.theme);
|
|
26
|
+
const sp = new URLSearchParams(window.location.search);
|
|
27
|
+
const task = sp.get("task") ?? "";
|
|
28
|
+
const path = sp.get("path") ?? "";
|
|
29
|
+
if (!task || !path) {
|
|
30
|
+
setError("链接缺少任务或文件路径。");
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
setQ({ task, path });
|
|
34
|
+
let alive = true;
|
|
35
|
+
fetch(taskFileApiUrl(task, path))
|
|
36
|
+
.then(async (res) => {
|
|
37
|
+
if (!res.ok) {
|
|
38
|
+
const j = await res.json().catch(() => null);
|
|
39
|
+
throw new Error(j?.error || `打开失败(${res.status})`);
|
|
40
|
+
}
|
|
41
|
+
const at = res.headers.get("x-orch-file-path");
|
|
42
|
+
if (at && alive) { try { setReal(decodeURIComponent(at)); } catch { /* keep the asked path */ } }
|
|
43
|
+
return res.text();
|
|
44
|
+
})
|
|
45
|
+
.then((t) => { if (alive) setText(t); })
|
|
46
|
+
.catch((e: unknown) => { if (alive) setError(e instanceof Error ? e.message : String(e)); });
|
|
47
|
+
return () => { alive = false; };
|
|
48
|
+
}, []);
|
|
49
|
+
|
|
50
|
+
const scope = useMemo(() => {
|
|
51
|
+
if (!q) return null;
|
|
52
|
+
const p = real ?? q.path;
|
|
53
|
+
const cut = p.lastIndexOf("/");
|
|
54
|
+
return { taskId: q.task, baseDir: cut > 0 ? p.slice(0, cut) : cut === 0 ? "/" : "" };
|
|
55
|
+
}, [q, real]);
|
|
56
|
+
const name = q ? q.path.split("/").pop() || q.path : "";
|
|
57
|
+
const raw = q ? taskFileApiUrl(q.task, q.path) : "";
|
|
58
|
+
|
|
59
|
+
return (
|
|
60
|
+
<div className="cli-ui" data-theme={theme} style={{ position: "fixed", inset: 0, overflow: "auto", background: "var(--bg)", color: "var(--ink)" }}>
|
|
61
|
+
<div style={{ position: "sticky", top: 0, zIndex: 1, display: "flex", alignItems: "center", gap: 10, padding: "10px 20px", borderBottom: "1px solid var(--line)", background: "var(--chrome)" }}>
|
|
62
|
+
<span style={{ fontFamily: "var(--mono)", fontSize: 13, fontWeight: 600, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", flex: 1, minWidth: 0 }} title={q?.path}>{name}</span>
|
|
63
|
+
{q && <a className="btn btn-line btn-sm" href={raw} target="_blank" rel="noreferrer">原文</a>}
|
|
64
|
+
{q && <a className="btn btn-line btn-sm" href={`${raw}${raw.includes("?") ? "&" : "?"}download=1`}>下载</a>}
|
|
65
|
+
</div>
|
|
66
|
+
<div style={{ maxWidth: 880, margin: "0 auto", padding: "24px 20px 64px", fontSize: 14, lineHeight: 1.7 }}>
|
|
67
|
+
{error ? (
|
|
68
|
+
<div style={{ color: "var(--red)" }}>{error}</div>
|
|
69
|
+
) : text === null ? (
|
|
70
|
+
<div style={{ color: "var(--ink-3)" }}>加载中…</div>
|
|
71
|
+
) : (
|
|
72
|
+
<FileLinkContext.Provider value={scope}>
|
|
73
|
+
<Markdown>{text}</Markdown>
|
|
74
|
+
</FileLinkContext.Provider>
|
|
75
|
+
)}
|
|
76
|
+
</div>
|
|
77
|
+
</div>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { Icon } from "./icons";
|
|
5
|
+
import { Modal } from "./Modal";
|
|
6
|
+
|
|
7
|
+
// Coach marks + the post-merge nudge for the built-in "Welcome" tutorial project
|
|
8
|
+
// (projects.seeded = 1). These carry the "how Operator works" teaching that we
|
|
9
|
+
// deliberately keep OUT of the Claude-facing project context — so the concepts
|
|
10
|
+
// live where the user reads them, not where the agent does.
|
|
11
|
+
|
|
12
|
+
const COACH_KEY = "orch:welcomeCoach:dismissed";
|
|
13
|
+
|
|
14
|
+
// A slim, dismissible bar above the tutorial session. Names the three-column loop
|
|
15
|
+
// and the three beats of the task the user is about to run.
|
|
16
|
+
export function WelcomeCoach() {
|
|
17
|
+
const [gone, setGone] = useState<boolean>(() => {
|
|
18
|
+
try { return localStorage.getItem(COACH_KEY) === "1"; } catch { return false; }
|
|
19
|
+
});
|
|
20
|
+
if (gone) return null;
|
|
21
|
+
const dismiss = () => {
|
|
22
|
+
try { localStorage.setItem(COACH_KEY, "1"); } catch {}
|
|
23
|
+
setGone(true);
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const steps: [string, string][] = [
|
|
27
|
+
["开始", "点击「开始」,实时看着 Claude 流式输出它的工具调用。"],
|
|
28
|
+
["回答", "它会停下来问你一个小问题 —— 选一个选项即可。"],
|
|
29
|
+
["审阅并合并", "打开「改动」,看一遍 diff,然后一键合并。"],
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<div className="wcoach">
|
|
34
|
+
<div className="wcoach-head">
|
|
35
|
+
<span className="wcoach-ic">{Icon.bolt()}</span>
|
|
36
|
+
<div className="wcoach-title">欢迎 —— 这是一个 2 分钟的实操导览</div>
|
|
37
|
+
<button className="wcoach-x" onClick={dismiss} title="关闭">{Icon.x()}</button>
|
|
38
|
+
</div>
|
|
39
|
+
<div className="wcoach-sub">
|
|
40
|
+
三栏布局:<strong>项目</strong>(左)· 本项目的<strong>任务</strong> · <strong>实时会话</strong>(右)。
|
|
41
|
+
运行下面的「试试看」任务,完整体验一遍流程。
|
|
42
|
+
</div>
|
|
43
|
+
<div className="wcoach-steps">
|
|
44
|
+
{steps.map(([label, body], i) => (
|
|
45
|
+
<div className="wcoach-step" key={label}>
|
|
46
|
+
<span className="wcoach-num">{i + 1}</span>
|
|
47
|
+
<div>
|
|
48
|
+
<div className="wcoach-step-t">{label}</div>
|
|
49
|
+
<div className="wcoach-step-b">{body}</div>
|
|
50
|
+
</div>
|
|
51
|
+
</div>
|
|
52
|
+
))}
|
|
53
|
+
</div>
|
|
54
|
+
</div>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Fires once the tutorial task is merged: the aha moment landed, so this is the
|
|
59
|
+
// natural place to point the user at their own code (and, since we dropped the
|
|
60
|
+
// notifications step from the wizard, to offer the browser-notification opt-in).
|
|
61
|
+
export function WelcomeNudge({ onCreateProject, onClose }: { onCreateProject: () => void; onClose: () => void }) {
|
|
62
|
+
const notifSupported = typeof window !== "undefined" && "Notification" in window;
|
|
63
|
+
const [perm, setPerm] = useState<NotificationPermission | "unsupported">(
|
|
64
|
+
notifSupported ? Notification.permission : "unsupported"
|
|
65
|
+
);
|
|
66
|
+
const enableNotifs = async () => {
|
|
67
|
+
if (!notifSupported) return;
|
|
68
|
+
try { setPerm(await Notification.requestPermission()); } catch {}
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<Modal
|
|
73
|
+
title="你刚刚跑通了整个流程 🎉"
|
|
74
|
+
sub="流式输出 · 一个提问 · 一份 diff · 一次合并 —— 全都在你自己的工作区里完成。"
|
|
75
|
+
onClose={onClose}
|
|
76
|
+
width={520}
|
|
77
|
+
footer={<>
|
|
78
|
+
<span className="spacer" />
|
|
79
|
+
<button className="btn btn-ghost" onClick={onClose}>继续探索</button>
|
|
80
|
+
<button className="btn btn-accent" onClick={onCreateProject}>{Icon.plus()} 创建项目</button>
|
|
81
|
+
</>}
|
|
82
|
+
>
|
|
83
|
+
<p style={{ margin: "0 0 14px", color: "var(--ink-2)", lineHeight: 1.55 }}>
|
|
84
|
+
这就是 Operator 的核心:每个任务都是独立的智能体会话,跑在各自的 git worktree 里,
|
|
85
|
+
因此审阅与合并完全由你决定。准备好把它对准你自己的代码了吗?这个「欢迎」项目
|
|
86
|
+
随时可以删除。
|
|
87
|
+
</p>
|
|
88
|
+
{perm === "default" && notifSupported && (
|
|
89
|
+
<button className="btn btn-line" onClick={enableNotifs} style={{ alignSelf: "flex-start" }}>
|
|
90
|
+
{Icon.spark()} 任务需要我时通知我
|
|
91
|
+
</button>
|
|
92
|
+
)}
|
|
93
|
+
{perm === "granted" && (
|
|
94
|
+
<div className="hlp" style={{ margin: 0, display: "flex", alignItems: "center", gap: 6 }}>
|
|
95
|
+
<span style={{ color: "var(--green, var(--accent))", display: "inline-flex" }}>{Icon.check()}</span>
|
|
96
|
+
通知已开启 —— 会话运行时你可以放心离开。
|
|
97
|
+
</div>
|
|
98
|
+
)}
|
|
99
|
+
</Modal>
|
|
100
|
+
);
|
|
101
|
+
}
|