@spzxg/cli-ui 0.6.2 → 0.6.6
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 +49 -0
- package/README.md +165 -19
- package/dist/cli-ui-embed-BAqonuoD.js +45952 -0
- package/dist/{cli-ui-index-BY5i3wId.js → cli-ui-index-b8wF7wrN.js} +2 -2
- package/dist/{cli-ui-jszip.min-m01fexHW.js → cli-ui-jszip.min-B0uLrCHg.js} +2 -2
- package/dist/{cli-ui-jszip.min-BZsUwI-s.js → cli-ui-jszip.min-ggP8OokP.js} +1 -1
- package/dist/{cli-ui-xlsx-CpWicJLT.js → cli-ui-xlsx-DEDdHrJJ.js} +1 -1
- package/dist/cli-ui.css +32 -1
- package/dist/cli-ui.js +1 -1
- package/package.json +17 -10
- package/src/board/BrowserPane.tsx +346 -115
- package/src/board/ChangesPane.tsx +2 -2
- package/src/board/CliBoard.tsx +79 -50
- package/src/board/FileViewer.tsx +54 -31
- 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 +94 -0
- package/src/board/folderDownload.ts +30 -14
- package/src/board/frameQuote.ts +2 -2
- package/src/board/panels.ts +33 -0
- package/src/board/textLoad.ts +9 -7
- package/src/board/workspace.ts +53 -14
- package/src/chat/CliChat.tsx +93 -40
- package/src/chat/Composer.tsx +44 -42
- 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 +17 -6
- package/src/core/callbacks.ts +8 -0
- package/src/core/client.ts +103 -19
- package/src/core/conversation.ts +4 -3
- package/src/core/id.ts +15 -0
- package/src/core/identity.ts +33 -0
- package/src/core/provider.tsx +31 -14
- package/src/core/store.ts +151 -53
- package/src/core/types.ts +64 -9
- 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/layout/split.ts +67 -30
- package/src/msglist/CliMsgList.tsx +19 -13
- 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 +468 -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 +82 -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 +280 -0
- package/src/operator.base.css +1806 -0
- package/src/operator.css +194 -16
- package/src/styles.css +49 -1
- package/src/ui/Markdown.tsx +7 -2
- package/src/ui/Popover.tsx +82 -0
- package/src/ui/TextBlock.tsx +3 -3
- package/src/ui/Transcript.tsx +17 -10
- package/src/ui/fileLinks.ts +6 -0
- package/src/ui/format.ts +1 -1
- package/src/ui/shared.tsx +1 -1
- package/src/ui/transcriptModel.ts +2 -2
- package/dist/cli-ui-embed-6IJ_jRVh.js +0 -41223
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Fragment, useState } from "react";
|
|
4
|
+
import { Icon } from "./icons";
|
|
5
|
+
import { Popover } from "./shared";
|
|
6
|
+
import { agentLabel, agentStateHint, capsFor } from "./agents";
|
|
7
|
+
import { modelLabel } from "./format";
|
|
8
|
+
import { modelOptions, type AgentsBundle, type TaskRow } from "./types";
|
|
9
|
+
|
|
10
|
+
// The model list (+ custom model box) for a task's agent — the body of the model
|
|
11
|
+
// popover in both the session header and the composer.
|
|
12
|
+
export function ModelMenu({ task, agents, onPick }: { task: TaskRow; agents: AgentsBundle; onPick: (m: string | null) => void }) {
|
|
13
|
+
const caps = capsFor(agents, task.agent);
|
|
14
|
+
const models = modelOptions(caps);
|
|
15
|
+
const [custom, setCustom] = useState("");
|
|
16
|
+
return (
|
|
17
|
+
<>
|
|
18
|
+
{models.map((m, i) => (
|
|
19
|
+
<Fragment key={m.value ?? "default"}>
|
|
20
|
+
{/* Section header whenever the group changes — Claude Code's
|
|
21
|
+
list runs to a dozen-plus pins, so it needs the structure. */}
|
|
22
|
+
{m.group && m.group !== models[i - 1]?.group && <div className="pop-sec">{m.group}</div>}
|
|
23
|
+
<div className="pop-item" onClick={() => onPick(m.value)}>
|
|
24
|
+
<div><div>{m.label}</div><div className="pi-sub">{m.sub}</div></div>
|
|
25
|
+
{(task.model ?? null) === m.value && <span className="pi-check">{Icon.check()}</span>}
|
|
26
|
+
</div>
|
|
27
|
+
</Fragment>
|
|
28
|
+
))}
|
|
29
|
+
{caps?.supportsCustomModels && (
|
|
30
|
+
<form className="pop-custom-model" onSubmit={(e) => {
|
|
31
|
+
e.preventDefault();
|
|
32
|
+
const value = custom.trim();
|
|
33
|
+
if (!value) return;
|
|
34
|
+
onPick(value);
|
|
35
|
+
setCustom("");
|
|
36
|
+
}}>
|
|
37
|
+
<div className="pop-sec">自定义模型</div>
|
|
38
|
+
<div className="pop-custom-row">
|
|
39
|
+
<input
|
|
40
|
+
className="ctx-mono"
|
|
41
|
+
value={custom}
|
|
42
|
+
onChange={(e) => setCustom(e.target.value)}
|
|
43
|
+
placeholder="模型 ID 或 inference-profile ARN"
|
|
44
|
+
aria-label="自定义模型 ID"
|
|
45
|
+
/>
|
|
46
|
+
<button className="btn btn-sm btn-accent" type="submit" disabled={!custom.trim()}>使用</button>
|
|
47
|
+
</div>
|
|
48
|
+
{task.model && !models.some((m) => m.value === task.model) && (
|
|
49
|
+
<div className="pi-sub" title={task.model}>当前:{task.model}</div>
|
|
50
|
+
)}
|
|
51
|
+
</form>
|
|
52
|
+
)}
|
|
53
|
+
</>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Composer-footer switchers: which agent and which model the NEXT message runs
|
|
58
|
+
// on. Both apply right away (a turn already running finishes on what it started
|
|
59
|
+
// with); switching agents hands the new one this task's conversation so far.
|
|
60
|
+
export function RunSwitch({ task, agents, running, onSetAgent, onSetModel }: {
|
|
61
|
+
task: TaskRow; agents: AgentsBundle; running: boolean;
|
|
62
|
+
onSetAgent: (id: string) => void; onSetModel: (m: string | null) => void;
|
|
63
|
+
}) {
|
|
64
|
+
const [open, setOpen] = useState<"agent" | "model" | null>(null);
|
|
65
|
+
const caps = capsFor(agents, task.agent);
|
|
66
|
+
const models = modelOptions(caps);
|
|
67
|
+
const modelText = models.find((m) => m.value === (task.model ?? null))?.label ?? (task.model ? modelLabel(task.model, caps) : "默认模型");
|
|
68
|
+
const when = running ? "当前这一轮跑完后生效" : "下一条消息起生效";
|
|
69
|
+
const toggle = (which: "agent" | "model") => (e: React.MouseEvent) => { e.stopPropagation(); setOpen((o) => (o === which ? null : which)); };
|
|
70
|
+
return (
|
|
71
|
+
<>
|
|
72
|
+
{agents.agents.length > 1 && (
|
|
73
|
+
<div style={{ position: "relative" }}>
|
|
74
|
+
<button className="run-sw" onClick={toggle("agent")} title={`切换智能体 —— ${when},新智能体会带上此前的对话`} aria-label="切换智能体">
|
|
75
|
+
{Icon.bolt()}<span className="run-sw-v">{agentLabel(agents, task.agent)}</span>{Icon.chevDown()}
|
|
76
|
+
</button>
|
|
77
|
+
{open === "agent" && (
|
|
78
|
+
<Popover onClose={() => setOpen(null)}>
|
|
79
|
+
<div className="pop-sec">切换智能体</div>
|
|
80
|
+
{agents.agents.map((a) => {
|
|
81
|
+
const hint = agentStateHint(a);
|
|
82
|
+
const current = a.id === task.agent;
|
|
83
|
+
return (
|
|
84
|
+
<div key={a.id} className="pop-item" onClick={() => { if (!current) onSetAgent(a.id); setOpen(null); }}>
|
|
85
|
+
<div>
|
|
86
|
+
<div>{a.label}</div>
|
|
87
|
+
<div className="pi-sub">
|
|
88
|
+
{hint === "未安装" ? "命令行工具未安装在服务器上" : hint === "未连接" ? "尚未连接 —— 请先到设置里连接" : current ? "当前使用中" : "接手后继续这个任务"}
|
|
89
|
+
</div>
|
|
90
|
+
</div>
|
|
91
|
+
{current && <span className="pi-check">{Icon.check()}</span>}
|
|
92
|
+
</div>
|
|
93
|
+
);
|
|
94
|
+
})}
|
|
95
|
+
<div className="pop-note">{when};换智能体时会把此前的对话记录交给新智能体。</div>
|
|
96
|
+
</Popover>
|
|
97
|
+
)}
|
|
98
|
+
</div>
|
|
99
|
+
)}
|
|
100
|
+
<div style={{ position: "relative" }}>
|
|
101
|
+
<button className="run-sw" onClick={toggle("model")} title={`切换模型 —— ${when}`} aria-label="切换模型">
|
|
102
|
+
{Icon.spark()}<span className="run-sw-v">{modelText}</span>{Icon.chevDown()}
|
|
103
|
+
</button>
|
|
104
|
+
{open === "model" && (
|
|
105
|
+
<Popover onClose={() => setOpen(null)}>
|
|
106
|
+
<div className="pop-sec">{agentLabel(agents, task.agent)} 的模型</div>
|
|
107
|
+
<ModelMenu task={task} agents={agents} onPick={(m) => { onSetModel(m); setOpen(null); }} />
|
|
108
|
+
</Popover>
|
|
109
|
+
)}
|
|
110
|
+
</div>
|
|
111
|
+
</>
|
|
112
|
+
);
|
|
113
|
+
}
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef, useState } from "react";
|
|
4
|
+
import { Icon } from "./icons";
|
|
5
|
+
import { jsend } from "./api";
|
|
6
|
+
import { openLive } from "./liveStream";
|
|
7
|
+
import { LoadNote } from "./shared";
|
|
8
|
+
import type { ServiceInfo, ServiceLogLine, ServiceEvent, ServiceStatus, ServiceVisibility } from "./protocol/types";
|
|
9
|
+
|
|
10
|
+
const LOG_CAP = 1500;
|
|
11
|
+
|
|
12
|
+
const STATUS_LABEL: Record<ServiceStatus, string> = {
|
|
13
|
+
stopped: "已停止", starting: "启动中…", running: "运行中", exited: "已退出", errored: "出错",
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
// Status dot class — green running, amber starting, red errored, grey otherwise.
|
|
17
|
+
function dotClass(s: ServiceStatus): string {
|
|
18
|
+
if (s === "running") return "g";
|
|
19
|
+
if (s === "starting") return "a";
|
|
20
|
+
if (s === "errored") return "r";
|
|
21
|
+
return "x";
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// A managed service is busy (no controls) only while starting; everything else is
|
|
25
|
+
// actionable. An exposed (unmanaged) entry has no process to control.
|
|
26
|
+
function ServiceRow({
|
|
27
|
+
svc, selected, onSelect, onAction, onVisibility,
|
|
28
|
+
}: {
|
|
29
|
+
svc: ServiceInfo;
|
|
30
|
+
selected: boolean;
|
|
31
|
+
onSelect: () => void;
|
|
32
|
+
onAction: (action: "start" | "stop" | "restart") => void;
|
|
33
|
+
onVisibility: (value: ServiceVisibility) => void;
|
|
34
|
+
}) {
|
|
35
|
+
const live = svc.status === "running" || svc.status === "starting";
|
|
36
|
+
const [copied, setCopied] = useState(false);
|
|
37
|
+
// The link worth handing out: the tokened share link when shared, else the URL.
|
|
38
|
+
const copyUrl = svc.shareUrl ?? svc.url;
|
|
39
|
+
const copy = async (e: React.MouseEvent) => {
|
|
40
|
+
e.stopPropagation();
|
|
41
|
+
if (!copyUrl) return;
|
|
42
|
+
try {
|
|
43
|
+
await navigator.clipboard.writeText(copyUrl);
|
|
44
|
+
setCopied(true);
|
|
45
|
+
setTimeout(() => setCopied(false), 1400);
|
|
46
|
+
} catch { /* clipboard blocked */ }
|
|
47
|
+
};
|
|
48
|
+
// Visibility only matters once the service has a public identity (a slug is
|
|
49
|
+
// assigned at first start/expose; until then there is no URL to gate).
|
|
50
|
+
const showShare = !!svc.slug;
|
|
51
|
+
return (
|
|
52
|
+
<button className={`svc-row${selected ? " on" : ""}`} onClick={onSelect}>
|
|
53
|
+
<span className={`svc-dot ${dotClass(svc.status)}`} />
|
|
54
|
+
<span className="svc-name">{svc.name}</span>
|
|
55
|
+
<span className="svc-status" title={svc.error ?? undefined}>{STATUS_LABEL[svc.status]}{svc.exitCode != null && svc.status !== "running" ? ` (${svc.exitCode})` : ""}</span>
|
|
56
|
+
<span style={{ flex: 1 }} />
|
|
57
|
+
{showShare && (
|
|
58
|
+
<select
|
|
59
|
+
className="svc-vis"
|
|
60
|
+
value={svc.visibility}
|
|
61
|
+
onClick={(e) => e.stopPropagation()}
|
|
62
|
+
onChange={(e) => onVisibility(e.target.value as ServiceVisibility)}
|
|
63
|
+
title="谁可以打开该服务的地址"
|
|
64
|
+
>
|
|
65
|
+
<option value="private">私有</option>
|
|
66
|
+
<option value="shared">链接可访问</option>
|
|
67
|
+
<option value="public">公开</option>
|
|
68
|
+
</select>
|
|
69
|
+
)}
|
|
70
|
+
{copyUrl && live && (
|
|
71
|
+
<span className="icon-btn" role="button" tabIndex={0} title={copied ? "已复制" : `复制 ${copyUrl}`} onClick={copy}>
|
|
72
|
+
{copied ? Icon.check() : Icon.copy()}
|
|
73
|
+
</span>
|
|
74
|
+
)}
|
|
75
|
+
{svc.url && live && (
|
|
76
|
+
<a className="svc-url" href={svc.url} target="_blank" rel="noopener noreferrer" onClick={(e) => e.stopPropagation()} title={`打开 ${svc.url}`}>
|
|
77
|
+
:{svc.port}
|
|
78
|
+
</a>
|
|
79
|
+
)}
|
|
80
|
+
{svc.managed && (
|
|
81
|
+
<span className="svc-actions" onClick={(e) => e.stopPropagation()}>
|
|
82
|
+
{live ? (
|
|
83
|
+
<>
|
|
84
|
+
<span className="icon-btn" role="button" tabIndex={0} title="重启" onClick={() => onAction("restart")}>{Icon.clear()}</span>
|
|
85
|
+
<span className="icon-btn" role="button" tabIndex={0} title="停止" onClick={() => onAction("stop")}>{Icon.stop()}</span>
|
|
86
|
+
</>
|
|
87
|
+
) : (
|
|
88
|
+
<span className="icon-btn" role="button" tabIndex={0} title="启动" onClick={() => onAction("start")}>{Icon.play()}</span>
|
|
89
|
+
)}
|
|
90
|
+
</span>
|
|
91
|
+
)}
|
|
92
|
+
</button>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function LogView({ lines }: { lines: ServiceLogLine[] }) {
|
|
97
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
98
|
+
const pinned = useRef(true);
|
|
99
|
+
// Auto-scroll to the tail unless the user has scrolled up to read history.
|
|
100
|
+
useEffect(() => {
|
|
101
|
+
const el = ref.current;
|
|
102
|
+
if (el && pinned.current) el.scrollTop = el.scrollHeight;
|
|
103
|
+
}, [lines]);
|
|
104
|
+
const onScroll = () => {
|
|
105
|
+
const el = ref.current;
|
|
106
|
+
if (!el) return;
|
|
107
|
+
pinned.current = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
|
|
108
|
+
};
|
|
109
|
+
return (
|
|
110
|
+
<div className="svc-logs" ref={ref} onScroll={onScroll}>
|
|
111
|
+
{lines.length === 0 ? (
|
|
112
|
+
<div className="svc-logs-empty">暂无输出。</div>
|
|
113
|
+
) : (
|
|
114
|
+
lines.map((l, i) => (
|
|
115
|
+
<div key={i} className={`svc-log-line ${l.stream}`}>{l.text || " "}</div>
|
|
116
|
+
))
|
|
117
|
+
)}
|
|
118
|
+
</div>
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Bottom drawer mirroring the terminal drawer: live status + controls + logs for a
|
|
123
|
+
// project's managed services, fed by the services SSE stream so it survives a tab
|
|
124
|
+
// reload (the processes live in the server, lib/services.ts).
|
|
125
|
+
export function ServicesDrawer({
|
|
126
|
+
projectId, hasConfig, visible, height, onClose, onResize,
|
|
127
|
+
}: {
|
|
128
|
+
projectId: string;
|
|
129
|
+
hasConfig: boolean;
|
|
130
|
+
visible: boolean;
|
|
131
|
+
height: number;
|
|
132
|
+
onClose: () => void;
|
|
133
|
+
onResize: (h: number) => void;
|
|
134
|
+
}) {
|
|
135
|
+
const dragging = useRef(false);
|
|
136
|
+
const [services, setServices] = useState<ServiceInfo[]>([]);
|
|
137
|
+
const [logs, setLogs] = useState<Record<string, ServiceLogLine[]>>({});
|
|
138
|
+
const [selected, setSelected] = useState<string | null>(null);
|
|
139
|
+
const [err, setErr] = useState<string | null>(null);
|
|
140
|
+
const selectedRef = useRef<string | null>(null);
|
|
141
|
+
selectedRef.current = selected;
|
|
142
|
+
|
|
143
|
+
// Resize handle (drag the top edge).
|
|
144
|
+
useEffect(() => {
|
|
145
|
+
const move = (e: MouseEvent) => {
|
|
146
|
+
if (!dragging.current) return;
|
|
147
|
+
const h = window.innerHeight - e.clientY;
|
|
148
|
+
onResize(Math.max(140, Math.min(h, Math.round(window.innerHeight * 0.78))));
|
|
149
|
+
};
|
|
150
|
+
const up = () => { dragging.current = false; document.body.style.userSelect = ""; };
|
|
151
|
+
window.addEventListener("mousemove", move);
|
|
152
|
+
window.addEventListener("mouseup", up);
|
|
153
|
+
return () => { window.removeEventListener("mousemove", move); window.removeEventListener("mouseup", up); };
|
|
154
|
+
}, [onResize]);
|
|
155
|
+
|
|
156
|
+
// Live services stream. Reopened per project; each connect re-snapshots.
|
|
157
|
+
useEffect(() => {
|
|
158
|
+
const onMessage = (data: string) => {
|
|
159
|
+
let ev: ServiceEvent;
|
|
160
|
+
try { ev = JSON.parse(data); } catch { return; }
|
|
161
|
+
if (ev.type === "snapshot") {
|
|
162
|
+
setServices(ev.services);
|
|
163
|
+
setLogs(ev.logs);
|
|
164
|
+
if (!selectedRef.current && ev.services.length) setSelected(ev.services[0].name);
|
|
165
|
+
} else if (ev.type === "status") {
|
|
166
|
+
setServices((prev) => {
|
|
167
|
+
const i = prev.findIndex((s) => s.name === ev.service.name);
|
|
168
|
+
if (i === -1) return [...prev, ev.service];
|
|
169
|
+
const next = prev.slice();
|
|
170
|
+
next[i] = ev.service;
|
|
171
|
+
return next;
|
|
172
|
+
});
|
|
173
|
+
if (!selectedRef.current) setSelected(ev.service.name);
|
|
174
|
+
} else if (ev.type === "log") {
|
|
175
|
+
setLogs((prev) => {
|
|
176
|
+
const cur = prev[ev.name] ?? [];
|
|
177
|
+
const next = cur.length >= LOG_CAP ? [...cur.slice(cur.length - LOG_CAP + 1), ev.line] : [...cur, ev.line];
|
|
178
|
+
return { ...prev, [ev.name]: next };
|
|
179
|
+
});
|
|
180
|
+
} else if (ev.type === "removed") {
|
|
181
|
+
setServices((prev) => prev.filter((s) => s.name !== ev.name));
|
|
182
|
+
}
|
|
183
|
+
};
|
|
184
|
+
const sub = openLive(`/api/projects/${projectId}/services/stream`, { onMessage });
|
|
185
|
+
return () => sub.close();
|
|
186
|
+
}, [projectId]);
|
|
187
|
+
|
|
188
|
+
const act = async (name: string, action: "start" | "stop" | "restart" | "visibility", value?: ServiceVisibility) => {
|
|
189
|
+
setErr(null);
|
|
190
|
+
setSelected(name);
|
|
191
|
+
try {
|
|
192
|
+
await jsend(`/api/projects/${projectId}/services`, "POST", { name, action, value });
|
|
193
|
+
} catch (e) {
|
|
194
|
+
let msg = e instanceof Error ? e.message : String(e);
|
|
195
|
+
try { const j = JSON.parse(msg); if (j?.error) msg = j.error; } catch { /* raw */ }
|
|
196
|
+
setErr(msg);
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
const current = selected ? logs[selected] ?? [] : [];
|
|
201
|
+
// Supervisor-level failure (port conflict, spawn failure) for the selected
|
|
202
|
+
// service — shown as a banner over the logs, not buried in them.
|
|
203
|
+
const selectedError = selected ? services.find((s) => s.name === selected)?.error ?? null : null;
|
|
204
|
+
|
|
205
|
+
return (
|
|
206
|
+
<div className={`term-drawer svc-drawer${visible ? "" : " collapsed"}`} style={visible ? { height } : undefined}>
|
|
207
|
+
<div className="term-resize" onMouseDown={() => { dragging.current = true; document.body.style.userSelect = "none"; }} />
|
|
208
|
+
<div className="term-bar">
|
|
209
|
+
{Icon.sliders()}
|
|
210
|
+
<span className="term-title">服务</span>
|
|
211
|
+
{err && <span className="svc-err">⚠ {err}</span>}
|
|
212
|
+
<span style={{ flex: 1 }} />
|
|
213
|
+
<button className="icon-btn" onClick={onClose} title="隐藏服务面板(进程会继续运行)">{Icon.chevDown()}</button>
|
|
214
|
+
</div>
|
|
215
|
+
<div className="svc-body">
|
|
216
|
+
<div className="svc-list">
|
|
217
|
+
{services.length === 0 ? (
|
|
218
|
+
hasConfig ? (
|
|
219
|
+
<LoadNote style={{ padding: "14px 10px" }}>正在加载服务…</LoadNote>
|
|
220
|
+
) : (
|
|
221
|
+
<div className="svc-empty">
|
|
222
|
+
尚未配置服务。请在项目上下文(⚙)中添加 dev / setup / test 命令。
|
|
223
|
+
</div>
|
|
224
|
+
)
|
|
225
|
+
) : (
|
|
226
|
+
services.map((s) => (
|
|
227
|
+
<ServiceRow
|
|
228
|
+
key={s.name}
|
|
229
|
+
svc={s}
|
|
230
|
+
selected={selected === s.name}
|
|
231
|
+
onSelect={() => setSelected(s.name)}
|
|
232
|
+
onAction={(a) => act(s.name, a)}
|
|
233
|
+
onVisibility={(v) => act(s.name, "visibility", v)}
|
|
234
|
+
/>
|
|
235
|
+
))
|
|
236
|
+
)}
|
|
237
|
+
</div>
|
|
238
|
+
<div className="svc-log-pane">
|
|
239
|
+
{selectedError && <div className="svc-banner">⚠ {selectedError}</div>}
|
|
240
|
+
<LogView lines={current} />
|
|
241
|
+
</div>
|
|
242
|
+
</div>
|
|
243
|
+
</div>
|
|
244
|
+
);
|
|
245
|
+
}
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useContext, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
|
4
|
+
import { Icon } from "./icons";
|
|
5
|
+
import TaskChanges, { type ResolveResult } from "./TaskChanges";
|
|
6
|
+
import { BrowserPane } from "./BrowserPane";
|
|
7
|
+
import { fmtTokens } from "./format";
|
|
8
|
+
import { clientFeatures } from "./protocol/features";
|
|
9
|
+
import type { ServiceInfo } from "./protocol/types";
|
|
10
|
+
import type { ProjectRow, TaskRow } from "./types";
|
|
11
|
+
|
|
12
|
+
import { clientId } from "../../core/id";
|
|
13
|
+
import { useBoardState, readBoardState, visitBoardFile, reportBoardState, releaseBoardState, activateBoardState } from "./boardState";
|
|
14
|
+
import type { BoardView } from "../../board/workspace";
|
|
15
|
+
import { useBoardPanels } from "../../board/panels";
|
|
16
|
+
import { PanelControls, PanelTab } from "../../board/PanelControls";
|
|
17
|
+
import { FilesPane } from "../../board/FilesPane";
|
|
18
|
+
import { FileViewer } from "../../board/FileViewer";
|
|
19
|
+
import { getOperatorBoardBackend } from "./boardBackend";
|
|
20
|
+
import { useOperatorContextEvents, useOperatorFileWatch } from "./boardEvents";
|
|
21
|
+
import { OperatorBoardContext, registerOperatorBoard, OperatorBoardQuoteContext } from "./boardInteraction";
|
|
22
|
+
import type { Quote } from "./composerDraft";
|
|
23
|
+
import type { OperatorBoardOptions, OperatorBoardRequest } from "./boardInteraction";
|
|
24
|
+
type OpenFile = { reloadKey?:number; taskId: string; path: string; version: number; mtime?: number; size?: number };
|
|
25
|
+
const fileKey = (file: Pick<OpenFile,"taskId"|"path">) => `file:${file.taskId}:${file.path}`;
|
|
26
|
+
const LABEL:Record<string,string>={files:"文件",changes:"改动",context:"上下文",preview:"预览",browser:"浏览器"};
|
|
27
|
+
type Session = { n: number; summaryBefore: string | null };
|
|
28
|
+
|
|
29
|
+
// The live URL a project's dev server is reachable at when no registered
|
|
30
|
+
// service reports one — local dev hits the project's stable port directly.
|
|
31
|
+
function fallbackUrl(project: ProjectRow): string | null {
|
|
32
|
+
return project.port ? `http://localhost:${project.port}` : null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Prefer what the service registry reports (on a hosted instance that's the
|
|
36
|
+
// public <slug>--<host> URL, exactly what the agent/user should open).
|
|
37
|
+
function useLiveUrl(project: ProjectRow): string | null {
|
|
38
|
+
const [url, setUrl] = useState<string | null>(null);
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
let dead = false;
|
|
41
|
+
setUrl(null);
|
|
42
|
+
fetch(`/api/projects/${project.id}/services`)
|
|
43
|
+
.then((r) => (r.ok ? r.json() : null))
|
|
44
|
+
.then((j: { services?: ServiceInfo[] } | null) => {
|
|
45
|
+
if (dead || !j?.services) return;
|
|
46
|
+
const withUrl = j.services.filter((s) => s.url);
|
|
47
|
+
const live = withUrl.find((s) => s.status === "running" || s.status === "starting");
|
|
48
|
+
setUrl((live ?? withUrl[0])?.url ?? null);
|
|
49
|
+
})
|
|
50
|
+
.catch(() => { /* fall back below */ });
|
|
51
|
+
return () => { dead = true; };
|
|
52
|
+
}, [project.id]);
|
|
53
|
+
return url ?? fallbackUrl(project);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function PreviewPane({ project }: { project: ProjectRow }) {
|
|
57
|
+
const url = useLiveUrl(project);
|
|
58
|
+
const [copied, setCopied] = useState(false);
|
|
59
|
+
const display = url ? url.replace(/^https?:\/\//, "") : "未配置开发服务器";
|
|
60
|
+
const copy = async () => { if (!url) return; try { await navigator.clipboard.writeText(url); setCopied(true); setTimeout(() => setCopied(false), 1400); } catch { /* clipboard blocked */ } };
|
|
61
|
+
return (
|
|
62
|
+
<div className="rail-pad">
|
|
63
|
+
<div className="prev-frame">
|
|
64
|
+
<div className="prev-chrome">
|
|
65
|
+
<span className="prev-dots"><i /><i /><i /></span>
|
|
66
|
+
<span className="prev-addr"><span className="prev-live" />{display}</span>
|
|
67
|
+
</div>
|
|
68
|
+
<div className="prev-body">
|
|
69
|
+
<div className="prev-grid" />
|
|
70
|
+
<div className="prev-mock">
|
|
71
|
+
<div className="pm-title">{project.name}</div>
|
|
72
|
+
<div className="pm-bar w60" />
|
|
73
|
+
<div className="pm-bar w80" />
|
|
74
|
+
<div className="pm-rows"><div className="pm-row" /><div className="pm-row" /><div className="pm-row" /></div>
|
|
75
|
+
<div className="pm-cta">实时构建</div>
|
|
76
|
+
</div>
|
|
77
|
+
</div>
|
|
78
|
+
</div>
|
|
79
|
+
<div className="prev-actions">
|
|
80
|
+
{url ? <a className="btn btn-line" href={url} target="_blank" rel="noopener noreferrer">{Icon.external()} 打开预览</a> : <button className="btn btn-line" disabled>{Icon.external()} 打开预览</button>}
|
|
81
|
+
<button className="btn btn-line" onClick={copy} disabled={!url}>{copied ? Icon.check() : Icon.copy()} {copied ? "已复制" : "复制链接"}</button>
|
|
82
|
+
</div>
|
|
83
|
+
<div className="prev-note">每个项目一运行就有真实可访问的地址 —— 手机上打开、发给同事,都无需部署。</div>
|
|
84
|
+
</div>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function ContextPane({ task, sessions, running, onClear }: { task: TaskRow; sessions: Session[]; running: boolean; onClear: () => void }) {
|
|
89
|
+
const pct = Math.min(100, Math.max(0, Math.round(task.context_pct)));
|
|
90
|
+
return (
|
|
91
|
+
<div className="rail-pad">
|
|
92
|
+
<div className="ctxw-head">
|
|
93
|
+
<span className="rail-eyebrow">上下文窗口</span>
|
|
94
|
+
<span className="ctxw-pct">已用 {pct}%{task.context_tokens > 0 ? ` · ${fmtTokens(task.context_tokens)} tok` : ""}</span>
|
|
95
|
+
</div>
|
|
96
|
+
<div className="ctxw-meter"><div className="ctxw-fill" style={{ width: `${pct}%` }} /></div>
|
|
97
|
+
|
|
98
|
+
<div className="ctxw-timeline">
|
|
99
|
+
{sessions.map((s, i) => {
|
|
100
|
+
const current = i === sessions.length - 1;
|
|
101
|
+
return (
|
|
102
|
+
<div key={s.n} className={`ctxw-node ${current ? "current" : "done"}`}>
|
|
103
|
+
<span className="ctxw-dot" />
|
|
104
|
+
<div className="ctxw-name">第 {s.n} 个窗口 · {current ? "当前" : "已摘要"}</div>
|
|
105
|
+
<div className="ctxw-desc">
|
|
106
|
+
{current
|
|
107
|
+
? "已带着摘要继续 —— 任务线索被完整交接下来。"
|
|
108
|
+
: "已压缩为摘要 —— 清空上下文后任务脉络依然保留。"}
|
|
109
|
+
</div>
|
|
110
|
+
</div>
|
|
111
|
+
);
|
|
112
|
+
})}
|
|
113
|
+
</div>
|
|
114
|
+
|
|
115
|
+
<button className="btn btn-line ctxw-clear" onClick={onClear} disabled={running || task.started !== 1}>{Icon.clear()} 立即清空上下文</button>
|
|
116
|
+
<div className="ctxw-foot">清空会把对话压缩成摘要,并以此开启新的上下文窗口。</div>
|
|
117
|
+
</div>
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function SessionRail({ project, task, sessions, running, onResolveWithAI, onMerged, onPrCreated, onClear, onCollapse, onSwitchToChat,
|
|
122
|
+
tabs, availableTabs, defaultTab, autoContext, watchFiles = true, fileWatchIntervalMs, onFilesChange, onFilesWatch, onBoardContextChange, onEvent, openRequest, onRequestHandled, activity, onQuote }: OperatorBoardOptions & {
|
|
123
|
+
project: ProjectRow; task: TaskRow; sessions: Session[]; running: boolean;
|
|
124
|
+
onResolveWithAI: (taskId: string) => Promise<ResolveResult>; onMerged?: () => void;
|
|
125
|
+
onPrCreated?: (url: string) => void; onClear: () => void; onCollapse: () => void; onSwitchToChat: () => void;
|
|
126
|
+
openRequest?: OperatorBoardRequest | null; onRequestHandled?: (sequence:number)=>void; activity?: string; onQuote?: (quote:Quote)=>void;
|
|
127
|
+
}) {
|
|
128
|
+
const contextQuote=useContext(OperatorBoardQuoteContext);
|
|
129
|
+
const parentBoard=useContext(OperatorBoardContext);
|
|
130
|
+
const quote=onQuote??contextQuote;
|
|
131
|
+
const features=clientFeatures();
|
|
132
|
+
const available=(availableTabs ?? ['files','changes','context','browser','preview']).filter(id=>id in LABEL).filter(id=>id!=='browser'||features.browser).filter(id=>id!=='preview'||features.livePreview);
|
|
133
|
+
const defaults=tabs ?? ['files',...(features.browser?['browser']:[])];
|
|
134
|
+
const panels=useBoardPanels(`operator:${task.id}`,available,defaults);
|
|
135
|
+
const [owner]=useState(()=>clientId());
|
|
136
|
+
const boardState=useBoardState(task.id);
|
|
137
|
+
const [browserPage,setBrowserPage]=useState<{url:string;title:string}|null>(null);
|
|
138
|
+
const [tab,setTab]=useState<string>(defaultTab ?? defaults[0] ?? '');
|
|
139
|
+
const [files,setFiles]=useState<OpenFile[]>([]);
|
|
140
|
+
const [dir,setDir]=useState('');
|
|
141
|
+
const [folderTask,setFolderTask]=useState(task.id);
|
|
142
|
+
const [tick,setTick]=useState(0);
|
|
143
|
+
const callbacks={onFilesChange,onFilesWatch,onBoardContextChange,onEvent};
|
|
144
|
+
useOperatorFileWatch(task.id,watchFiles,parentBoard?{}:callbacks,event=>{setTick(value=>value+1);const paths=new Set(event.changes.map(change=>change.path));setFiles(previous=>previous.map(file=>file.taskId===task.id&&paths.has(file.path)?{...file,version:file.version+1,reloadKey:(file.reloadKey??0)+1}:file));},fileWatchIntervalMs);
|
|
145
|
+
useOperatorContextEvents(task.id,!parentBoard,callbacks);
|
|
146
|
+
|
|
147
|
+
const backend=useMemo(()=>getOperatorBoardBackend(),[]);
|
|
148
|
+
const [restored,setRestored]=useState(false);
|
|
149
|
+
const storageKey=`cli-ui:operator-files:${task.id}`;
|
|
150
|
+
useEffect(()=>{
|
|
151
|
+
try {
|
|
152
|
+
// Restore after hydration, not in state initializers: server and first
|
|
153
|
+
// client render must have the same tabs even when storage has history.
|
|
154
|
+
const snapshot=readBoardState(task.id);
|
|
155
|
+
if(snapshot){
|
|
156
|
+
setFiles((snapshot.opened??[]).slice(-8).map(file=>({taskId:file.sourceId??task.id,path:file.path,version:0})));
|
|
157
|
+
setTab(snapshot.tab);
|
|
158
|
+
}else{
|
|
159
|
+
const saved=JSON.parse(sessionStorage.getItem(storageKey)??'null');
|
|
160
|
+
if(Array.isArray(saved?.files)) setFiles(saved.files.filter((f:OpenFile)=>typeof f?.taskId==='string'&&typeof f?.path==='string').slice(-8).map((f:OpenFile)=>({...f,version:0})));
|
|
161
|
+
if(typeof saved?.active==='string') setTab(saved.active);
|
|
162
|
+
}
|
|
163
|
+
} catch { /* Invalid preferences do not block the board. */ }
|
|
164
|
+
setRestored(true);
|
|
165
|
+
},[storageKey]);
|
|
166
|
+
useEffect(()=>{if(restored)try{sessionStorage.setItem(storageKey,JSON.stringify({files:files.map(({taskId,path})=>({taskId,path})),active:tab}));}catch{}},[storageKey,restored,files,tab]);
|
|
167
|
+
const fileTabs=available.includes('files')?files.map(fileKey):[];
|
|
168
|
+
const shown=[...panels.open,...fileTabs];
|
|
169
|
+
const active=shown.includes(tab)?tab:shown[0]??'';
|
|
170
|
+
const tabsRef=useRef<HTMLDivElement>(null);
|
|
171
|
+
useLayoutEffect(()=>{
|
|
172
|
+
const reveal=()=>{const box=tabsRef.current,item=box?.querySelector<HTMLElement>('.rail-tab.on');if(!box||!item)return;
|
|
173
|
+
const outer=box.getBoundingClientRect(),inner=item.getBoundingClientRect();if(inner.left<outer.left)box.scrollLeft+=inner.left-outer.left;else if(inner.right>outer.right)box.scrollLeft+=inner.right-outer.right;};
|
|
174
|
+
reveal();if(typeof ResizeObserver==='undefined')return;const observer=new ResizeObserver(reveal);if(tabsRef.current)observer.observe(tabsRef.current);return()=>observer.disconnect();
|
|
175
|
+
},[active,shown.length]);
|
|
176
|
+
const file=files.find(f=>fileKey(f)===active);
|
|
177
|
+
const current=useRef(active);current.current=active;
|
|
178
|
+
const openFile=(taskId:string,path:string)=>{
|
|
179
|
+
if(!available.includes('files'))return;
|
|
180
|
+
const clean=path.replace(/^\.\//,'').replace(/\/+$/,'');if(!clean)return;
|
|
181
|
+
const id=fileKey({taskId,path:clean});
|
|
182
|
+
activateBoardState(task.id,owner);
|
|
183
|
+
visitBoardFile(task.id,{sourceId:taskId,path:clean});
|
|
184
|
+
setFiles(previous=>{
|
|
185
|
+
const found=previous.find(f=>fileKey(f)===id);
|
|
186
|
+
if(found)return previous.map(f=>fileKey(f)===id?{...f,version:f.version+1}:f);
|
|
187
|
+
const next=[...previous,{taskId,path:clean,version:0}];
|
|
188
|
+
if(next.length>8){const index=next.findIndex(f=>fileKey(f)!==current.current&&fileKey(f)!==id);next.splice(index<0?0:index,1);}
|
|
189
|
+
return next;
|
|
190
|
+
});setTab(id);
|
|
191
|
+
};
|
|
192
|
+
useEffect(()=>{
|
|
193
|
+
if(parentBoard)return;
|
|
194
|
+
return registerOperatorBoard(target=>{
|
|
195
|
+
if(target.taskId!==task.id)return false;
|
|
196
|
+
if(target.kind==='file'){if(!available.includes('files'))return false;openFile(target.taskId,target.path);return true;}
|
|
197
|
+
if(!panels.show('browser'))return false;setTab('browser');return true;
|
|
198
|
+
});
|
|
199
|
+
},[task.id,parentBoard,available.join(','),panels.show]);
|
|
200
|
+
const seen=useRef<number|null>(null);
|
|
201
|
+
useEffect(()=>{
|
|
202
|
+
if(!restored||!openRequest||seen.current===openRequest.sequence)return;
|
|
203
|
+
seen.current=openRequest.sequence;
|
|
204
|
+
const target=openRequest.target;
|
|
205
|
+
if(target.kind==='file')openFile(target.taskId,target.path);
|
|
206
|
+
else if(target.taskId===task.id&&panels.show('browser'))setTab('browser');
|
|
207
|
+
onRequestHandled?.(openRequest.sequence);
|
|
208
|
+
},[openRequest,restored]); // Requests originate in the owning session, including when this panel was collapsed.
|
|
209
|
+
const lastRefresh=useRef(0);
|
|
210
|
+
useEffect(()=>{const timer=setTimeout(()=>{lastRefresh.current=Date.now();setTick(n=>n+1);},Math.max(0,700-(Date.now()-lastRefresh.current)));return()=>clearTimeout(timer);},[activity,task.updated_at,running]);
|
|
211
|
+
useEffect(()=>{if(!running)return;const timer=setInterval(()=>{if(document.visibilityState==='visible')setTick(n=>n+1);},15000);return()=>clearInterval(timer);},[running]);
|
|
212
|
+
useEffect(()=>{
|
|
213
|
+
if(!file)return;let alive=true;
|
|
214
|
+
void backend.stat(file.taskId,file.path).then(stat=>{
|
|
215
|
+
if(!alive||stat.type!=='file')return;
|
|
216
|
+
setFiles(previous=>previous.map(f=>fileKey(f)===fileKey(file)&&f.mtime!==stat.mtime?{...f,mtime:stat.mtime,size:stat.size,version:f.mtime===undefined?f.version:f.version+1}:f));
|
|
217
|
+
},()=>{if(alive)setFiles(previous=>previous.map(f=>fileKey(f)===fileKey(file)&&f.mtime!==-1?{...f,mtime:-1,version:f.version+1}:f));});
|
|
218
|
+
return()=>{alive=false;};
|
|
219
|
+
},[backend,active,tick]);
|
|
220
|
+
const boardView:BoardView={tab:active,dir,file:file?.path??null,files:files.map(file=>file.path),
|
|
221
|
+
current:file?{path:file.path,sourceId:file.taskId}:null,opened:files.map(file=>({path:file.path,sourceId:file.taskId})),
|
|
222
|
+
history:boardState?.history??[],browser:active==='browser'?browserPage:null,visible:true,autoContext:autoContext!==false};
|
|
223
|
+
const viewSignature=JSON.stringify(boardView);
|
|
224
|
+
useEffect(()=>{if(restored)reportBoardState(task.id,owner,boardView);},[task.id,owner,restored,viewSignature]); // Metadata changes only; file refresh counters do not claim a new view.
|
|
225
|
+
useEffect(()=>()=>releaseBoardState(task.id,owner),[task.id,owner]);
|
|
226
|
+
const label=(id:string)=>LABEL[id]??files.find(f=>fileKey(f)===id)?.path.split('/').pop()??id;
|
|
227
|
+
const close=(id:string)=>{
|
|
228
|
+
if(id.startsWith('file:'))setFiles(previous=>previous.filter(f=>fileKey(f)!==id));else panels.close(id);
|
|
229
|
+
if(active===id)setTab(shown.find(item=>item!==id)??'');
|
|
230
|
+
};
|
|
231
|
+
return <aside className="sess-rail" aria-label="任务看板" onPointerDown={()=>activateBoardState(task.id,owner)} onClickCapture={()=>activateBoardState(task.id,owner)} onFocusCapture={()=>activateBoardState(task.id,owner)}>
|
|
232
|
+
<div className="rail-tabs">
|
|
233
|
+
<div className="operator-file-tabs" ref={tabsRef} role="tablist" aria-label="看板模块">
|
|
234
|
+
{shown.map(id=><PanelTab key={id} id={id} label={label(id)} active={active===id} onSelect={()=>{const opened=files.find(file=>fileKey(file)===id);if(opened)visitBoardFile(task.id,{path:opened.path,sourceId:opened.taskId});setTab(id);}} onClose={()=>close(id)}/>)}
|
|
235
|
+
</div>
|
|
236
|
+
<PanelControls available={available} open={panels.open} label={label} onShow={id=>{panels.show(id);setTab(id);}} onClose={close}/>
|
|
237
|
+
<button className="rail-collapse" title="刷新看板" aria-label="刷新看板" onClick={()=>setTick(n=>n+1)}>{Icon.restore()}</button>
|
|
238
|
+
<button className="rail-collapse" onClick={onCollapse} title="隐藏面板" aria-label="隐藏看板">{Icon.chevRight()}</button>
|
|
239
|
+
</div>
|
|
240
|
+
<div className="rail-scroll">
|
|
241
|
+
{!active&&<div className="cli-board-empty">看板模块已全部关闭。通过右上角“管理看板模块”重新打开。</div>}
|
|
242
|
+
{active==='changes'&&<>
|
|
243
|
+
{task.fork_commit&&<div className="fork-merge-note">基于「{task.fork_source_title}」派生。合并到 {project.branch} 时,也会包含来源分支尚未合并的改动。</div>}
|
|
244
|
+
<TaskChanges taskId={task.id} running={running} prUrl={task.pr_url} onMerged={onMerged} onPrCreated={onPrCreated} onResolveWithAI={async id=>{const result=await onResolveWithAI(id);if(result.ok&&!result.merged)onSwitchToChat();return result;}}/>
|
|
245
|
+
</>}
|
|
246
|
+
{active==='files'&&<FilesPane backend={backend} jobId={folderTask} dir={dir} onDir={setDir} onOpen={entry=>openFile(folderTask,entry.path)} refreshKey={tick}/>}
|
|
247
|
+
{file&&<>
|
|
248
|
+
{file.taskId!==task.id&&<div className="fork-merge-note">正在查看来源任务的文件,不会切换当前执行分支。</div>}
|
|
249
|
+
<FileViewer key={fileKey(file)} backend={backend} jobId={file.taskId} path={file.path} size={file.size} mtime={file.mtime} version={file.version} reloadKey={file.reloadKey}
|
|
250
|
+
onOpenPath={path=>openFile(file.taskId,path)}
|
|
251
|
+
actions={quote?[{label:'引用到对话',onClick:()=>quote({id:`file:${file.taskId}:${file.path}`,author:`文件 ${file.path}`,text:`${file.taskId===task.id?'当前工作区':`来源任务 ${file.taskId} 的工作区`}文件 ${file.path}`})}]:[]}
|
|
252
|
+
onQuoteText={quote?text=>quote({id:`selection:${file.taskId}:${file.path}`,author:`${file.taskId===task.id?"当前工作区":`来源任务 ${file.taskId} 的工作区`}文件 ${file.path}`,text}):undefined}
|
|
253
|
+
lead={<button className="cli-fv-back" title="返回文件列表" onClick={()=>{setFolderTask(file.taskId);setDir(file.path.includes('/')?file.path.slice(0,file.path.lastIndexOf('/')):'');panels.show('files');setTab('files');}}>‹</button>}/>
|
|
254
|
+
</>}
|
|
255
|
+
{active==='preview'&&features.livePreview&&<PreviewPane project={project}/>}
|
|
256
|
+
{active==='context'&&<ContextPane task={task} sessions={sessions} running={running} onClear={onClear}/>}
|
|
257
|
+
{active==='browser'&&features.browser&&<BrowserPane key={task.id} taskId={task.id} onPage={setBrowserPage} onQuote={quote?page=>quote({id:`browser:${task.id}`,author:"浏览器页面",text:`${page.title?`${page.title} — `:""}${page.url}`}):undefined} onQuoteText={quote?(text,page)=>quote({id:`browser-selection:${task.id}`,author:`网页 ${page.title||page.url}`,text}):undefined}/>}
|
|
258
|
+
</div>
|
|
259
|
+
</aside>;
|
|
260
|
+
}
|