@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,720 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
4
|
+
import type { Priority } from "./protocol/types";
|
|
5
|
+
import { Icon } from "./icons";
|
|
6
|
+
import { jget, jsend } from "./api";
|
|
7
|
+
import { relTime, duration, fmtJobCost } from "./format";
|
|
8
|
+
import { type ProjectRow, type ProjectSession, type TaskRow, type AgentsBundle, type InternalUsageEstimate } from "./types";
|
|
9
|
+
import { agentLabel, agentStateHint, defaultAgentFor, findAgent } from "./agents";
|
|
10
|
+
import { StatusDot, Skel, ErrNote } from "./shared";
|
|
11
|
+
import { Modal, BrowseDirButton, PrioritySeg, DepPicker } from "./Modal";
|
|
12
|
+
import { GitHubClonePicker } from "./github";
|
|
13
|
+
import { Markdown } from "./Markdown";
|
|
14
|
+
import { clientFeatures } from "./protocol/features";
|
|
15
|
+
|
|
16
|
+
// One-click copy, with the button itself confirming. Used for the project id and
|
|
17
|
+
// for the ready-to-run curl snippets below it.
|
|
18
|
+
function useCopy(): [string, (key: string, text: string) => void] {
|
|
19
|
+
const [copied, setCopied] = useState("");
|
|
20
|
+
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
21
|
+
useEffect(() => () => { if (timer.current) clearTimeout(timer.current); }, []);
|
|
22
|
+
const copy = (key: string, text: string) => {
|
|
23
|
+
navigator.clipboard?.writeText(text).then(() => {
|
|
24
|
+
setCopied(key);
|
|
25
|
+
if (timer.current) clearTimeout(timer.current);
|
|
26
|
+
timer.current = setTimeout(() => setCopied(""), 1500);
|
|
27
|
+
}).catch(() => { /* clipboard blocked */ });
|
|
28
|
+
};
|
|
29
|
+
return [copied, copy];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// The project id, copyable, sitting next to the modal's heading. The id is what
|
|
33
|
+
// every /api/projects/{id} call needs, and it is otherwise only visible in the
|
|
34
|
+
// URL — so surfacing it here saves a trip to the address bar.
|
|
35
|
+
function CopyIdChip({ id }: { id: string }) {
|
|
36
|
+
const [copied, copy] = useCopy();
|
|
37
|
+
return (
|
|
38
|
+
<button
|
|
39
|
+
className="copy-id"
|
|
40
|
+
onClick={() => copy("id", id)}
|
|
41
|
+
title={copied ? "已复制" : `复制项目 ID:${id}`}
|
|
42
|
+
aria-label={`复制项目 ID ${id}`}
|
|
43
|
+
>
|
|
44
|
+
<span className="copy-id-v">{id}</span>
|
|
45
|
+
{copied ? Icon.check() : Icon.copy()}
|
|
46
|
+
</button>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Ready-to-run API calls for THIS project, with its id already filled in. The
|
|
51
|
+
// four cover the whole loop the REST API exists for — create a task, start its
|
|
52
|
+
// turn, watch the stream, read the diff — so the modal doubles as the place you
|
|
53
|
+
// grab a working command instead of assembling one from the docs.
|
|
54
|
+
function ProjectApiSnippets({ projectId }: { projectId: string }) {
|
|
55
|
+
const [copied, copy] = useCopy();
|
|
56
|
+
// Three-state: null until /whoami answers. Rendering "token off" while the
|
|
57
|
+
// fetch is still in flight would flash a claim we haven't verified.
|
|
58
|
+
const [tokenOn, setTokenOn] = useState<boolean | null>(null);
|
|
59
|
+
const [open, setOpen] = useState(false);
|
|
60
|
+
useEffect(() => {
|
|
61
|
+
let alive = true;
|
|
62
|
+
jget<{ apiToken?: boolean }>("/api/auth/whoami")
|
|
63
|
+
.then((d) => { if (alive) setTokenOn(!!d.apiToken); })
|
|
64
|
+
.catch(() => {});
|
|
65
|
+
return () => { alive = false; };
|
|
66
|
+
}, []);
|
|
67
|
+
|
|
68
|
+
const base = typeof window !== "undefined" ? window.location.origin : "";
|
|
69
|
+
// Only shown when the instance actually gates on a token, so the copied
|
|
70
|
+
// command matches what this box will accept.
|
|
71
|
+
const auth = tokenOn ? ` \\\n -H 'Authorization: Bearer <你的令牌>'` : "";
|
|
72
|
+
const snippets: { key: string; label: string; cmd: string }[] = [
|
|
73
|
+
{
|
|
74
|
+
key: "create",
|
|
75
|
+
label: "创建任务",
|
|
76
|
+
cmd: `curl -s -X POST ${base}/api/tasks \\\n -H 'content-type: application/json'${auth} \\\n -d '{"project_id":"${projectId}","title":"任务标题","description":"要做什么"}'`,
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
key: "start",
|
|
80
|
+
label: "启动首轮",
|
|
81
|
+
cmd: `curl -s -X POST ${base}/api/tasks/<任务ID>/messages \\\n -H 'content-type: application/json'${auth} \\\n -d '{"text":""}'`,
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
key: "watch",
|
|
85
|
+
label: "监听输出(SSE)",
|
|
86
|
+
cmd: `curl -N ${base}/api/tasks/<任务ID>/messages${auth}`,
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
key: "diff",
|
|
90
|
+
label: "查看改动",
|
|
91
|
+
cmd: `curl -s ${base}/api/tasks/<任务ID>/diff${auth}`,
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
key: "list",
|
|
95
|
+
label: "列出本项目的任务",
|
|
96
|
+
cmd: `curl -s ${base}/api/projects/${projectId}${auth}`,
|
|
97
|
+
},
|
|
98
|
+
];
|
|
99
|
+
|
|
100
|
+
return (
|
|
101
|
+
<div className="field" style={{ marginBottom: 0 }}>
|
|
102
|
+
<button className="api-snip-head" onClick={() => setOpen((o) => !o)} aria-expanded={open}>
|
|
103
|
+
<span className={`api-snip-chev ${open ? "open" : ""}`}>{Icon.chevRight()}</span>
|
|
104
|
+
{Icon.terminal()}
|
|
105
|
+
<span className="api-snip-t">用接口调用这个项目</span>
|
|
106
|
+
<span className="opt">— curl,项目 ID 已填好</span>
|
|
107
|
+
</button>
|
|
108
|
+
{open && (
|
|
109
|
+
<div className="api-snips">
|
|
110
|
+
{/* Why the Authorization line is (or isn't) in the commands above. Without
|
|
111
|
+
this, an absent header reads as an omission rather than a setting. */}
|
|
112
|
+
{tokenOn === true && (
|
|
113
|
+
<div className="api-snip-auth on">
|
|
114
|
+
{Icon.lock()}
|
|
115
|
+
{/* One span, not loose nodes: the row is a flex container, so any
|
|
116
|
+
bare text or <code> beside the icon would become its own flex
|
|
117
|
+
item and the sentence would render as separate columns. */}
|
|
118
|
+
<span>
|
|
119
|
+
本实例已启用接口令牌 —— 命令中已加上认证头,把 <code><你的令牌></code> 换成实际的值。
|
|
120
|
+
</span>
|
|
121
|
+
</div>
|
|
122
|
+
)}
|
|
123
|
+
{tokenOn === false && (
|
|
124
|
+
<div className="api-snip-auth">
|
|
125
|
+
{Icon.lock()}
|
|
126
|
+
<span>
|
|
127
|
+
本实例未启用接口令牌 —— 只有本机能调用,无需认证头。
|
|
128
|
+
在「设置 → 接口访问」启用后,这里会自动带上 <code>Authorization</code> 头。
|
|
129
|
+
</span>
|
|
130
|
+
</div>
|
|
131
|
+
)}
|
|
132
|
+
{snippets.map((s) => (
|
|
133
|
+
<div className="api-snip" key={s.key}>
|
|
134
|
+
<div className="api-snip-l">
|
|
135
|
+
<span>{s.label}</span>
|
|
136
|
+
<button className="btn btn-ghost btn-sm" onClick={() => copy(s.key, s.cmd)}>
|
|
137
|
+
{copied === s.key ? Icon.check() : Icon.copy()} {copied === s.key ? "已复制" : "复制"}
|
|
138
|
+
</button>
|
|
139
|
+
</div>
|
|
140
|
+
<pre className="api-snip-pre ctx-mono">{s.cmd}</pre>
|
|
141
|
+
</div>
|
|
142
|
+
))}
|
|
143
|
+
<div className="hlp" style={{ marginTop: 2 }}>
|
|
144
|
+
把 <code><任务ID></code> 换成「创建任务」返回的 <code>id</code>。首轮的 <code>text</code> 会被忽略
|
|
145
|
+
—— 智能体收到的是任务的标题和描述。
|
|
146
|
+
</div>
|
|
147
|
+
</div>
|
|
148
|
+
)}
|
|
149
|
+
</div>
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Segmented agent picker (Claude Code / Codex …). Hidden when only one agent is
|
|
154
|
+
// registered — nothing to choose. An agent that can't run yet is still
|
|
155
|
+
// selectable (you can create a not-started task and connect later) but flagged
|
|
156
|
+
// with WHY: "未连接" gets a Connect CTA into the setup wizard, "未安装" says the
|
|
157
|
+
// CLI is missing on the server, where connecting again would fix nothing.
|
|
158
|
+
export function AgentPicker({ agents, value, onChange, onConnect, help, label = "智能体" }: {
|
|
159
|
+
agents: AgentsBundle; value: string; onChange: (id: string) => void; onConnect?: () => void; help?: string; label?: string;
|
|
160
|
+
}) {
|
|
161
|
+
if (agents.agents.length <= 1) return null;
|
|
162
|
+
const sel = findAgent(agents, value);
|
|
163
|
+
const selHint = agentStateHint(sel);
|
|
164
|
+
return (
|
|
165
|
+
<div className="field">
|
|
166
|
+
<div className="lab">{label}</div>
|
|
167
|
+
<div className="seg" style={{ flexWrap: "wrap" }}>
|
|
168
|
+
{agents.agents.map((a) => {
|
|
169
|
+
const hint = agentStateHint(a);
|
|
170
|
+
return (
|
|
171
|
+
<button key={a.id} className={a.id === value ? "on" : ""} onClick={() => onChange(a.id)}
|
|
172
|
+
title={hint ? `${a.label} ${hint === "未安装" ? "的命令行工具未安装在服务器上" : "尚未连接"}` : `使用 ${a.label} 运行`}>
|
|
173
|
+
{a.label}{hint && <span className="opt"> · {hint}</span>}
|
|
174
|
+
</button>
|
|
175
|
+
);
|
|
176
|
+
})}
|
|
177
|
+
</div>
|
|
178
|
+
{selHint ? (
|
|
179
|
+
<div className="hlp" style={{ color: "var(--amber)", display: "flex", alignItems: "center", gap: 10, flexWrap: "wrap" }}>
|
|
180
|
+
<span>
|
|
181
|
+
{selHint === "未安装"
|
|
182
|
+
? `${sel!.label} 的命令行工具不在服务器上 —— 请先安装(或设置 CLAUDE_CLI_PATH/CODEX_CLI_PATH)再启动会话。`
|
|
183
|
+
: `${sel!.label} 尚未连接 —— 请先连接再启动会话。`}
|
|
184
|
+
</span>
|
|
185
|
+
{selHint === "未连接" && onConnect && <button className="btn btn-line btn-sm" onClick={onConnect}>连接 {sel!.label}</button>}
|
|
186
|
+
</div>
|
|
187
|
+
) : (
|
|
188
|
+
<div className="hlp">{help ?? "任务开始后也可以随时切换(在对话框底部或这里)。"}</div>
|
|
189
|
+
)}
|
|
190
|
+
</div>
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function NewTaskModal({ project, agents, tasks, onClose, onCreate, onOpenSetup }: { project: ProjectRow; agents: AgentsBundle; tasks: TaskRow[]; onClose: () => void; onCreate: (i: { title: string; desc: string; priority: Priority; agent: string; startNow: boolean; sendContext: boolean; depends_on: string[]; auto_start: boolean; browser: boolean }) => void; onOpenSetup?: () => void }) {
|
|
195
|
+
const [title, setTitle] = useState("");
|
|
196
|
+
const [desc, setDesc] = useState("");
|
|
197
|
+
const [priority, setPriority] = useState<Priority>("med");
|
|
198
|
+
const [agent, setAgent] = useState(() => defaultAgentFor(agents, project.default_agent));
|
|
199
|
+
const [startNow, setStartNow] = useState(false);
|
|
200
|
+
const [sendContext, setSendContext] = useState(project.send_context !== 0);
|
|
201
|
+
const [deps, setDeps] = useState<string[]>([]);
|
|
202
|
+
const [autoStart, setAutoStart] = useState(false);
|
|
203
|
+
// The task's real browser (docs/BROWSER.md): offered only while the feature is on.
|
|
204
|
+
const showBrowser = clientFeatures().browser;
|
|
205
|
+
const [browser, setBrowser] = useState(false);
|
|
206
|
+
const ref = useRef<HTMLInputElement>(null);
|
|
207
|
+
useEffect(() => { ref.current?.focus(); }, []);
|
|
208
|
+
// The bundle can arrive after mount; adopt the resolved default until the user picks.
|
|
209
|
+
const touched = useRef(false);
|
|
210
|
+
useEffect(() => { if (!touched.current) setAgent(defaultAgentFor(agents, project.default_agent)); }, [agents, project.default_agent]);
|
|
211
|
+
const pickAgent = (id: string) => { touched.current = true; setAgent(id); };
|
|
212
|
+
const can = title.trim().length > 0;
|
|
213
|
+
// A task with unfinished blockers can't start now, so the two options are exclusive.
|
|
214
|
+
const blocked = deps.some((id) => tasks.find((t) => t.id === id)?.status !== "done");
|
|
215
|
+
// Can't launch a session on an agent that isn't signed in — but the task can
|
|
216
|
+
// still be created (not started) and started once the agent is connected.
|
|
217
|
+
const selAgent = findAgent(agents, agent);
|
|
218
|
+
// Ready = connected AND installed on the server (POST /messages refuses the
|
|
219
|
+
// launch otherwise, so offering "start now" here would just produce a 400).
|
|
220
|
+
const agentReady = selAgent ? !agentStateHint(selAgent) : true;
|
|
221
|
+
const canStart = !blocked && agentReady;
|
|
222
|
+
const create = () => can && onCreate({ title: title.trim(), desc: desc.trim(), priority, agent, startNow: startNow && canStart, sendContext, depends_on: deps, auto_start: autoStart && deps.length > 0, browser: showBrowser && browser });
|
|
223
|
+
return (
|
|
224
|
+
<Modal title="新建任务" sub={`${project.name} · 标题与描述构成 ${agentLabel(agents, agent)} 的任务上下文`} onClose={onClose}
|
|
225
|
+
footer={<>
|
|
226
|
+
<label style={{ display: "flex", alignItems: "center", gap: 8, fontSize: 12.5, color: !canStart ? "var(--ink-4)" : "var(--ink-2)", cursor: !canStart ? "not-allowed" : "pointer" }}
|
|
227
|
+
title={blocked ? "暂时无法开始 —— 该任务被尚未完成的任务阻塞" : !agentReady ? `${selAgent?.label} ${agentStateHint(selAgent) === "未安装" ? "的命令行工具未安装在服务器上" : "尚未连接"},无法启动会话` : undefined}>
|
|
228
|
+
<input type="checkbox" checked={startNow && canStart} disabled={!canStart} onChange={(e) => setStartNow(e.target.checked)} /> 立即开始会话
|
|
229
|
+
</label>
|
|
230
|
+
<span className="spacer" />
|
|
231
|
+
<button className="btn btn-ghost" onClick={onClose}>取消</button>
|
|
232
|
+
<button className="btn btn-accent" disabled={!can} onClick={create}>{Icon.plus()} 创建任务</button>
|
|
233
|
+
</>}>
|
|
234
|
+
<div className="field">
|
|
235
|
+
<div className="lab">标题</div>
|
|
236
|
+
<input ref={ref} type="text" value={title} placeholder="例如:为认证接口加上限流"
|
|
237
|
+
onChange={(e) => setTitle(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter" && can) create(); }} />
|
|
238
|
+
</div>
|
|
239
|
+
<div className="field">
|
|
240
|
+
<div className="lab">描述 <span className="opt">— 要做什么</span></div>
|
|
241
|
+
<textarea value={desc} placeholder="描述这个功能或任务。智能体会在注入的任务上下文中收到这段内容。" onChange={(e) => setDesc(e.target.value)} />
|
|
242
|
+
{sendContext && <div className="hlp">项目上下文会自动前置 —— 无需重复说明技术栈或规范。</div>}
|
|
243
|
+
<label style={{ display: "flex", alignItems: "center", gap: 8, marginTop: 8, fontSize: 12.5, color: "var(--ink-2)", cursor: "pointer" }}
|
|
244
|
+
title="取消勾选后,该任务的会话不会携带已保存的项目上下文。任务详情与编排工具始终包含在内。">
|
|
245
|
+
<input type="checkbox" checked={sendContext} onChange={(e) => setSendContext(e.target.checked)} />
|
|
246
|
+
向智能体发送已保存的项目上下文
|
|
247
|
+
</label>
|
|
248
|
+
{showBrowser && (
|
|
249
|
+
<label style={{ display: "flex", alignItems: "center", gap: 8, marginTop: 6, fontSize: 12.5, color: "var(--ink-2)", cursor: "pointer" }}
|
|
250
|
+
title="智能体可以用这台节点上的真实浏览器(ego lite)打开网页、填写表单、抓取数据;你可以在任务右侧「浏览器」面板实时查看和接管。">
|
|
251
|
+
<input type="checkbox" checked={browser} onChange={(e) => setBrowser(e.target.checked)} />
|
|
252
|
+
使用浏览器(ego lite)
|
|
253
|
+
</label>
|
|
254
|
+
)}
|
|
255
|
+
</div>
|
|
256
|
+
<AgentPicker agents={agents} value={agent} onChange={pickAgent} onConnect={onOpenSetup} />
|
|
257
|
+
<div className="field">
|
|
258
|
+
<div className="lab">优先级</div>
|
|
259
|
+
<PrioritySeg value={priority} onChange={setPriority} />
|
|
260
|
+
</div>
|
|
261
|
+
<DepPicker candidates={tasks} value={deps} onChange={setDeps} autoStart={autoStart} onAutoStart={setAutoStart} />
|
|
262
|
+
</Modal>
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export function EditTaskModal({ task, tasks, agents, onClose, onSave, onDelete, onOpenSetup }: { task: TaskRow; tasks: TaskRow[]; agents: AgentsBundle; onClose: () => void; onSave: (id: string, patch: { title: string; description: string; priority: Priority; agent?: string; depends_on: string[]; auto_start: boolean }) => void; onDelete: (id: string) => void; onOpenSetup?: () => void }) {
|
|
267
|
+
const [title, setTitle] = useState(task.title);
|
|
268
|
+
const [desc, setDesc] = useState(task.description);
|
|
269
|
+
const [priority, setPriority] = useState<Priority>(task.priority);
|
|
270
|
+
const [agent, setAgent] = useState(task.agent);
|
|
271
|
+
const [deps, setDeps] = useState<string[]>(task.depends_on ?? []);
|
|
272
|
+
const [autoStart, setAutoStart] = useState(!!task.auto_start);
|
|
273
|
+
const [confirmDel, setConfirmDel] = useState(false);
|
|
274
|
+
const ref = useRef<HTMLInputElement>(null);
|
|
275
|
+
useEffect(() => { ref.current?.focus(); }, []);
|
|
276
|
+
const can = title.trim().length > 0;
|
|
277
|
+
const candidates = useMemo(() => tasks.filter((t) => t.id !== task.id), [tasks, task.id]);
|
|
278
|
+
const save = () => can && onSave(task.id, { title: title.trim(), description: desc.trim(), priority, agent: agent !== task.agent ? agent : undefined, depends_on: deps, auto_start: autoStart && deps.length > 0 });
|
|
279
|
+
return (
|
|
280
|
+
<Modal title="编辑任务" sub="标题与描述构成智能体的任务上下文" onClose={onClose}
|
|
281
|
+
footer={<>
|
|
282
|
+
{confirmDel ? (
|
|
283
|
+
<button className="btn-danger on" onClick={() => onDelete(task.id)} title="永久删除该任务及其会话与 worktree">{Icon.x()} 永久删除任务</button>
|
|
284
|
+
) : (
|
|
285
|
+
<button className="btn-danger" onClick={() => setConfirmDel(true)}>{Icon.x()} 删除任务</button>
|
|
286
|
+
)}
|
|
287
|
+
<span className="spacer" />
|
|
288
|
+
<button className="btn btn-ghost" onClick={onClose}>取消</button>
|
|
289
|
+
<button className="btn btn-accent" disabled={!can} onClick={save}>{Icon.check()} 保存修改</button>
|
|
290
|
+
</>}>
|
|
291
|
+
<div className="field">
|
|
292
|
+
<div className="lab">标题</div>
|
|
293
|
+
<input ref={ref} type="text" value={title} placeholder="例如:为认证接口加上限流"
|
|
294
|
+
onChange={(e) => setTitle(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter" && can) save(); }} />
|
|
295
|
+
</div>
|
|
296
|
+
<div className="field">
|
|
297
|
+
<div className="lab">描述 <span className="opt">— 要做什么</span></div>
|
|
298
|
+
<textarea value={desc} placeholder="描述这个功能或任务。这会成为智能体启动提示的正文。" onChange={(e) => setDesc(e.target.value)} />
|
|
299
|
+
{task.started === 1 ? (
|
|
300
|
+
<div className="hlp">已发送给智能体 —— 这里的修改会更新任务记录和之后的会话,但不影响正在运行的会话。</div>
|
|
301
|
+
) : (
|
|
302
|
+
<div className="hlp">项目上下文会自动前置 —— 无需重复说明技术栈或规范。</div>
|
|
303
|
+
)}
|
|
304
|
+
</div>
|
|
305
|
+
<AgentPicker agents={agents} value={agent} onChange={setAgent} onConnect={onOpenSetup}
|
|
306
|
+
help={task.started === 1 ? "任务进行中也可以切换 —— 从下一条消息起由新智能体接手,并会带上此前的对话记录。" : undefined} />
|
|
307
|
+
<div className="field">
|
|
308
|
+
<div className="lab">优先级</div>
|
|
309
|
+
<PrioritySeg value={priority} onChange={setPriority} />
|
|
310
|
+
</div>
|
|
311
|
+
<DepPicker candidates={candidates} value={deps} onChange={setDeps} autoStart={autoStart} onAutoStart={setAutoStart} />
|
|
312
|
+
{confirmDel && (
|
|
313
|
+
<div className="hlp" style={{ color: "var(--red)", marginTop: 16 }}>
|
|
314
|
+
这会从编排器中永久删除“{task.title}”及其智能体会话和 git worktree。worktree 中未合并的改动都会被丢弃。
|
|
315
|
+
</div>
|
|
316
|
+
)}
|
|
317
|
+
</Modal>
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Mirror of the server's RefreshState (lib/contextRefresh.ts) — the detached
|
|
322
|
+
// "Refresh with AI" job state the modal polls.
|
|
323
|
+
type RefreshState = { status: "idle" | "running" | "done" | "error"; draft: string; error: string; started_at: number; estimate?: InternalUsageEstimate | null };
|
|
324
|
+
|
|
325
|
+
export function ContextModal({ project, agents, onSetDefaultAgent, onClose, onSave, onDelete, onDeprecate }: { project: ProjectRow; agents: AgentsBundle; onSetDefaultAgent: (agent: string) => void; onClose: () => void; onSave: (p: { name: string; context: string; send_context: number; repo_path: string; branch: string; dev_command: string; setup_command: string; test_command: string }) => void; onDelete: () => void; onDeprecate: () => void }) {
|
|
326
|
+
const [name, setName] = useState(project.name);
|
|
327
|
+
const [context, setContext] = useState(project.context);
|
|
328
|
+
const [sendContext, setSendContext] = useState(project.send_context !== 0);
|
|
329
|
+
const [repo, setRepo] = useState(project.repo_path);
|
|
330
|
+
const [branch, setBranch] = useState(project.branch);
|
|
331
|
+
const [devCmd, setDevCmd] = useState(project.dev_command);
|
|
332
|
+
const [setupCmd, setSetupCmd] = useState(project.setup_command);
|
|
333
|
+
const [testCmd, setTestCmd] = useState(project.test_command);
|
|
334
|
+
const [confirmDel, setConfirmDel] = useState(false);
|
|
335
|
+
const showServices = clientFeatures().services;
|
|
336
|
+
// AI context refresh: let Claude read the repo and draft fresh context. The
|
|
337
|
+
// draft now runs as a DETACHED server-side job (it can take minutes and must
|
|
338
|
+
// survive sleep/reload), so the client starts it and polls for the result
|
|
339
|
+
// rather than holding one long request open. The drafted text replaces the
|
|
340
|
+
// textarea but isn't saved until Save — we stash the prior text for Undo.
|
|
341
|
+
const [refreshing, setRefreshing] = useState(false);
|
|
342
|
+
const [refreshErr, setRefreshErr] = useState<string | null>(null);
|
|
343
|
+
const [prevContext, setPrevContext] = useState<string | null>(null);
|
|
344
|
+
const [refreshEstimate, setRefreshEstimate] = useState<InternalUsageEstimate | null>(null);
|
|
345
|
+
// Edit vs. rendered-markdown preview of the context. Refreshing forces edit
|
|
346
|
+
// (the textarea shows the disabled/loading state).
|
|
347
|
+
const [preview, setPreview] = useState(false);
|
|
348
|
+
const showPreview = preview && !refreshing;
|
|
349
|
+
|
|
350
|
+
// Latest edited context, read inside async handlers without making them
|
|
351
|
+
// depend on `context` (which would churn the polling effect / stale-close it).
|
|
352
|
+
const contextRef = useRef(context);
|
|
353
|
+
contextRef.current = context;
|
|
354
|
+
// started_at of the job whose result we've already applied — so a draft is
|
|
355
|
+
// consumed exactly once even if a POST reply and a poll tick race.
|
|
356
|
+
const appliedRef = useRef(0);
|
|
357
|
+
|
|
358
|
+
const ackRefresh = useCallback(() => {
|
|
359
|
+
jsend(`/api/projects/${project.id}/refresh-context`, "DELETE").catch(() => {});
|
|
360
|
+
}, [project.id]);
|
|
361
|
+
|
|
362
|
+
// Fold a polled job state into the UI. Idempotent: applies a finished draft at
|
|
363
|
+
// most once, then acks it so it doesn't resurface on the next modal open.
|
|
364
|
+
const handleState = useCallback((s: RefreshState) => {
|
|
365
|
+
setRefreshEstimate(s.estimate ?? null);
|
|
366
|
+
if (s.status === "running") { setRefreshing(true); return; }
|
|
367
|
+
if (s.started_at && appliedRef.current !== s.started_at) {
|
|
368
|
+
if (s.status === "done" && s.draft) {
|
|
369
|
+
appliedRef.current = s.started_at;
|
|
370
|
+
setPrevContext(contextRef.current);
|
|
371
|
+
setContext(s.draft);
|
|
372
|
+
setRefreshErr(null);
|
|
373
|
+
ackRefresh();
|
|
374
|
+
} else if (s.status === "error") {
|
|
375
|
+
appliedRef.current = s.started_at;
|
|
376
|
+
setRefreshErr(s.error || "刷新失败");
|
|
377
|
+
ackRefresh();
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
setRefreshing(false);
|
|
381
|
+
}, [ackRefresh]);
|
|
382
|
+
|
|
383
|
+
// On open, reconnect to whatever the server has: a still-running job, or a
|
|
384
|
+
// draft/error left from a job that finished while the modal was closed.
|
|
385
|
+
useEffect(() => {
|
|
386
|
+
let alive = true;
|
|
387
|
+
jget<RefreshState>(`/api/projects/${project.id}/refresh-context`)
|
|
388
|
+
.then((s) => { if (alive) handleState(s); })
|
|
389
|
+
.catch(() => {});
|
|
390
|
+
return () => { alive = false; };
|
|
391
|
+
}, [project.id, handleState]);
|
|
392
|
+
|
|
393
|
+
// While a job runs, poll for its result. Stops when refreshing flips false
|
|
394
|
+
// (terminal state) or the modal unmounts — the job keeps running server-side.
|
|
395
|
+
useEffect(() => {
|
|
396
|
+
if (!refreshing) return;
|
|
397
|
+
const t = setInterval(() => {
|
|
398
|
+
jget<RefreshState>(`/api/projects/${project.id}/refresh-context`).then(handleState).catch(() => {});
|
|
399
|
+
}, 2500);
|
|
400
|
+
return () => clearInterval(t);
|
|
401
|
+
}, [refreshing, project.id, handleState]);
|
|
402
|
+
|
|
403
|
+
const refreshContext = async () => {
|
|
404
|
+
if (refreshing) return;
|
|
405
|
+
setRefreshErr(null);
|
|
406
|
+
setRefreshing(true);
|
|
407
|
+
try {
|
|
408
|
+
handleState(await jsend<RefreshState>(`/api/projects/${project.id}/refresh-context`, "POST"));
|
|
409
|
+
} catch (e) {
|
|
410
|
+
let msg = e instanceof Error ? e.message : String(e);
|
|
411
|
+
try { const j = JSON.parse(msg); if (j?.error) msg = j.error; } catch { /* not JSON — show raw */ }
|
|
412
|
+
setRefreshErr(msg);
|
|
413
|
+
setRefreshing(false);
|
|
414
|
+
}
|
|
415
|
+
};
|
|
416
|
+
|
|
417
|
+
return (
|
|
418
|
+
<Modal title={<>项目上下文 <CopyIdChip id={project.id} /></>} sub={`会前置到 ${project.name} 的每个任务`} onClose={onClose} width={620}
|
|
419
|
+
footer={<>
|
|
420
|
+
{confirmDel ? (
|
|
421
|
+
<button className="btn-danger on" onClick={onDelete} title="永久删除该项目及其任务和对话历史">{Icon.x()} 永久删除 {project.name}</button>
|
|
422
|
+
) : (
|
|
423
|
+
<>
|
|
424
|
+
<button className="btn-danger" onClick={() => setConfirmDel(true)}>{Icon.x()} 删除项目</button>
|
|
425
|
+
<button className="btn btn-line" onClick={onDeprecate} title="把该项目收进侧栏的已归档区域。不会删除任何内容 —— 随时可以恢复。">{Icon.archive()} 归档</button>
|
|
426
|
+
</>
|
|
427
|
+
)}
|
|
428
|
+
<span className="spacer" />
|
|
429
|
+
<button className="btn btn-ghost" onClick={onClose}>取消</button>
|
|
430
|
+
<button className="btn btn-accent" onClick={() => onSave({ name, context, send_context: sendContext ? 1 : 0, repo_path: repo, branch, dev_command: devCmd, setup_command: setupCmd, test_command: testCmd })}>{Icon.check()} 保存</button>
|
|
431
|
+
</>}>
|
|
432
|
+
<div className="field">
|
|
433
|
+
<div className="lab">项目名称</div>
|
|
434
|
+
<input type="text" value={name} onChange={(e) => setName(e.target.value)} />
|
|
435
|
+
</div>
|
|
436
|
+
<div className="field">
|
|
437
|
+
<div className="lab ctx-lab">
|
|
438
|
+
<span>我们在做什么</span>
|
|
439
|
+
<div className="ctx-actions">
|
|
440
|
+
{prevContext != null && !refreshing && (
|
|
441
|
+
<button
|
|
442
|
+
className="btn btn-ghost btn-sm"
|
|
443
|
+
onClick={() => { setContext(prevContext); setPrevContext(null); }}
|
|
444
|
+
title="恢复 AI 刷新前的上下文"
|
|
445
|
+
>撤销</button>
|
|
446
|
+
)}
|
|
447
|
+
<button
|
|
448
|
+
className={`btn btn-line btn-sm${showPreview ? " on" : ""}`}
|
|
449
|
+
onClick={() => setPreview((p) => !p)}
|
|
450
|
+
disabled={refreshing}
|
|
451
|
+
title="切换 Markdown 渲染预览"
|
|
452
|
+
>{Icon.doc()} {showPreview ? "编辑" : "预览"}</button>
|
|
453
|
+
<button
|
|
454
|
+
className="btn btn-line btn-sm"
|
|
455
|
+
onClick={refreshContext}
|
|
456
|
+
disabled={refreshing || !repo}
|
|
457
|
+
title={repo ? "让智能体阅读仓库并起草新的上下文。保存前请先审阅和修改。" : "请先设置工作目录"}
|
|
458
|
+
>{Icon.spark()} {refreshing ? "正在阅读仓库…" : "用 AI 刷新"}</button>
|
|
459
|
+
{refreshEstimate && !refreshing && (
|
|
460
|
+
<span className="job-cost-hint">
|
|
461
|
+
{refreshEstimate.source === "project_latest" ? "上次运行消耗" : "通常运行消耗"} {fmtJobCost(refreshEstimate)}
|
|
462
|
+
</span>
|
|
463
|
+
)}
|
|
464
|
+
</div>
|
|
465
|
+
</div>
|
|
466
|
+
{showPreview ? (
|
|
467
|
+
<div className={`md-preview${context.trim() ? "" : " empty"}`} style={{ minHeight: 150, maxHeight: 320 }}>
|
|
468
|
+
{context.trim() ? <Markdown>{context}</Markdown> : "暂无可预览的内容。"}
|
|
469
|
+
</div>
|
|
470
|
+
) : (
|
|
471
|
+
<textarea style={{ minHeight: 150 }} value={context} disabled={refreshing} onChange={(e) => setContext(e.target.value)} />
|
|
472
|
+
)}
|
|
473
|
+
{refreshErr ? (
|
|
474
|
+
<ErrNote style={{ marginTop: 7 }} onRetry={refreshContext} retryLabel="重试">{refreshErr}</ErrNote>
|
|
475
|
+
) : refreshing ? (
|
|
476
|
+
<div className="hlp" style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
|
477
|
+
<span className="typing"><i /><i /><i /></span>
|
|
478
|
+
正在浏览 {repo.split("/").pop() || "仓库"} 以起草新的上下文 —— 可能需要一分钟。
|
|
479
|
+
</div>
|
|
480
|
+
) : prevContext != null ? (
|
|
481
|
+
<div className="hlp">已根据仓库起草。请审阅并修改后保存 —— 或点击「撤销」还原。</div>
|
|
482
|
+
) : (
|
|
483
|
+
<div className="hlp">尽量写清技术栈、规范与约束。该项目下的每个任务都会继承这段内容。</div>
|
|
484
|
+
)}
|
|
485
|
+
<label style={{ display: "flex", alignItems: "center", gap: 8, marginTop: 8, fontSize: 12.5, color: "var(--ink-2)", cursor: "pointer" }}>
|
|
486
|
+
<input type="checkbox" checked={sendContext} onChange={(e) => setSendContext(e.target.checked)} />
|
|
487
|
+
在新的智能体会话中包含这段上下文
|
|
488
|
+
</label>
|
|
489
|
+
{!sendContext && (
|
|
490
|
+
<div className="hlp">新任务将不携带已保存的上下文启动(任务详情与编排工具仍然包含)。每个任务在启动时都可以单独覆盖此设置。</div>
|
|
491
|
+
)}
|
|
492
|
+
</div>
|
|
493
|
+
<div style={{ display: "flex", gap: 14 }}>
|
|
494
|
+
<div className="field" style={{ flex: 1, marginBottom: 0 }}>
|
|
495
|
+
<div className="lab">{Icon.folder()} 工作目录 <span className="opt">— 运行任务必需</span></div>
|
|
496
|
+
<div style={{ display: "flex", gap: 8 }}>
|
|
497
|
+
<input type="text" className="ctx-mono" style={{ flex: 1, minWidth: 0 }} value={repo} placeholder="/Users/you/code/project" onChange={(e) => setRepo(e.target.value)} />
|
|
498
|
+
<BrowseDirButton initial={repo} onPick={setRepo} />
|
|
499
|
+
</div>
|
|
500
|
+
</div>
|
|
501
|
+
<div className="field" style={{ flex: "0 0 170px", marginBottom: 0 }}>
|
|
502
|
+
<div className="lab">{Icon.git()} 分支</div>
|
|
503
|
+
<input type="text" className="ctx-mono" value={branch} onChange={(e) => setBranch(e.target.value)} />
|
|
504
|
+
</div>
|
|
505
|
+
</div>
|
|
506
|
+
<div style={{ marginTop: 14 }}>
|
|
507
|
+
<AgentPicker
|
|
508
|
+
agents={agents} value={project.default_agent} onChange={onSetDefaultAgent}
|
|
509
|
+
label="新任务的默认智能体"
|
|
510
|
+
help="该项目的新任务默认使用此智能体。已有任务仍保留创建时选定的智能体。"
|
|
511
|
+
/>
|
|
512
|
+
</div>
|
|
513
|
+
{showServices && (
|
|
514
|
+
<div className="field" style={{ marginTop: 14 }}>
|
|
515
|
+
<div className="lab ctx-lab">
|
|
516
|
+
<span>{Icon.sliders()} 服务</span>
|
|
517
|
+
<span className="opt" style={{ fontWeight: 400 }}>端口 <code className="ctx-mono">{project.port || "—"}</code> 会以 <code className="ctx-mono">PORT</code> 注入</span>
|
|
518
|
+
</div>
|
|
519
|
+
<div className="hlp" style={{ marginTop: 0, marginBottom: 8 }}>
|
|
520
|
+
编排器会在 {repo ? repo.split("/").pop() : "工作目录"} 中托管这些进程 —— 可在「服务」面板启停,它们的生命周期独立于浏览器标签页。
|
|
521
|
+
</div>
|
|
522
|
+
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
|
523
|
+
<label className="svc-cfg-row">
|
|
524
|
+
<span className="svc-cfg-lab">开发服务器</span>
|
|
525
|
+
<input type="text" className="ctx-mono" value={devCmd} placeholder="npm run dev" onChange={(e) => setDevCmd(e.target.value)} />
|
|
526
|
+
</label>
|
|
527
|
+
<label className="svc-cfg-row">
|
|
528
|
+
<span className="svc-cfg-lab">初始化 <span className="opt">— 可选</span></span>
|
|
529
|
+
<input type="text" className="ctx-mono" value={setupCmd} placeholder="npm install" onChange={(e) => setSetupCmd(e.target.value)} />
|
|
530
|
+
</label>
|
|
531
|
+
<label className="svc-cfg-row">
|
|
532
|
+
<span className="svc-cfg-lab">测试 <span className="opt">— 可选</span></span>
|
|
533
|
+
<input type="text" className="ctx-mono" value={testCmd} placeholder="npm test" onChange={(e) => setTestCmd(e.target.value)} />
|
|
534
|
+
</label>
|
|
535
|
+
</div>
|
|
536
|
+
</div>
|
|
537
|
+
)}
|
|
538
|
+
<ProjectApiSnippets projectId={project.id} />
|
|
539
|
+
{confirmDel && (
|
|
540
|
+
<div className="hlp" style={{ color: "var(--red)", marginTop: 16 }}>
|
|
541
|
+
这会从编排器中永久删除“{project.name}”及其任务和对话历史。磁盘上{repo ? ` ${repo} 中` : ""}的代码不会被改动。
|
|
542
|
+
</div>
|
|
543
|
+
)}
|
|
544
|
+
</Modal>
|
|
545
|
+
);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
export function SessionsModal({ project, onClose, onJump }: { project: ProjectRow; onClose: () => void; onJump: (taskId: string) => void }) {
|
|
549
|
+
const [sessions, setSessions] = useState<ProjectSession[] | null>(null);
|
|
550
|
+
const [error, setError] = useState<string | null>(null);
|
|
551
|
+
const load = useCallback(() => {
|
|
552
|
+
setError(null);
|
|
553
|
+
setSessions(null);
|
|
554
|
+
jget<ProjectSession[]>(`/api/projects/${project.id}/sessions`)
|
|
555
|
+
.then(setSessions)
|
|
556
|
+
.catch((e) => setError(e instanceof Error ? e.message : String(e)));
|
|
557
|
+
}, [project.id]);
|
|
558
|
+
useEffect(() => { load(); }, [load]);
|
|
559
|
+
|
|
560
|
+
const total = sessions?.length ?? 0;
|
|
561
|
+
return (
|
|
562
|
+
<Modal
|
|
563
|
+
title="会话"
|
|
564
|
+
sub={`${project.name} 下运行过的所有智能体会话${sessions ? ` · 共 ${total} 个` : ""}`}
|
|
565
|
+
onClose={onClose}
|
|
566
|
+
width={640}
|
|
567
|
+
>
|
|
568
|
+
{error && <ErrNote onRetry={load}>无法加载会话:{error}</ErrNote>}
|
|
569
|
+
{!sessions && !error && (
|
|
570
|
+
// Skeleton mirroring the session rows below, so the modal doesn't reflow
|
|
571
|
+
// when the real list lands.
|
|
572
|
+
<div className="skel-list" aria-hidden>
|
|
573
|
+
{[52, 38, 46].map((w, i) => (
|
|
574
|
+
<div key={i} className="task" style={{ cursor: "default", marginBottom: 0 }}>
|
|
575
|
+
<div className="task-top">
|
|
576
|
+
<Skel w={9} h={9} r="50%" />
|
|
577
|
+
<Skel w={`${w}%`} h={12} />
|
|
578
|
+
<span style={{ flex: 1 }} />
|
|
579
|
+
<Skel w={70} h={10} />
|
|
580
|
+
</div>
|
|
581
|
+
<div className="task-foot">
|
|
582
|
+
<Skel w={150} h={9} />
|
|
583
|
+
</div>
|
|
584
|
+
</div>
|
|
585
|
+
))}
|
|
586
|
+
</div>
|
|
587
|
+
)}
|
|
588
|
+
{sessions && total === 0 && (
|
|
589
|
+
<div className="empty" style={{ padding: "24px 8px" }}>
|
|
590
|
+
<div className="e-t">还没有会话</div>
|
|
591
|
+
<div className="e-s">启动一个任务,开启该项目的第一个智能体会话。</div>
|
|
592
|
+
</div>
|
|
593
|
+
)}
|
|
594
|
+
{sessions && total > 0 && (
|
|
595
|
+
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
|
596
|
+
{sessions.map((s) => (
|
|
597
|
+
<button
|
|
598
|
+
key={s.id}
|
|
599
|
+
className="task"
|
|
600
|
+
style={{ textAlign: "left", width: "100%" }}
|
|
601
|
+
onClick={() => onJump(s.task_id)}
|
|
602
|
+
title="打开该任务"
|
|
603
|
+
>
|
|
604
|
+
<div className="task-top">
|
|
605
|
+
<StatusDot status={s.task_status} running={!s.ended_at} />
|
|
606
|
+
<span className="ttitle">{s.task_title}</span>
|
|
607
|
+
<span className="slabel">第 {s.generation} 次会话</span>
|
|
608
|
+
</div>
|
|
609
|
+
<div className="task-foot">
|
|
610
|
+
<span className="activity">{relTime(s.started_at)} · {duration(s.started_at, s.ended_at)} · {s.message_count} 条消息</span>
|
|
611
|
+
<span className="spacer" />
|
|
612
|
+
{s.claude_session_id ? (
|
|
613
|
+
<span className="activity ctx-mono" title={s.claude_session_id} style={{ fontSize: 11, opacity: 0.7 }}>
|
|
614
|
+
{s.claude_session_id.slice(0, 8)}
|
|
615
|
+
</span>
|
|
616
|
+
) : (
|
|
617
|
+
<span className="activity" style={{ opacity: 0.5 }}>无会话 ID</span>
|
|
618
|
+
)}
|
|
619
|
+
</div>
|
|
620
|
+
</button>
|
|
621
|
+
))}
|
|
622
|
+
</div>
|
|
623
|
+
)}
|
|
624
|
+
</Modal>
|
|
625
|
+
);
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
export function NewProjectModal({ onClose, onCreate }: { onClose: () => void; onCreate: (i: { name: string; sub: string; color: string; context: string; repo_path: string; branch?: string }) => void | Promise<void> }) {
|
|
629
|
+
const [name, setName] = useState("");
|
|
630
|
+
const [sub, setSub] = useState("");
|
|
631
|
+
const [context, setContext] = useState("");
|
|
632
|
+
const [repo, setRepo] = useState("");
|
|
633
|
+
const colors = ["#C2603C", "#3E7CA8", "#6B6F8C", "#5C8C5A", "#9A6E14", "#9E5BA0"];
|
|
634
|
+
const [color, setColor] = useState(colors[0]);
|
|
635
|
+
// Where the code comes from: a local folder — existing repo or greenfield —
|
|
636
|
+
// or a clone of one of the user's GitHub repos (the onboarding path).
|
|
637
|
+
const [mode, setMode] = useState<"fresh" | "clone">("fresh");
|
|
638
|
+
const [cloneSpec, setCloneSpec] = useState(""); // owner/repo or pasted URL
|
|
639
|
+
const [cloning, setCloning] = useState(false);
|
|
640
|
+
const [cloneErr, setCloneErr] = useState<string | null>(null);
|
|
641
|
+
const ref = useRef<HTMLInputElement>(null);
|
|
642
|
+
useEffect(() => { ref.current?.focus(); }, []);
|
|
643
|
+
const ok = name.trim().length > 0 && !cloning && (mode === "fresh" || cloneSpec.trim().length > 0);
|
|
644
|
+
|
|
645
|
+
const submit = async () => {
|
|
646
|
+
if (!ok) return;
|
|
647
|
+
const base = { name: name.trim(), sub: sub.trim() || "应用", color, context: context.trim() };
|
|
648
|
+
if (mode === "fresh") { await onCreate({ ...base, repo_path: repo.trim() }); return; }
|
|
649
|
+
// Clone first; only create the project once the repo actually landed.
|
|
650
|
+
setCloning(true);
|
|
651
|
+
setCloneErr(null);
|
|
652
|
+
try {
|
|
653
|
+
const r = await jsend<{ path: string; branch: string }>("/api/github/clone", "POST", { repo: cloneSpec.trim() });
|
|
654
|
+
await onCreate({ ...base, repo_path: r.path, branch: r.branch });
|
|
655
|
+
} catch (e) {
|
|
656
|
+
setCloneErr(e instanceof Error ? e.message : String(e));
|
|
657
|
+
setCloning(false);
|
|
658
|
+
}
|
|
659
|
+
};
|
|
660
|
+
|
|
661
|
+
return (
|
|
662
|
+
<Modal title="新建项目" sub="每个项目都是你正在构建的一个独立应用" onClose={onClose}
|
|
663
|
+
footer={<>
|
|
664
|
+
{cloneErr && <span className="hlp" style={{ color: "var(--red)", margin: 0 }}>⚠ {cloneErr}</span>}
|
|
665
|
+
<span className="spacer" />
|
|
666
|
+
<button className="btn btn-ghost" onClick={onClose}>取消</button>
|
|
667
|
+
<button className="btn btn-accent" disabled={!ok} onClick={submit}>
|
|
668
|
+
{Icon.plus()} {mode === "clone" ? (cloning ? "克隆中…" : "克隆并创建") : "创建项目"}
|
|
669
|
+
</button>
|
|
670
|
+
</>}>
|
|
671
|
+
<div style={{ display: "flex", gap: 14 }}>
|
|
672
|
+
<div className="field" style={{ flex: 1 }}>
|
|
673
|
+
<div className="lab">项目名称</div>
|
|
674
|
+
<input ref={ref} type="text" value={name} placeholder="例如:Northwind" onChange={(e) => setName(e.target.value)} />
|
|
675
|
+
</div>
|
|
676
|
+
<div className="field" style={{ flex: "0 0 150px" }}>
|
|
677
|
+
<div className="lab">一句话简介</div>
|
|
678
|
+
<input type="text" value={sub} placeholder="邮件客户端" onChange={(e) => setSub(e.target.value)} />
|
|
679
|
+
</div>
|
|
680
|
+
</div>
|
|
681
|
+
<div className="field">
|
|
682
|
+
<div className="lab">主题色</div>
|
|
683
|
+
<div style={{ display: "flex", gap: 9 }}>
|
|
684
|
+
{colors.map((c) => (
|
|
685
|
+
<button key={c} onClick={() => setColor(c)} style={{ width: 34, height: 34, borderRadius: 9, background: c, outline: color === c ? "2px solid var(--ink)" : "none", outlineOffset: 2, border: "none", cursor: "pointer" }} />
|
|
686
|
+
))}
|
|
687
|
+
</div>
|
|
688
|
+
</div>
|
|
689
|
+
<div className="field">
|
|
690
|
+
<div className="lab">{Icon.folder()} 代码</div>
|
|
691
|
+
<div className="seg">
|
|
692
|
+
<button className={mode === "fresh" ? "on" : ""} onClick={() => setMode("fresh")}>本地文件夹</button>
|
|
693
|
+
<button className={mode === "clone" ? "on" : ""} onClick={() => setMode("clone")}>{Icon.github()} 从 GitHub 克隆</button>
|
|
694
|
+
</div>
|
|
695
|
+
</div>
|
|
696
|
+
{mode === "fresh" ? (
|
|
697
|
+
<div className="field">
|
|
698
|
+
<div className="lab">工作目录 <span className="opt">— 选择已有仓库或文件夹;留空则先创建项目,之后再补上</span></div>
|
|
699
|
+
<div style={{ display: "flex", gap: 8 }}>
|
|
700
|
+
<input type="text" className="ctx-mono" style={{ flex: 1, minWidth: 0 }} value={repo} placeholder="/Users/you/code/project" onChange={(e) => setRepo(e.target.value)} />
|
|
701
|
+
<BrowseDirButton initial={repo} onPick={setRepo} />
|
|
702
|
+
</div>
|
|
703
|
+
</div>
|
|
704
|
+
) : (
|
|
705
|
+
<GitHubClonePicker
|
|
706
|
+
value={cloneSpec}
|
|
707
|
+
onChange={(spec, shortName) => {
|
|
708
|
+
setCloneSpec(spec);
|
|
709
|
+
// Picking a repo names the project after it (only if still unnamed).
|
|
710
|
+
if (shortName && !name.trim()) setName(shortName);
|
|
711
|
+
}}
|
|
712
|
+
/>
|
|
713
|
+
)}
|
|
714
|
+
<div className="field">
|
|
715
|
+
<div className="lab">我们在做什么 <span className="opt">— 可选,稍后也能补充</span></div>
|
|
716
|
+
<textarea value={context} placeholder="项目简介、技术栈、规范…" onChange={(e) => setContext(e.target.value)} />
|
|
717
|
+
</div>
|
|
718
|
+
</Modal>
|
|
719
|
+
);
|
|
720
|
+
}
|