@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.
Files changed (133) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +145 -13
  3. package/dist/cli-ui-embed-BmLN2Jjg.js +43290 -0
  4. package/dist/{cli-ui-index-BFpRZXn4.js → cli-ui-index-D4qfPHMd.js} +2 -2
  5. package/dist/{cli-ui-jszip.min-B9tqMA-e.js → cli-ui-jszip.min-DC9898pC.js} +1 -1
  6. package/dist/{cli-ui-jszip.min-DIavJzUG.js → cli-ui-jszip.min-DpEl0G1z.js} +2 -2
  7. package/dist/{cli-ui-xlsx-BKHhqcoa.js → cli-ui-xlsx-zs7oEtPj.js} +1 -1
  8. package/dist/cli-ui.css +32 -1
  9. package/dist/cli-ui.js +1 -1
  10. package/package.json +14 -8
  11. package/src/board/BrowserPane.tsx +160 -74
  12. package/src/board/ChangesPane.tsx +2 -2
  13. package/src/board/CliBoard.tsx +79 -50
  14. package/src/board/FileViewer.tsx +32 -22
  15. package/src/board/FilesPane.tsx +32 -15
  16. package/src/board/PanelControls.tsx +28 -0
  17. package/src/board/blocks.tsx +5 -5
  18. package/src/board/context.ts +65 -0
  19. package/src/board/fileOps.ts +26 -3
  20. package/src/board/fileWatch.ts +88 -0
  21. package/src/board/folderDownload.ts +30 -14
  22. package/src/board/panels.ts +33 -0
  23. package/src/board/textLoad.ts +2 -2
  24. package/src/board/workspace.ts +38 -9
  25. package/src/chat/CliChat.tsx +113 -53
  26. package/src/chat/Composer.tsx +68 -57
  27. package/src/chat/RunControls.tsx +99 -0
  28. package/src/chat/configuration.ts +15 -0
  29. package/src/chat/draft.ts +41 -0
  30. package/src/chat/export.ts +2 -2
  31. package/src/components.tsx +13 -0
  32. package/src/core/blocks.ts +2 -2
  33. package/src/core/callbacks.ts +8 -0
  34. package/src/core/client.ts +16 -6
  35. package/src/core/config.ts +11 -15
  36. package/src/core/conversation.ts +4 -4
  37. package/src/core/id.ts +15 -0
  38. package/src/core/identity.ts +33 -0
  39. package/src/core/provider.tsx +24 -14
  40. package/src/core/store.ts +83 -26
  41. package/src/core/types.ts +34 -0
  42. package/src/elements.tsx +135 -29
  43. package/src/embed.ts +6 -8
  44. package/src/index.ts +24 -17
  45. package/src/layout/CliSplit.tsx +1 -1
  46. package/src/msglist/CliMsgList.tsx +10 -10
  47. package/src/operator/branchFlow.ts +35 -0
  48. package/src/operator/index.ts +9 -0
  49. package/src/operator/liveStream.ts +1 -0
  50. package/src/operator/node/AgentConnect.tsx +624 -0
  51. package/src/operator/node/AppearancePanel.tsx +35 -0
  52. package/src/operator/node/BranchHistory.tsx +67 -0
  53. package/src/operator/node/BranchNavigator.tsx +36 -0
  54. package/src/operator/node/BrowserPane.tsx +128 -0
  55. package/src/operator/node/BrowserPopup.tsx +22 -0
  56. package/src/operator/node/Chat.tsx +517 -0
  57. package/src/operator/node/CommandPalette.tsx +213 -0
  58. package/src/operator/node/Composer.tsx +287 -0
  59. package/src/operator/node/InsightsView.tsx +831 -0
  60. package/src/operator/node/Layout.tsx +190 -0
  61. package/src/operator/node/Markdown.tsx +61 -0
  62. package/src/operator/node/Modal.tsx +179 -0
  63. package/src/operator/node/NeedsYouMenu.tsx +54 -0
  64. package/src/operator/node/OnboardingWizard.tsx +250 -0
  65. package/src/operator/node/Orchestrator.tsx +634 -0
  66. package/src/operator/node/ProjectLanding.tsx +86 -0
  67. package/src/operator/node/ProjectsColumn.tsx +137 -0
  68. package/src/operator/node/ProviderAuth.tsx +561 -0
  69. package/src/operator/node/RunControls.tsx +2 -0
  70. package/src/operator/node/RunSwitch.tsx +113 -0
  71. package/src/operator/node/Services.tsx +245 -0
  72. package/src/operator/node/SessionRail.tsx +260 -0
  73. package/src/operator/node/SessionView.tsx +288 -0
  74. package/src/operator/node/SettingsView.tsx +690 -0
  75. package/src/operator/node/TaskBoard.tsx +352 -0
  76. package/src/operator/node/TaskChanges.tsx +530 -0
  77. package/src/operator/node/TaskHero.tsx +50 -0
  78. package/src/operator/node/TasksColumn.tsx +200 -0
  79. package/src/operator/node/Terminal.tsx +143 -0
  80. package/src/operator/node/Transcript.tsx +463 -0
  81. package/src/operator/node/ViewFile.tsx +79 -0
  82. package/src/operator/node/Welcome.tsx +101 -0
  83. package/src/operator/node/WorktreePrune.tsx +333 -0
  84. package/src/operator/node/agents.ts +58 -0
  85. package/src/operator/node/api.ts +40 -0
  86. package/src/operator/node/boardBackend.ts +80 -0
  87. package/src/operator/node/boardEvents.ts +22 -0
  88. package/src/operator/node/boardInteraction.ts +15 -0
  89. package/src/operator/node/boardState.ts +57 -0
  90. package/src/operator/node/boardWorkspace.ts +12 -0
  91. package/src/operator/node/branchFlow.ts +1 -0
  92. package/src/operator/node/composerDraft.ts +52 -0
  93. package/src/operator/node/fileLinks.ts +81 -0
  94. package/src/operator/node/format.ts +290 -0
  95. package/src/operator/node/github.tsx +234 -0
  96. package/src/operator/node/icons.tsx +88 -0
  97. package/src/operator/node/liveStream.ts +270 -0
  98. package/src/operator/node/modals.tsx +720 -0
  99. package/src/operator/node/navHistory.ts +70 -0
  100. package/src/operator/node/persist.ts +26 -0
  101. package/src/operator/node/protocol/agents/optIn.ts +9 -0
  102. package/src/operator/node/protocol/agents/types.ts +394 -0
  103. package/src/operator/node/protocol/approvalFailure.ts +77 -0
  104. package/src/operator/node/protocol/authFailure.ts +66 -0
  105. package/src/operator/node/protocol/customModels.ts +23 -0
  106. package/src/operator/node/protocol/events.ts +13 -0
  107. package/src/operator/node/protocol/features.ts +70 -0
  108. package/src/operator/node/protocol/internalUsage.ts +1 -0
  109. package/src/operator/node/protocol/promptLimits.ts +52 -0
  110. package/src/operator/node/protocol/storageRetention.ts +13 -0
  111. package/src/operator/node/protocol/types.ts +332 -0
  112. package/src/operator/node/protocol/usageLimit.ts +72 -0
  113. package/src/operator/node/shared.tsx +89 -0
  114. package/src/operator/node/transcriptModel.ts +129 -0
  115. package/src/operator/node/types.ts +296 -0
  116. package/src/operator/node/useGlobalEvents.ts +97 -0
  117. package/src/operator/node/useOrchestrator.ts +696 -0
  118. package/src/operator/node/usePrefs.ts +114 -0
  119. package/src/operator/node/useRecaps.ts +91 -0
  120. package/src/operator/node/useTaskStream.ts +279 -0
  121. package/src/operator.base.css +1798 -0
  122. package/src/operator.css +186 -16
  123. package/src/styles.css +51 -0
  124. package/src/ui/Markdown.tsx +7 -2
  125. package/src/ui/Popover.tsx +77 -0
  126. package/src/ui/TextBlock.tsx +3 -3
  127. package/src/ui/Transcript.tsx +10 -8
  128. package/src/ui/fileLinks.ts +6 -0
  129. package/src/ui/format.ts +1 -1
  130. package/src/ui/icons.tsx +1 -0
  131. package/src/ui/shared.tsx +1 -1
  132. package/src/ui/transcriptModel.ts +2 -2
  133. package/dist/cli-ui-embed-Dk2QGHxn.js +0 -41146
@@ -0,0 +1,35 @@
1
+ "use client";
2
+
3
+ import { Icon } from "./icons";
4
+ import type { Appearance } from "./types";
5
+
6
+ export function AppearancePanel({ appearance, setAppearance, onClose }: { appearance: Appearance; setAppearance: (k: keyof Appearance, v: string) => void; onClose: () => void }) {
7
+ const Row = ({ label, children }: { label: string; children: React.ReactNode }) => (
8
+ <div style={{ marginBottom: 16 }}>
9
+ <div style={{ fontFamily: "var(--mono)", fontSize: 10, fontWeight: 700, letterSpacing: ".07em", textTransform: "uppercase", color: "var(--ink-3)", marginBottom: 8 }}>{label}</div>
10
+ {children}
11
+ </div>
12
+ );
13
+ return (
14
+ <div className="modal" style={{ position: "fixed", right: 16, bottom: 16, width: 264, zIndex: 300, animation: "pop .16s ease" }}>
15
+ <div className="modal-h" style={{ padding: "14px 16px 12px" }}>
16
+ <div className="m-title" style={{ fontSize: 15, flex: 1, display: "flex", alignItems: "center", gap: 8 }}>{Icon.sliders()} 外观</div>
17
+ <button className="modal-close" onClick={onClose}>{Icon.x()}</button>
18
+ </div>
19
+ <div className="modal-b" style={{ padding: 16 }}>
20
+ <Row label="模式">
21
+ <div className="seg">
22
+ <button className={appearance.theme === "light" ? "on" : ""} onClick={() => setAppearance("theme", "light")}>{Icon.sun()} 浅色</button>
23
+ <button className={appearance.theme === "dark" ? "on" : ""} onClick={() => setAppearance("theme", "dark")}>{Icon.moon()} 深色</button>
24
+ </div>
25
+ </Row>
26
+ <Row label={`密度 · ${appearance.density === "1" ? "宽松" : "紧凑"}`}>
27
+ <div className="seg">
28
+ <button className={appearance.density === "1" ? "on" : ""} onClick={() => setAppearance("density", "1")}>宽松</button>
29
+ <button className={appearance.density === "0.72" ? "on" : ""} onClick={() => setAppearance("density", "0.72")}>紧凑</button>
30
+ </div>
31
+ </Row>
32
+ </div>
33
+ </div>
34
+ );
35
+ }
@@ -0,0 +1,67 @@
1
+ "use client";
2
+ import { useEffect, useState } from "react";
3
+ import { buildSessions } from "./format";
4
+ import { FileLinkContext } from "./fileLinks";
5
+ import { MessageView, SessionBreak } from "./Transcript";
6
+ import { BranchNavigator } from "./BranchNavigator";
7
+ import { branchAncestry, messagesAtFork, type BranchStep } from "./branchFlow";
8
+ import { agentLabel } from "./agents";
9
+ import { jget } from "./api";
10
+ import { ErrNote, LoadNote } from "./shared";
11
+ import type { AgentsBundle, Msg, TaskRow } from "./types";
12
+
13
+ type History = { messages?: Msg[]; error?: string };
14
+ // Reusing a common prefix keeps branch switches immediate. Keys include the
15
+ // source version; failures are retryable and never become permanent cache hits.
16
+ const cache = new Map<string, Msg[]>();
17
+ const keyFor = (source: TaskRow) => `${source.id}:${source.updated_at}`;
18
+
19
+ export function useBranchHistory(task: TaskRow, tasks: TaskRow[]) {
20
+ const steps = branchAncestry(task, tasks);
21
+ const signature = JSON.stringify(steps.map(({ source, child }) => [source && keyFor(source), child.id, child.created_at]));
22
+ const [loaded, setLoaded] = useState<Record<string, History>>({});
23
+ const [retry, setRetry] = useState(0);
24
+ useEffect(() => {
25
+ let dead = false;
26
+ for (const { source } of steps) {
27
+ if (!source || cache.has(keyFor(source))) continue;
28
+ const key = keyFor(source);
29
+ setLoaded((prev) => ({ ...prev, [key]: {} }));
30
+ void jget<TaskRow & { messages: (Msg & { created_at?: number })[] }>(`/api/tasks/${source.id}`).then((data) => {
31
+ if (data.project_id !== task.project_id) throw new Error("来源会话不属于当前项目。");
32
+ const messages = data.messages.map((m) => ({ ...m, ts: m.created_at ?? m.ts }));
33
+ cache.set(key, messages);
34
+ while (cache.size > 24) cache.delete(cache.keys().next().value!);
35
+ if (!dead) setLoaded((prev) => ({ ...prev, [key]: { messages } }));
36
+ }).catch((error: unknown) => {
37
+ if (!dead) setLoaded((prev) => ({ ...prev, [key]: { error: error instanceof Error ? error.message : "来源会话加载失败" } }));
38
+ });
39
+ }
40
+ return () => { dead = true; };
41
+ // The signature represents the path and source versions, not the changing
42
+ // array identity from the selected task's live stream.
43
+ // eslint-disable-next-line react-hooks/exhaustive-deps
44
+ }, [signature, retry]);
45
+ const entries = steps.map((step) => ({ ...step, history: step.source ? { ...loaded[keyFor(step.source)], ...(cache.has(keyFor(step.source)) ? { messages: cache.get(keyFor(step.source)) } : {}) } : {} }));
46
+ return { entries, ready: entries.every(({ source, history }) => !source || !!history.messages || !!history.error), retry: () => setRetry((n) => n + 1) };
47
+ }
48
+
49
+ export function BranchHistory({ entries, tasks, agents, onSelect, onQuote, onRetry }: {
50
+ entries: (BranchStep<TaskRow> & { history: History })[]; tasks: TaskRow[]; agents: AgentsBundle;
51
+ onSelect: (id: string) => void; onQuote: (m: Msg, source: TaskRow, selected?: string) => void; onRetry: () => void;
52
+ }) {
53
+ return <>{entries.map(({ source, child, history }) => <div className="branch-history-step" key={child.id}>
54
+ <section className="flow-history" data-flow-task-id={source?.id || child.parent_task_id} aria-label={`来源会话:${source?.title || child.fork_source_title}`}>
55
+ <div className="flow-task-heading"><span>来源会话</span>{source ? <button onClick={() => onSelect(source.id)} title="打开来源任务">{source.title}</button> : <strong>{child.fork_source_title}</strong>}</div>
56
+ {!source ? <div className="flow-history-note">来源任务已删除,保留派生时的上下文。{child.fork_context && <details><summary>查看保存的上下文</summary><pre>{child.fork_context}</pre></details>}</div>
57
+ : history.error ? <ErrNote onRetry={onRetry}>{history.error}</ErrNote>
58
+ : !history.messages ? <LoadNote>正在加载来源会话…</LoadNote>
59
+ : <FileLinkContext.Provider value={{taskId:source.id}}>{buildSessions(messagesAtFork(history.messages, child)).map((session, si) => <div key={session.n}>
60
+ {si > 0 && session.summaryBefore && <SessionBreak summary={session.summaryBefore} />}
61
+ <div className="session-label"><span className="ln" />第 {session.n} 次会话 · 派生前<span className="ln" /></div>
62
+ {session.messages.map((m, mi) => <MessageView key={m.id} m={m} initial={mi === 0 && m.role === "user"} hideWho={m.role === "assistant" && ["assistant", "tool"].includes(session.messages[mi - 1]?.role)} agent={source.agent} agentLabel={agentLabel(agents, source.agent)} readOnly onQuote={(msg, selected) => onQuote(msg, source, selected)} />)}
63
+ </div>)}</FileLinkContext.Provider>}
64
+ </section>
65
+ <BranchNavigator sourceId={child.parent_task_id!} projectId={child.project_id} tasks={tasks} selected={child} onSelect={onSelect} />
66
+ </div>)}</>;
67
+ }
@@ -0,0 +1,36 @@
1
+ "use client";
2
+ import { useEffect, useRef } from "react";
3
+ import { Icon } from "./icons";
4
+ import { SLABEL, type TaskRow } from "./types";
5
+ import { branchOptions } from "./branchFlow";
6
+
7
+ export function BranchNavigator({ sourceId, projectId, tasks, selected, completed = true, onSelect }: {
8
+ sourceId: string; projectId: string; tasks: TaskRow[]; selected?: TaskRow; completed?: boolean; onSelect: (id: string) => void;
9
+ }) {
10
+ const rail = useRef<HTMLDivElement>(null);
11
+ const branches = branchOptions(sourceId, projectId, tasks, selected);
12
+ useEffect(() => {
13
+ const strip = rail.current;
14
+ const active = strip?.querySelector<HTMLElement>("[aria-current='page']");
15
+ if (!strip || !active) return;
16
+ const item = active.getBoundingClientRect(), bounds = strip.getBoundingClientRect();
17
+ if (item.left < bounds.left) strip.scrollLeft += item.left - bounds.left;
18
+ else if (item.right > bounds.right) strip.scrollLeft += item.right - bounds.right;
19
+ }, [selected?.id]);
20
+ if (!branches.length) return null;
21
+ return <div className="branch-nav" data-parent-id={sourceId} aria-label="任务分支">
22
+ <div className="branch-ending"><span>{completed ? "此处任务已完成" : "此任务的后续分支"}</span></div>
23
+ <div className="branch-nav-head"><span>{Icon.git()} 分支路径</span><span className="branch-nav-hint">选择分支,在下方继续</span>
24
+ {branches.length > 2 && <>
25
+ <button className="icon-btn" aria-label="向左查看分支" onClick={() => rail.current?.scrollBy({ left: -(rail.current.clientWidth / 2), behavior: "smooth" })}>{Icon.chevRight({ style: { transform: "rotate(180deg)" } })}</button>
26
+ <button className="icon-btn" aria-label="向右查看分支" onClick={() => rail.current?.scrollBy({ left: rail.current.clientWidth / 2, behavior: "smooth" })}>{Icon.chevRight()}</button>
27
+ </>}
28
+ </div>
29
+ <div ref={rail} className="branch-cards">
30
+ {branches.map((t, index) => <button key={t.id} className={`branch-card${t.id === selected?.id ? " selected" : ""}`} aria-current={t.id === selected?.id ? "page" : undefined} onClick={() => onSelect(t.id)} title={`${t.work_branch || ""} · ${t.fork_commit?.slice(0, 7) || ""}`}>
31
+ <span className="branch-card-meta"><span>分支 {index + 1}</span><span>{t.running ? "运行中" : SLABEL[t.status]}</span></span>
32
+ <strong>{t.title}</strong><small>派生自 {t.fork_source_title}</small>
33
+ </button>)}
34
+ </div>
35
+ </div>;
36
+ }
@@ -0,0 +1,128 @@
1
+ "use client";
2
+ import { useMemo, useState } from "react";
3
+ import { Icon } from "./icons";
4
+ import { BrowserPane as BrowserSurface } from "../../board/BrowserPane";
5
+ import { createOperatorBoardBackend } from "./boardBackend";
6
+ import type { BrowserStatus } from "../../core/types";
7
+ interface EgoProfile { id: string; name: string; isDefault: boolean }
8
+ interface ImportSource { browser: string; displayName: string; profiles: { dir: string; name: string; email: string; isDefault: boolean }[] }
9
+ interface ProfilesState { selected: string | null; profiles: EgoProfile[]; sources?: ImportSource[]; imported?: string; error?: string }
10
+
11
+ /**
12
+ * The node's login profile (shared by every task here): which ego profile new
13
+ * browsers open in, switching it, and importing the sign-ins of an everyday
14
+ * browser on this Mac (fewer sign-in walls and CAPTCHAs). Loads only when
15
+ * expanded — listing may start the node's browser.
16
+ */
17
+ function LoginProfiles() {
18
+ const [open, setOpen] = useState(false);
19
+ const [state, setState] = useState<ProfilesState | null>(null);
20
+ const [busy, setBusy] = useState<string | null>(null);
21
+ const [error, setError] = useState<string | null>(null);
22
+ const [done, setDone] = useState<string | null>(null);
23
+
24
+ const call = async (init?: RequestInit, sources = true) => {
25
+ const res = await fetch(`/api/browser/profiles${sources ? "?sources=1" : ""}`, init);
26
+ const j = (await res.json().catch(() => null)) as ProfilesState | null;
27
+ if (!res.ok || !j || j.error) throw new Error(j?.error ?? `请求失败(${res.status})`);
28
+ return j;
29
+ };
30
+ const load = async () => {
31
+ setBusy("load");
32
+ setError(null);
33
+ try { setState(await call()); } catch (e) { setError(e instanceof Error ? e.message : String(e)); } finally { setBusy(null); }
34
+ };
35
+ const post = async (key: string, body: Record<string, unknown>, okText: (j: ProfilesState) => string) => {
36
+ setBusy(key);
37
+ setError(null);
38
+ setDone(null);
39
+ try {
40
+ const j = await call({ method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) }, false);
41
+ setState((prev) => ({ ...j, sources: prev?.sources }));
42
+ setDone(okText(j));
43
+ } catch (e) {
44
+ setError(e instanceof Error ? e.message : String(e));
45
+ } finally {
46
+ setBusy(null);
47
+ }
48
+ };
49
+
50
+ const current = state ? state.profiles.find((p) => p.id === state.selected) ?? state.profiles.find((p) => p.isDefault) : null;
51
+ return (
52
+ <div className="bp-logins">
53
+ <button className="bp-logins-head" onClick={() => { const next = !open; setOpen(next); if (next && !state) void load(); }}>
54
+ <span className="rail-eyebrow">登录状态 · 本节点所有任务共享</span>
55
+ <span className="bp-meta">{current ? current.name : open ? "" : "展开"}</span>
56
+ <span style={{ flex: 1 }} />
57
+ <span className="bp-meta">{open ? "收起" : "管理"}</span>
58
+ </button>
59
+ {open && (
60
+ <div className="bp-logins-body">
61
+ {busy === "load" && <div className="prev-note">读取中…(第一次会先启动浏览器)</div>}
62
+ {state && (
63
+ <>
64
+ <div className="bp-logins-sub">浏览器使用的配置</div>
65
+ {state.profiles.map((p) => {
66
+ const using = p.id === (state.selected ?? state.profiles.find((x) => x.isDefault)?.id);
67
+ return (
68
+ <div key={p.id} className="bp-logins-row">
69
+ <span className="bp-logins-name">{p.name}<span className="bp-meta"> · {p.id}</span></span>
70
+ {using
71
+ ? <span className="bp-badge ai">使用中</span>
72
+ : <button className="btn btn-line btn-sm" disabled={!!busy} onClick={() => post(`select:${p.id}`, { action: "select", id: p.id }, () => `已切换到「${p.name}」,之后新打开的浏览器会用它。`)}>使用</button>}
73
+ </div>
74
+ );
75
+ })}
76
+ <div className="bp-logins-sub">从这台 Mac 上的浏览器导入登录状态</div>
77
+ {(state.sources ?? []).length === 0 && <div className="prev-note">没有找到可导入的浏览器。</div>}
78
+ {(state.sources ?? []).map((s) => (
79
+ <div key={s.browser}>
80
+ <div className="bp-meta" style={{ margin: "6px 0 4px" }}>{s.displayName}</div>
81
+ {s.profiles.map((p) => (
82
+ <div key={p.dir} className="bp-logins-row">
83
+ <span className="bp-logins-name">{p.name}{p.email && <span className="bp-meta"> · {p.email}</span>}</span>
84
+ <button className="btn btn-line btn-sm" disabled={!!busy} onClick={() => post(`import:${s.browser}:${p.dir}`, { action: "import", browser: s.browser, profile: p.dir }, (j) => `已从 ${s.displayName}「${p.name}」导入,并设为本节点浏览器的登录配置(${j.imported ?? ""})。`)}>
85
+ {busy === `import:${s.browser}:${p.dir}` ? "导入中(约 15 秒)…" : "导入"}
86
+ </button>
87
+ </div>
88
+ ))}
89
+ </div>
90
+ ))}
91
+ <div className="prev-note">
92
+ 导入会复制该浏览器配置的登录状态、Cookie 和浏览记录,新建一份配置并设为当前使用;不会改动原浏览器,也不会把 ego 设为默认浏览器。
93
+ 切换只影响之后新打开的浏览器,已经打开的保持不变。
94
+ </div>
95
+ </>
96
+ )}
97
+ {done && <div className="bp-ok">{done}</div>}
98
+ {error && <div className="bp-error">{error}</div>}
99
+ </div>
100
+ )}
101
+ </div>
102
+ );
103
+ }
104
+
105
+ export function BrowserPane({ taskId, popup=false, onQuote, onQuoteText, onPage }: {
106
+ taskId:string; popup?:boolean; onQuote?:(page:{url:string;title:string})=>void;
107
+ onQuoteText?:(text:string,page:{url:string;title:string})=>void;
108
+ onPage?:(page:{url:string;title:string}|null)=>void;
109
+ }) {
110
+ const backend=useMemo(()=>createOperatorBoardBackend(),[]);
111
+ const [busy,setBusy]=useState(false);
112
+ const [error,setError]=useState<string|null>(null);
113
+ const localAction=async(status:BrowserStatus,update:(status:BrowserStatus)=>void)=>{
114
+ if(busy)return;setBusy(true);setError(null);
115
+ try{
116
+ const response=await fetch(`/api/tasks/${encodeURIComponent(taskId)}/browser`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:status.shownLocally?'park':'show'})});
117
+ const result=await response.json();if(!response.ok)throw new Error(result.error||`操作失败(${response.status})`);if(result.state)update(result);
118
+ }catch(error){setError(error instanceof Error?error.message:String(error));}finally{setBusy(false);}
119
+ };
120
+ return <BrowserSurface key={taskId} backend={backend} jobId={taskId} className={popup?'bp-popup':undefined} onQuote={onQuote} onQuoteText={onQuoteText} onPage={onPage}
121
+ footer={!popup?<LoginProfiles/>:undefined} extension={(status,update)=><>
122
+ {status.state==='running'&&<div className="bp-row">
123
+ {!popup&&<button className="btn btn-line btn-sm" onClick={()=>window.open(`/browser/${encodeURIComponent(taskId)}`,`browser-${taskId}`,'width=1320,height=960')}>{Icon.external()} 弹出窗口</button>}
124
+ <button className="btn btn-ghost btn-sm" disabled={busy} onClick={()=>void localAction(status,update)}>{status.shownLocally?'收起节点上的窗口':'在节点屏幕上显示'}</button>
125
+ </div>}
126
+ {error&&<div role="alert" className="bp-error">{error}</div>}
127
+ </>}/>;
128
+ }
@@ -0,0 +1,22 @@
1
+ "use client";
2
+
3
+ // Pop-out window for a task's real browser (docs/BROWSER_PLAN.md §6.4): the
4
+ // same pane as the session rail, full size. Streams only while this window is
5
+ // open and visible.
6
+
7
+ import { use, useLayoutEffect, useState } from "react";
8
+ import { BrowserPane } from "./BrowserPane";
9
+ import { loadPersist } from "./persist";
10
+ import { clientFeatures } from "./protocol/features";
11
+
12
+ export default function BrowserPopup({ params }: { params: Promise<{ taskId: string }> }) {
13
+ const { taskId } = use(params);
14
+ const [theme,setTheme]=useState("dark");
15
+ useLayoutEffect(()=>{const theme=loadPersist().appearance?.theme??"dark";setTheme(theme);document.documentElement.setAttribute("data-theme",theme);},[]);
16
+ if (!clientFeatures().browser) return <div className="rail-pad"><div className="prev-note">浏览器功能未开启</div></div>;
17
+ return (
18
+ <main className="cli-ui bp-popup-page" data-theme={theme}>
19
+ <BrowserPane taskId={taskId} popup />
20
+ </main>
21
+ );
22
+ }