@spzxg/cli-ui 0.6.5 → 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 +23 -0
- package/README.md +33 -13
- package/dist/cli-ui-embed-BAqonuoD.js +45952 -0
- package/dist/{cli-ui-index-D4qfPHMd.js → cli-ui-index-b8wF7wrN.js} +2 -2
- package/dist/{cli-ui-jszip.min-DpEl0G1z.js → cli-ui-jszip.min-B0uLrCHg.js} +2 -2
- package/dist/{cli-ui-jszip.min-DC9898pC.js → cli-ui-jszip.min-ggP8OokP.js} +1 -1
- package/dist/{cli-ui-xlsx-zs7oEtPj.js → cli-ui-xlsx-DEDdHrJJ.js} +1 -1
- package/dist/cli-ui.css +1 -1
- package/dist/cli-ui.js +1 -1
- package/package.json +9 -8
- package/src/board/BrowserPane.tsx +198 -53
- package/src/board/FileViewer.tsx +22 -9
- package/src/board/fileWatch.ts +9 -3
- package/src/board/frameQuote.ts +2 -2
- package/src/board/textLoad.ts +7 -5
- package/src/board/workspace.ts +15 -5
- package/src/chat/CliChat.tsx +21 -11
- package/src/chat/Composer.tsx +4 -2
- package/src/core/blocks.ts +15 -4
- package/src/core/client.ts +96 -14
- package/src/core/conversation.ts +1 -0
- package/src/core/provider.tsx +7 -0
- package/src/core/store.ts +68 -27
- package/src/core/types.ts +30 -9
- package/src/layout/split.ts +67 -30
- package/src/msglist/CliMsgList.tsx +9 -3
- package/src/operator/node/Transcript.tsx +7 -2
- package/src/operator/node/boardBackend.ts +6 -4
- package/src/operator/node/protocol/types.ts +2 -2
- package/src/operator/node/useTaskStream.ts +5 -4
- package/src/operator.base.css +8 -0
- package/src/operator.css +8 -0
- package/src/styles.css +29 -1
- package/src/ui/Popover.tsx +10 -5
- package/src/ui/Transcript.tsx +7 -2
- package/dist/cli-ui-embed-BmLN2Jjg.js +0 -43290
package/src/core/types.ts
CHANGED
|
@@ -43,7 +43,13 @@ export interface CenterTurn {
|
|
|
43
43
|
queue_position?: number | null;
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
-
|
|
46
|
+
/** Node-observed human wait state. Missing/null means unavailable, not confirmed idle. */
|
|
47
|
+
export interface AwaitingInputState {
|
|
48
|
+
awaiting_input?: boolean | null;
|
|
49
|
+
awaiting_input_observed_at?: string | null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface CenterTask extends AwaitingInputState {
|
|
47
53
|
id: string;
|
|
48
54
|
job_id: string;
|
|
49
55
|
seq?: number;
|
|
@@ -63,7 +69,7 @@ export interface CenterTask {
|
|
|
63
69
|
}
|
|
64
70
|
|
|
65
71
|
/** 对话任务里的一个对话(一个子任务、一个 Agent 会话)。conversation.conversations 的一项。 */
|
|
66
|
-
export interface ConversationInfo {
|
|
72
|
+
export interface ConversationInfo extends AwaitingInputState {
|
|
67
73
|
task_id: string;
|
|
68
74
|
seq: number;
|
|
69
75
|
title: string;
|
|
@@ -79,7 +85,7 @@ export interface ConversationInfo {
|
|
|
79
85
|
created_at: string | null;
|
|
80
86
|
}
|
|
81
87
|
|
|
82
|
-
export interface ConversationSummary {
|
|
88
|
+
export interface ConversationSummary extends AwaitingInputState {
|
|
83
89
|
/** 里面的每个对话(按开的先后)。旧版 center 没有这个字段:当作只有一个对话。 */
|
|
84
90
|
conversations?: ConversationInfo[];
|
|
85
91
|
turns: number;
|
|
@@ -117,6 +123,12 @@ export interface SendResult {
|
|
|
117
123
|
turn: CenterTurn;
|
|
118
124
|
}
|
|
119
125
|
|
|
126
|
+
/** 每次浏览器操作前的随机等待范围;合法范围由任务中心与节点共同校验。 */
|
|
127
|
+
export interface BrowserPacing {
|
|
128
|
+
min_seconds: number;
|
|
129
|
+
max_seconds: number;
|
|
130
|
+
}
|
|
131
|
+
|
|
120
132
|
/** 新建对话时可带的参数(POST /api/jobs,mode=conversation)。 */
|
|
121
133
|
export interface NewConversationOptions {
|
|
122
134
|
title?: string;
|
|
@@ -127,6 +139,10 @@ export interface NewConversationOptions {
|
|
|
127
139
|
agent?: string;
|
|
128
140
|
timeoutSeconds?: number;
|
|
129
141
|
browser?: boolean;
|
|
142
|
+
/** Ego 操作手册的 YAML 正文(不是文件名或 URL);必须显式 browser:true。 */
|
|
143
|
+
browserPlaybook?: string;
|
|
144
|
+
/** 转发至节点执行器和浏览器脚本;必须显式 browser:true。 */
|
|
145
|
+
browserPacing?: BrowserPacing;
|
|
130
146
|
}
|
|
131
147
|
|
|
132
148
|
// ── Operator:节点转录 ─────────────────────────────────────────────
|
|
@@ -213,8 +229,8 @@ export type NodeEvent =
|
|
|
213
229
|
| { type: "tool"; id: string; title: string; detail: string; peek?: ToolPeek; diff?: DiffLine[]; msgId?: string; generation?: number }
|
|
214
230
|
| { type: "tool_result"; id: string; content: string; isError: boolean; peek?: ToolPeek; msgId?: string }
|
|
215
231
|
| { type: "ask"; id: string; questions: AskQuestion[]; msgId?: string; generation?: number }
|
|
216
|
-
| { type: "ask_answered"; id: string; answers: AskAnswers; msgId?: string }
|
|
217
|
-
| { type: "input"; input: InputRequest; msgId?: string; generation?: number }
|
|
232
|
+
| { type: "ask_answered"; id: string; answers: AskAnswers; msgId?: string; awaiting_input?: 0 | 1 }
|
|
233
|
+
| { type: "input"; input: InputRequest; msgId?: string; generation?: number; awaiting_input?: 0 | 1 }
|
|
218
234
|
| { type: "usage"; usage: TurnUsage }
|
|
219
235
|
| { type: "notice"; content: string; msgId?: string; generation?: number }
|
|
220
236
|
| { type: "error"; content: string; msgId?: string; generation?: number }
|
|
@@ -233,7 +249,7 @@ export type StreamItem =
|
|
|
233
249
|
| { type: "task"; task: StreamTask | BatchStreamTask };
|
|
234
250
|
|
|
235
251
|
/** 实时流看的那个对话(GET /api/jobs/:id/stream?task=)。 */
|
|
236
|
-
export interface StreamTask { id: string; seq: number; title: string; status: string; closed: boolean; remote_task_id: string | null; continue_from_task_id: string | null }
|
|
252
|
+
export interface StreamTask extends AwaitingInputState { id: string; seq: number; title: string; status: string; closed: boolean; remote_task_id: string | null; continue_from_task_id: string | null }
|
|
237
253
|
|
|
238
254
|
export interface StreamSnapshot {
|
|
239
255
|
job: { id: string; status: JobStatus; closed_reason: string | null; node_id: string | null; mode: string; title: string; conversation: ConversationSummary };
|
|
@@ -246,7 +262,7 @@ export interface StreamSnapshot {
|
|
|
246
262
|
* 一次性任务(含续派)的子任务,实时流里的样子。cli-chat 只读查看这类任务时用。
|
|
247
263
|
* prompt / opener_prompt:指定了 ?task= 时看的那个子任务才有(opener_prompt = 开出它所在节点对话的那个子任务的原文)。
|
|
248
264
|
*/
|
|
249
|
-
export interface BatchStreamTask {
|
|
265
|
+
export interface BatchStreamTask extends AwaitingInputState {
|
|
250
266
|
id: string;
|
|
251
267
|
seq: number;
|
|
252
268
|
task_key: string | null;
|
|
@@ -426,15 +442,20 @@ export interface BrowserStatus {
|
|
|
426
442
|
}
|
|
427
443
|
|
|
428
444
|
/** 浏览器画面流里的一条消息(节点原样转发)。 */
|
|
429
|
-
export
|
|
445
|
+
export interface BrowserTab { id: string; title: string; url: string }
|
|
446
|
+
/** 新节点用页面身份隔离输入;旧节点可以省略这两个字段。 */
|
|
447
|
+
export interface BrowserTarget { targetId?: string; targetEpoch?: number }
|
|
448
|
+
export type BrowserMessage = BrowserTarget & (
|
|
430
449
|
| { type: "frame"; data: string; metadata?: { deviceWidth: number; deviceHeight: number } }
|
|
431
450
|
| { type: "page"; url?: string; title?: string }
|
|
451
|
+
/** 只包含当前任务内的网页;只有明确声明 canSelect 才显示可切换标签。 */
|
|
452
|
+
| { type: "tabs"; tabs: BrowserTab[]; activeId: string | null; canSelect?: boolean }
|
|
432
453
|
| { type: "error"; message?: string }
|
|
433
454
|
/** 按了复制 / 剪切:选中的文字(只发给按键的人),写进本机剪贴板。 */
|
|
434
455
|
| { type: "clipboard"; text: string }
|
|
435
456
|
/** 网页弹出了 alert / confirm / prompt(画面里看不到,单独显示)。 */
|
|
436
457
|
| { type: "dialog"; dialogType: "alert" | "confirm" | "prompt" | "beforeunload"; message: string; defaultPrompt?: string }
|
|
437
|
-
| { type: "dialog_closed" };
|
|
458
|
+
| { type: "dialog_closed" });
|
|
438
459
|
|
|
439
460
|
/** 浏览器实时连接(GET /browser/live)的画面连接状况。 */
|
|
440
461
|
export type BrowserFramesState = { state: "idle" | "connecting" | "live" | "retrying"; detail?: string };
|
package/src/layout/split.ts
CHANGED
|
@@ -5,12 +5,12 @@
|
|
|
5
5
|
* attachSplit(container, { panes: [{ size: 300, min: 220, max: 480 }, { min: 360 }, { size: "42%", min: 320 }], storageKey: "strategy" })
|
|
6
6
|
*
|
|
7
7
|
* 规则:
|
|
8
|
-
* · 写了 size 的栏是定宽(px
|
|
8
|
+
* · 写了 size 的栏是定宽(px 或百分比,未手动调整的百分比随容器宽度变化);没写 size 的栏占剩下的宽度(只能有一个,没有就是最后一栏)。
|
|
9
9
|
* · 拖动条改它旁边定宽的那一栏;任何一栏都不会小于自己的 min,占剩余宽度的那栏也一样。
|
|
10
10
|
* · 双击拖动条:那一栏回到初始宽度。键盘:拖动条能聚焦,←/→ 每次 16px(按住 Shift 64px)。
|
|
11
11
|
* · 子元素根节点带 collapsed 类(cli-board 收起时):那一栏变成 40px 窄条(--cli-split-collapsed),旁边的拖动条隐藏;展开后回到原来的宽度。
|
|
12
12
|
* · 写了 storageKey:拖好的宽度记在 localStorage(cli-ui:split:<storageKey>),下次打开还是这个宽度。
|
|
13
|
-
* ·
|
|
13
|
+
* · 窗口变窄时定宽栏按超过 min 的空间比例让出宽度;各栏 min 之和能放下时,保证占剩余宽度的那栏不小于它的 min。
|
|
14
14
|
*/
|
|
15
15
|
export type SplitPane = { size?: number | `${number}%`; min?: number; max?: number };
|
|
16
16
|
export type SplitOptions = {
|
|
@@ -26,6 +26,8 @@ const STEP = 16;
|
|
|
26
26
|
|
|
27
27
|
export function attachSplit(container: HTMLElement, initial: SplitOptions = {}): SplitHandle {
|
|
28
28
|
let options = initial;
|
|
29
|
+
// Only user overrides are pixels. null keeps the configured responsive default.
|
|
30
|
+
// Old persisted numeric arrays remain valid user preferences.
|
|
29
31
|
let sizes: (number | null)[] = [];
|
|
30
32
|
let gutters: HTMLElement[] = [];
|
|
31
33
|
let panes: HTMLElement[] = [];
|
|
@@ -45,48 +47,79 @@ export function attachSplit(container: HTMLElement, initial: SplitOptions = {}):
|
|
|
45
47
|
};
|
|
46
48
|
|
|
47
49
|
const spec = (i: number): SplitPane => options.panes?.[i] ?? {};
|
|
48
|
-
const
|
|
50
|
+
const preferredFlexIndex = () => {
|
|
49
51
|
const i = panes.findIndex((_, n) => spec(n).size === undefined);
|
|
50
52
|
return i >= 0 ? i : panes.length - 1;
|
|
51
53
|
};
|
|
52
|
-
const minOf = (i: number) => spec(i).min ?? DEFAULT_MIN;
|
|
54
|
+
const minOf = (i: number) => Math.max(0, spec(i).min ?? DEFAULT_MIN);
|
|
55
|
+
const clamp = (i: number, w: number) => Math.round(Math.min(Math.max(minOf(i), spec(i).max ?? Infinity), Math.max(minOf(i), w)));
|
|
56
|
+
const contentWidth = () => {
|
|
57
|
+
const style = getComputedStyle(container);
|
|
58
|
+
return Math.max(0, container.clientWidth - (parseFloat(style.paddingLeft) || 0) - (parseFloat(style.paddingRight) || 0));
|
|
59
|
+
};
|
|
53
60
|
const initialSize = (i: number): number | null => {
|
|
54
|
-
if (i ===
|
|
61
|
+
if (i === preferredFlexIndex()) return null;
|
|
55
62
|
const s = spec(i).size;
|
|
56
|
-
if (typeof s === "string") return
|
|
57
|
-
return s ?? 320;
|
|
63
|
+
if (typeof s === "string") return clamp(i, (parseFloat(s) / 100) * (contentWidth() || 1200));
|
|
64
|
+
return clamp(i, s ?? 320);
|
|
58
65
|
};
|
|
59
66
|
const isCollapsed = (pane: HTMLElement) => !!pane.firstElementChild?.classList.contains("collapsed");
|
|
60
|
-
const
|
|
67
|
+
const isVisible = (pane: HTMLElement) => !pane.hidden && getComputedStyle(pane).display !== "none";
|
|
68
|
+
const flexIndex = () => {
|
|
69
|
+
const preferred = preferredFlexIndex();
|
|
70
|
+
if (panes[preferred] && isVisible(panes[preferred]) && !isCollapsed(panes[preferred])) return preferred;
|
|
71
|
+
// A host may hide whole panes on mobile. A remaining visible pane fills the
|
|
72
|
+
// space without turning its default width into a permanent pixel override.
|
|
73
|
+
for (let i = panes.length - 1; i >= 0; i--) if (isVisible(panes[i]) && !isCollapsed(panes[i])) return i;
|
|
74
|
+
return -1;
|
|
75
|
+
};
|
|
76
|
+
const desiredSizes = () => panes.map((_, i) => i === preferredFlexIndex() ? null : clamp(i, sizes[i] ?? initialSize(i)!));
|
|
61
77
|
|
|
62
78
|
/** 定宽栏放进容器:总宽超了就按比例收,给占剩余宽度的那栏留出它的 min。 */
|
|
63
|
-
function fitted(): (number | null)[] {
|
|
64
|
-
const
|
|
65
|
-
const
|
|
66
|
-
const
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
79
|
+
function fitted(flex: number): (number | null)[] {
|
|
80
|
+
const shown = desiredSizes();
|
|
81
|
+
const fixed = panes.flatMap((p, i) => i !== flex && isVisible(p) && !isCollapsed(p) ? [i] : []);
|
|
82
|
+
const collapsedW = panes.reduce((sum, p) => sum + (isVisible(p) && isCollapsed(p) ? p.getBoundingClientRect().width : 0), 0);
|
|
83
|
+
const gutterW = gutters.reduce((sum, g) => {
|
|
84
|
+
if (g.hidden) return sum;
|
|
85
|
+
const style = getComputedStyle(g);
|
|
86
|
+
const width = g.getBoundingClientRect().width || parseFloat(style.flexBasis) || 0;
|
|
87
|
+
return sum + Math.max(0, width + (parseFloat(style.marginLeft) || 0) + (parseFloat(style.marginRight) || 0));
|
|
88
|
+
}, 0);
|
|
89
|
+
const room = contentWidth() - (flex < 0 ? 0 : minOf(flex)) - collapsedW - gutterW;
|
|
90
|
+
const sum = fixed.reduce((total, i) => total + shown[i]!, 0);
|
|
91
|
+
if (!container.clientWidth || sum <= room) return shown;
|
|
92
|
+
const minimum = fixed.reduce((total, i) => total + minOf(i), 0);
|
|
93
|
+
// Scaling whole widths can hit one pane's minimum and still overflow the
|
|
94
|
+
// flexible pane. Allocate only the space above each minimum instead.
|
|
95
|
+
const ratio = sum > minimum ? Math.max(0, room - minimum) / (sum - minimum) : 0;
|
|
96
|
+
for (const i of fixed) shown[i] = minOf(i) + Math.floor((shown[i]! - minOf(i)) * ratio);
|
|
97
|
+
return shown;
|
|
73
98
|
}
|
|
74
99
|
|
|
75
100
|
function apply() {
|
|
76
101
|
const flex = flexIndex();
|
|
77
|
-
|
|
102
|
+
// Set narrow strips before measuring: their previous expanded width must
|
|
103
|
+
// not be subtracted from the available space on the first collapse frame.
|
|
104
|
+
for (const pane of panes) {
|
|
105
|
+
if (!isCollapsed(pane)) continue;
|
|
106
|
+
pane.style.flex = "0 0 var(--cli-split-collapsed, 40px)";
|
|
107
|
+
pane.style.width = "";
|
|
108
|
+
pane.style.minWidth = "";
|
|
109
|
+
}
|
|
110
|
+
gutters.forEach((g, i) => { g.hidden = !isVisible(panes[i]) || !isVisible(panes[i + 1]) || isCollapsed(panes[i]) || isCollapsed(panes[i + 1]); });
|
|
111
|
+
const shown = fitted(flex);
|
|
78
112
|
panes.forEach((pane, i) => {
|
|
79
113
|
pane.classList.add("cli-split-pane");
|
|
114
|
+
if (isCollapsed(pane)) return;
|
|
80
115
|
if (i === flex) { pane.style.flex = "1 1 0"; pane.style.width = ""; pane.style.minWidth = `${minOf(i)}px`; return; }
|
|
81
|
-
//
|
|
82
|
-
if (
|
|
116
|
+
// Hidden preferred flex panes keep their flex role for when they return.
|
|
117
|
+
if (shown[i] === null) { pane.style.flex = "1 1 0"; pane.style.width = ""; pane.style.minWidth = `${minOf(i)}px`; return; }
|
|
83
118
|
pane.style.flex = `0 0 ${shown[i]}px`;
|
|
84
119
|
pane.style.width = `${shown[i]}px`;
|
|
85
120
|
pane.style.minWidth = "0";
|
|
86
121
|
});
|
|
87
122
|
gutters.forEach((g, i) => {
|
|
88
|
-
const hidden = isCollapsed(panes[i]) || isCollapsed(panes[i + 1]);
|
|
89
|
-
g.hidden = hidden;
|
|
90
123
|
g.setAttribute("aria-valuenow", String(Math.round(panes[sideOf(i)]?.getBoundingClientRect().width ?? 0)));
|
|
91
124
|
});
|
|
92
125
|
}
|
|
@@ -95,18 +128,20 @@ export function attachSplit(container: HTMLElement, initial: SplitOptions = {}):
|
|
|
95
128
|
const sideOf = (gutter: number) => (gutter === flexIndex() ? gutter + 1 : gutter);
|
|
96
129
|
|
|
97
130
|
function resize(gutter: number, dx: number) {
|
|
131
|
+
if (gutters[gutter]?.hidden) return;
|
|
98
132
|
const target = sideOf(gutter);
|
|
99
133
|
const sign = target === gutter ? 1 : -1;
|
|
100
134
|
const flex = flexIndex();
|
|
135
|
+
if (flex < 0) return;
|
|
101
136
|
const current = panes[target].getBoundingClientRect().width;
|
|
102
137
|
// 占剩余宽度的那栏不能被挤到 min 以下。
|
|
103
138
|
const flexW = panes[flex].getBoundingClientRect().width;
|
|
104
|
-
const grow = Math.min(sign * dx, flexW - minOf(flex));
|
|
139
|
+
const grow = Math.min(sign * dx, Math.max(0, flexW - minOf(flex)));
|
|
105
140
|
sizes[target] = clamp(target, current + grow);
|
|
106
141
|
apply();
|
|
107
142
|
}
|
|
108
143
|
|
|
109
|
-
function finish() { save(); options.onResize?.(
|
|
144
|
+
function finish() { save(); options.onResize?.(desiredSizes()); }
|
|
110
145
|
|
|
111
146
|
function makeGutter(index: number) {
|
|
112
147
|
const g = document.createElement("div");
|
|
@@ -138,7 +173,7 @@ export function attachSplit(container: HTMLElement, initial: SplitOptions = {}):
|
|
|
138
173
|
});
|
|
139
174
|
g.addEventListener("dblclick", () => {
|
|
140
175
|
const target = sideOf(index);
|
|
141
|
-
sizes[target] =
|
|
176
|
+
sizes[target] = null;
|
|
142
177
|
apply();
|
|
143
178
|
finish();
|
|
144
179
|
});
|
|
@@ -153,20 +188,22 @@ export function attachSplit(container: HTMLElement, initial: SplitOptions = {}):
|
|
|
153
188
|
|
|
154
189
|
/** 子元素变了(宿主增删栏):重新找栏、重新插拖动条。 */
|
|
155
190
|
function rebuild() {
|
|
191
|
+
for (const p of panes) ro?.unobserve?.(p);
|
|
156
192
|
for (const g of gutters) g.remove();
|
|
157
193
|
panes = [...container.children].filter((c): c is HTMLElement => c instanceof HTMLElement && !c.classList.contains("cli-split-gutter"));
|
|
158
194
|
const saved = load();
|
|
159
|
-
const flex =
|
|
195
|
+
const flex = preferredFlexIndex();
|
|
160
196
|
sizes = panes.map((_, i) => {
|
|
161
197
|
if (i === flex) return null;
|
|
162
198
|
const keep = sizes[i];
|
|
163
|
-
if (typeof keep === "number") return keep;
|
|
199
|
+
if (typeof keep === "number" && Number.isFinite(keep)) return clamp(i, keep);
|
|
164
200
|
const s = saved && saved.length === panes.length ? saved[i] : null;
|
|
165
|
-
return typeof s === "number" ? clamp(i, s) :
|
|
201
|
+
return typeof s === "number" && Number.isFinite(s) ? clamp(i, s) : null;
|
|
166
202
|
});
|
|
167
203
|
gutters = panes.slice(0, -1).map((_, i) => makeGutter(i));
|
|
168
204
|
gutters.forEach((g, i) => panes[i].after(g));
|
|
169
205
|
apply();
|
|
206
|
+
for (const p of panes) ro?.observe(p);
|
|
170
207
|
}
|
|
171
208
|
|
|
172
209
|
// 只看栏的增删、栏的根节点换了、根节点的 class 变了(收起 / 展开);栏里面的内容(流式输出)不看,不然每个字都要重排。
|
|
@@ -199,7 +236,7 @@ export function attachSplit(container: HTMLElement, initial: SplitOptions = {}):
|
|
|
199
236
|
observer.disconnect(); rebuild(); observe();
|
|
200
237
|
},
|
|
201
238
|
reset() {
|
|
202
|
-
sizes = panes.map((
|
|
239
|
+
sizes = panes.map(() => null);
|
|
203
240
|
apply();
|
|
204
241
|
finish();
|
|
205
242
|
},
|
|
@@ -59,6 +59,9 @@ export function CliMsgList(props: CliMsgListProps) {
|
|
|
59
59
|
|
|
60
60
|
const SEARCH_MIN = 6;
|
|
61
61
|
const BATCH_ACTIVE = new Set(["dispatching", "running", "verifying"]);
|
|
62
|
+
// An orphaned task can retain a fresh, authoritative human-wait observation
|
|
63
|
+
// while the center is reconciling its execution state. Do not call it running.
|
|
64
|
+
const BATCH_AWAITING = new Set([...BATCH_ACTIVE, "orphaned"]);
|
|
62
65
|
|
|
63
66
|
interface CardView {
|
|
64
67
|
id: string;
|
|
@@ -95,9 +98,11 @@ export function cardView(id: string, entry: JobEntry | undefined): CardView {
|
|
|
95
98
|
const label = cancelled ? "已取消" : failed ? "已失败" : "已完成";
|
|
96
99
|
return { ...base, cls: cancelled || failed ? "x" : "g", label, running: false, awaiting: false, group: cancelled ? "cancelled" : "done", activity: updatedAt ? `${label} · ${relTime(updatedAt)}` : label };
|
|
97
100
|
}
|
|
98
|
-
//
|
|
101
|
+
// A browser handoff/input card keeps its turn running while requiring a human.
|
|
99
102
|
if (batch) {
|
|
100
103
|
const running = (job?.tasks ?? []).some((task) => BATCH_ACTIVE.has(task.status));
|
|
104
|
+
const awaiting = (job?.tasks ?? []).some((task) => BATCH_AWAITING.has(task.status) && task.awaiting_input === true);
|
|
105
|
+
if (awaiting) return { ...base, cls: "a", label: "需要你处理", running, awaiting, group: "needs", activity: "等你处理" };
|
|
101
106
|
return { ...base, cls: "a", label: "进行中", running, awaiting: false, group: "active", activity: running ? "运行中 · 工作中" : status === "queued" ? "等待执行节点" : "进行中" };
|
|
102
107
|
}
|
|
103
108
|
// 开了几个对话:任何一个在跑就算在跑,任何一个停在提问上就要你处理(关掉的对话不算)。
|
|
@@ -105,7 +110,7 @@ export function cardView(id: string, entry: JobEntry | undefined): CardView {
|
|
|
105
110
|
const many = (conv?.conversations?.length ?? 0) > 1;
|
|
106
111
|
const running = many ? open.some((c) => c.running_turn) : !!conv?.running_turn;
|
|
107
112
|
// 上一轮以提问结束、后面没有新消息:等你回答。
|
|
108
|
-
const awaiting = many ? open.some((c) => !c.running_turn && !c.queued_turns && !!c.last_turn?.asked) : !running && !conv?.queued_turns && !!conv?.last_turn?.asked;
|
|
113
|
+
const awaiting = many ? open.some((c) => c.awaiting_input === true || (!c.running_turn && !c.queued_turns && !!c.last_turn?.asked)) : conv?.awaiting_input === true || (!running && !conv?.queued_turns && !!conv?.last_turn?.asked);
|
|
109
114
|
if (awaiting) return { ...base, cls: "a", label: "需要你处理", running, awaiting, group: "needs", activity: updatedAt ? `等你处理 · ${relTime(updatedAt)}` : "等你处理" };
|
|
110
115
|
if (running) return { ...base, cls: "a", label: "进行中", running, awaiting, group: "active", activity: "运行中 · 工作中" };
|
|
111
116
|
const waiting = conv?.queued_turns ? `${conv.queued_turns} 条排队中` : status === "queued" ? "等待执行节点" : updatedAt ? relTime(updatedAt) : "未开始";
|
|
@@ -143,6 +148,7 @@ export function convRows(jobId: string, entry: JobEntry | undefined): ConvRowVie
|
|
|
143
148
|
if (tasks.length <= 1) return [];
|
|
144
149
|
return tasks.map((t) => {
|
|
145
150
|
const look = BATCH_ROW[t.status] ?? { cls: "a" as DotCls, label: t.status };
|
|
151
|
+
if (BATCH_AWAITING.has(t.status) && t.awaiting_input === true) return { key: convKey(jobId, t.id), title: t.title, cls: "a" as DotCls, label: "需要你处理", running: !!look.running, awaiting: true, activity: "等你处理", turns: 0 };
|
|
146
152
|
return { key: convKey(jobId, t.id), title: t.title, cls: look.cls, label: look.label, running: !!look.running, awaiting: false, activity: look.label, turns: 0 };
|
|
147
153
|
});
|
|
148
154
|
}
|
|
@@ -153,7 +159,7 @@ export function convRows(jobId: string, entry: JobEntry | undefined): ConvRowVie
|
|
|
153
159
|
const base = { key: convKey(jobId, c.task_id), title: c.title, turns: c.turns };
|
|
154
160
|
if (c.closed || jobEnded) return { ...base, cls: "g" as DotCls, label: "已结束", running: false, awaiting: false, activity: "已结束" };
|
|
155
161
|
const running = c.running_turn;
|
|
156
|
-
const awaiting = !running && !c.queued_turns && !!c.last_turn?.asked;
|
|
162
|
+
const awaiting = c.awaiting_input === true || (!running && !c.queued_turns && !!c.last_turn?.asked);
|
|
157
163
|
if (awaiting) return { ...base, cls: "a" as DotCls, label: "需要你处理", running, awaiting, activity: "等你处理" };
|
|
158
164
|
if (running) return { ...base, cls: "a" as DotCls, label: "进行中", running, awaiting, activity: "运行中" };
|
|
159
165
|
return { ...base, cls: "a" as DotCls, label: "进行中", running, awaiting, activity: c.queued_turns ? `${c.queued_turns} 条排队中` : "空闲" };
|
|
@@ -193,8 +193,13 @@ function InputView({ input, agentLabel, onSubmit, onShowBrowser }: { onShowBrows
|
|
|
193
193
|
if (!complete || busy || !onSubmit) return;
|
|
194
194
|
setBusy(true);
|
|
195
195
|
setErr(null);
|
|
196
|
-
|
|
197
|
-
|
|
196
|
+
try {
|
|
197
|
+
const e = await onSubmit(input.id, vals.map((v, i) => input.fields[i].secret ? v : v.trim()));
|
|
198
|
+
if (e) { setErr(e); setBusy(false); }
|
|
199
|
+
} catch (error) {
|
|
200
|
+
setErr(error instanceof Error ? error.message : "提交失败,请重试");
|
|
201
|
+
setBusy(false);
|
|
202
|
+
}
|
|
198
203
|
};
|
|
199
204
|
return (
|
|
200
205
|
<div className="ask">
|
|
@@ -15,7 +15,7 @@ export function createOperatorBoardBackend(fetcher: typeof fetch = (...args) =>
|
|
|
15
15
|
suffix=suffix.replace(/^files\/raw/,'files');
|
|
16
16
|
return fetcher(`/api/tasks/${target[1]}/${suffix}${url.search}`, init);
|
|
17
17
|
} });
|
|
18
|
-
const viewers=new Map<string,WebSocket>();
|
|
18
|
+
const viewers=new Map<string,{taskId:string;socket:WebSocket}>();
|
|
19
19
|
return { ...client.board!, directLinks: true, browserLive:undefined,
|
|
20
20
|
async info(id) {
|
|
21
21
|
const response=await fetcher(`/api/tasks/${encodeURIComponent(id)}`,{cache:'no-store'});
|
|
@@ -60,7 +60,7 @@ export function createOperatorBoardBackend(fetcher: typeof fetch = (...args) =>
|
|
|
60
60
|
browserFrames(id,handlers) {
|
|
61
61
|
const socket=new WebSocket(`${location.protocol==='https:'?'wss:':'ws:'}//${location.host}/browser/${encodeURIComponent(id)}/stream`);
|
|
62
62
|
const viewer=clientId();let stopped=false;
|
|
63
|
-
viewers.set(viewer,socket);
|
|
63
|
+
viewers.set(viewer,{taskId:id,socket});
|
|
64
64
|
const timeout=setTimeout(()=>{if(socket.readyState!==WebSocket.OPEN)socket.close();},10000);
|
|
65
65
|
socket.onopen=()=>{clearTimeout(timeout);if(!stopped)handlers.onViewer(viewer);};
|
|
66
66
|
socket.onmessage=event=>{if(stopped)return;try{handlers.onMessage(JSON.parse(String(event.data)) as BrowserMessage);}catch{/* Next frame can recover. */}};
|
|
@@ -68,8 +68,10 @@ export function createOperatorBoardBackend(fetcher: typeof fetch = (...args) =>
|
|
|
68
68
|
socket.onclose=event=>{clearTimeout(timeout);viewers.delete(viewer);if(!stopped)handlers.onClose?.(new CenterError(event.reason||'浏览器画面连接已断开,正在重连','NETWORK',0));};
|
|
69
69
|
return()=>{stopped=true;clearTimeout(timeout);viewers.delete(viewer);socket.close();};
|
|
70
70
|
},
|
|
71
|
-
async browserInput(
|
|
72
|
-
const
|
|
71
|
+
async browserInput(id,viewer,events) {
|
|
72
|
+
const entry=viewers.get(viewer);
|
|
73
|
+
if(entry&&entry.taskId!==id)throw new CenterError('浏览器连接属于另一个任务,请重新连接','HTTP_409',409);
|
|
74
|
+
const socket=entry?.socket;
|
|
73
75
|
if(!socket||socket.readyState!==WebSocket.OPEN)throw new CenterError('浏览器画面尚未连接,请稍后重试','NETWORK',0);
|
|
74
76
|
for(const event of events)socket.send(JSON.stringify(event));
|
|
75
77
|
},
|
|
@@ -239,9 +239,9 @@ export type StreamEvent =
|
|
|
239
239
|
| { type: "tool"; id: string; title: string; detail: string; peek?: ToolPeek; diff?: DiffLine[] }
|
|
240
240
|
| { type: "tool_result"; id: string; content: string; isError: boolean; peek?: ToolPeek }
|
|
241
241
|
| { type: "ask"; id: string; questions: AskQuestion[] }
|
|
242
|
-
| { type: "ask_answered"; id: string; answers: AskAnswers }
|
|
242
|
+
| { type: "ask_answered"; id: string; answers: AskAnswers; awaiting_input?: 0 | 1 }
|
|
243
243
|
// An input card was raised or settled (the whole card each time; upsert by msgId).
|
|
244
|
-
| { type: "input"; input: InputRequest }
|
|
244
|
+
| { type: "input"; input: InputRequest; awaiting_input?: 0 | 1 }
|
|
245
245
|
| { type: "suggested"; title: string }
|
|
246
246
|
| { type: "usage"; usage: TurnUsage }
|
|
247
247
|
| { type: "notice"; content: string } // a quiet, non-error system note (e.g. "caught up to main")
|
|
@@ -149,15 +149,16 @@ export function useTaskStream({ selTask, selProjRef, agentsRef, setTaskRunning,
|
|
|
149
149
|
// Only drop the flag once every parked ask on this task is answered.
|
|
150
150
|
const open = openAsksRef.current[taskId];
|
|
151
151
|
open?.delete(ev.id);
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
}
|
|
152
|
+
const reported = ev.awaiting_input;
|
|
153
|
+
const awaiting = reported === 0 || reported === 1 ? reported : open?.size ? 1 : 0;
|
|
154
|
+
setTasks((prev) => prev.map((x) => (x.id === taskId ? { ...x, awaiting_input: awaiting } : x)));
|
|
155
155
|
} else if (ev.type === "input") {
|
|
156
156
|
// browser_ask_user card raised or settled: the event carries the whole card.
|
|
157
157
|
const data: ToolData = { title: "需要你提供信息", input: ev.input };
|
|
158
158
|
upsertMsg(taskId, { id: ev.msgId ?? `in-${ev.input.id}`, role: "tool", content: JSON.stringify(data), generation: gen });
|
|
159
|
+
const reported = ev.awaiting_input;
|
|
159
160
|
const waiting = ev.input.status === "pending";
|
|
160
|
-
setTasks((prev) => prev.map((x) => (x.id === taskId ? { ...x, awaiting_input: waiting ? 1 : x.awaiting_input && openAsksRef.current[taskId]?.size ? 1 : 0 } : x)));
|
|
161
|
+
setTasks((prev) => prev.map((x) => (x.id === taskId ? { ...x, awaiting_input: reported === 0 || reported === 1 ? reported : waiting ? 1 : x.awaiting_input && openAsksRef.current[taskId]?.size ? 1 : 0 } : x)));
|
|
161
162
|
} else if (ev.type === "usage") {
|
|
162
163
|
// Live cumulative spend: add this turn's totals to the task's figure.
|
|
163
164
|
// Context occupancy, by contrast, is the latest turn's INPUT-side
|
package/src/operator.base.css
CHANGED
|
@@ -1659,6 +1659,14 @@ button.pop-item:hover{background:var(--surface-2);}
|
|
|
1659
1659
|
.bp-handoff{border:1px solid var(--coral);background:var(--coral-soft);border-radius:var(--r-sm);padding:10px 12px;font-size:12.5px;line-height:1.6;color:var(--ink);}
|
|
1660
1660
|
.bp-handoff-sub{margin-top:4px;font-family:var(--mono);font-size:10.5px;color:var(--ink-3);}
|
|
1661
1661
|
.bp-viewer{border:1px solid var(--line);border-radius:var(--r);overflow:hidden;background:var(--panel);box-shadow:var(--sh-2);position:relative;}
|
|
1662
|
+
.bp-tabs{display:flex;gap:4px;overflow-x:auto;overscroll-behavior-x:contain;padding:6px 6px 0;border-bottom:1px solid var(--line);background:var(--surface-2);scrollbar-width:thin;}
|
|
1663
|
+
.bp-tab{display:flex;align-items:center;gap:6px;flex:0 0 auto;min-width:72px;max-width:200px;padding:7px 10px;border-radius:6px 6px 0 0;border-bottom:2px solid transparent;color:var(--ink-3);font-size:12px;text-align:left;}
|
|
1664
|
+
.bp-tab > span:first-child{overflow:hidden;white-space:nowrap;text-overflow:ellipsis;}
|
|
1665
|
+
.bp-tab.active{background:var(--panel);border-bottom-color:var(--accent);color:var(--ink);}
|
|
1666
|
+
.bp-tab:not(:disabled):hover{background:var(--panel);color:var(--ink);}
|
|
1667
|
+
.bp-tab:disabled{cursor:default;}
|
|
1668
|
+
.bp-tab:focus-visible{outline:2px solid var(--accent);outline-offset:-2px;}
|
|
1669
|
+
.bp-tab-wait{flex:none;color:var(--accent);}
|
|
1662
1670
|
.bp-chrome{gap:6px;}
|
|
1663
1671
|
.bp-nav{width:24px;height:24px;border-radius:4px;color:var(--ink-3);font-size:15px;line-height:1;}
|
|
1664
1672
|
.bp-nav:not(:disabled):hover{background:var(--surface-2);color:var(--ink);}
|
package/src/operator.css
CHANGED
|
@@ -1659,6 +1659,14 @@
|
|
|
1659
1659
|
.cli-ui .bp-handoff{border:1px solid var(--coral);background:var(--coral-soft);border-radius:var(--r-sm);padding:10px 12px;font-size:12.5px;line-height:1.6;color:var(--ink);}
|
|
1660
1660
|
.cli-ui .bp-handoff-sub{margin-top:4px;font-family:var(--mono);font-size:10.5px;color:var(--ink-3);}
|
|
1661
1661
|
.cli-ui .bp-viewer{border:1px solid var(--line);border-radius:var(--r);overflow:hidden;background:var(--panel);box-shadow:var(--sh-2);position:relative;}
|
|
1662
|
+
.cli-ui .bp-tabs{display:flex;gap:4px;overflow-x:auto;overscroll-behavior-x:contain;padding:6px 6px 0;border-bottom:1px solid var(--line);background:var(--surface-2);scrollbar-width:thin;}
|
|
1663
|
+
.cli-ui .bp-tab{display:flex;align-items:center;gap:6px;flex:0 0 auto;min-width:72px;max-width:200px;padding:7px 10px;border-radius:6px 6px 0 0;border-bottom:2px solid transparent;color:var(--ink-3);font-size:12px;text-align:left;}
|
|
1664
|
+
.cli-ui .bp-tab > span:first-child{overflow:hidden;white-space:nowrap;text-overflow:ellipsis;}
|
|
1665
|
+
.cli-ui .bp-tab.active{background:var(--panel);border-bottom-color:var(--accent);color:var(--ink);}
|
|
1666
|
+
.cli-ui .bp-tab:not(:disabled):hover{background:var(--panel);color:var(--ink);}
|
|
1667
|
+
.cli-ui .bp-tab:disabled{cursor:default;}
|
|
1668
|
+
.cli-ui .bp-tab:focus-visible{outline:2px solid var(--accent);outline-offset:-2px;}
|
|
1669
|
+
.cli-ui .bp-tab-wait{flex:none;color:var(--accent);}
|
|
1662
1670
|
.cli-ui .bp-chrome{gap:6px;}
|
|
1663
1671
|
.cli-ui .bp-nav{width:24px;height:24px;border-radius:4px;color:var(--ink-3);font-size:15px;line-height:1;}
|
|
1664
1672
|
.cli-ui .bp-nav:not(:disabled):hover{background:var(--surface-2);color:var(--ink);}
|
package/src/styles.css
CHANGED
|
@@ -63,7 +63,7 @@
|
|
|
63
63
|
.cli-ui .cli-task-control{height:34px;padding:0 10px;white-space:nowrap;}
|
|
64
64
|
.cli-ui .cli-task-control:focus-visible{outline:2px solid var(--accent);outline-offset:3px;}
|
|
65
65
|
@container cli-chat (max-width:560px){
|
|
66
|
-
.cli-ui .sess-head{gap:8px;padding:12px 14px;}
|
|
66
|
+
.cli-ui .sess-head{gap:8px;padding:12px 14px;flex-wrap:wrap;}
|
|
67
67
|
.cli-ui .sess-head .sh-main{flex-basis:100%;}
|
|
68
68
|
.cli-ui .sess-head .sh-tools{flex:1;justify-content:flex-start;margin-left:0;}
|
|
69
69
|
.cli-ui .sess-head .cli-export{margin-left:auto;}
|
|
@@ -324,3 +324,31 @@ cli-split{display:block;}
|
|
|
324
324
|
|
|
325
325
|
.cli-ui .cli-auto-background{font-size:12px;color:var(--muted);margin-top:8px;}
|
|
326
326
|
.cli-ui .cli-auto-background pre{white-space:pre-wrap;overflow-wrap:anywhere;max-height:220px;overflow:auto;}
|
|
327
|
+
|
|
328
|
+
/* Keep controls reachable when a keyboard, split host or landscape view leaves little height. */
|
|
329
|
+
.cli-ui .session{overflow-x:hidden;overflow-y:auto;overscroll-behavior-y:contain;}
|
|
330
|
+
/* A zero-height transcript must not paint over references and composer controls. */
|
|
331
|
+
.cli-ui .transcript-wrap{overflow:hidden;}
|
|
332
|
+
@media (pointer:coarse){
|
|
333
|
+
.cli-ui .comp-area textarea,.cli-ui .ask-other,.cli-ui .bp-addr,.cli-ui .bp-dialog-in{font-size:16px;}
|
|
334
|
+
.cli-ui .rail-ftab-x{width:32px;height:36px;opacity:1;}
|
|
335
|
+
.cli-ui .cli-ref-x{width:32px;height:32px;}
|
|
336
|
+
.cli-ui .cli-fv-btn,.cli-ui .cli-fv-back,.cli-ui .bp-nav,.cli-ui .cli-attach{min-height:36px;min-width:36px;}
|
|
337
|
+
.cli-ui .rail-collapse{min-width:36px;min-height:36px;}
|
|
338
|
+
}
|
|
339
|
+
@media (max-height:500px){
|
|
340
|
+
.cli-ui.cli-chat .sess-head{min-height:42px;padding:6px 12px;gap:6px;}
|
|
341
|
+
.cli-ui.cli-chat .sess-head .sh-main{flex:1 1 0;}
|
|
342
|
+
.cli-ui.cli-chat .sess-head .sh-tools{flex:0 1 auto;max-width:65%;margin-left:auto;justify-content:flex-end;gap:4px;}
|
|
343
|
+
.cli-ui.cli-chat .sess-head .sh-title{font-size:15px;}
|
|
344
|
+
.cli-ui .msg-nav{display:none;}
|
|
345
|
+
.cli-ui.cli-chat .composer{padding:6px 10px;}
|
|
346
|
+
.cli-ui.cli-chat .comp-area{padding:8px 12px 0;}
|
|
347
|
+
.cli-ui.cli-chat .comp-area textarea{min-height:40px;max-height:64px;}
|
|
348
|
+
.cli-ui.cli-chat .comp-foot{padding:4px 8px;}
|
|
349
|
+
.cli-ui .cli-board-background{margin:0 10px 4px;padding:5px 8px;max-height:56px;overflow:auto;flex:0 0 auto;}
|
|
350
|
+
.cli-ui .cli-refs{max-height:64px;overflow:auto;flex:0 0 auto;}
|
|
351
|
+
}
|
|
352
|
+
@media (pointer:coarse) and (max-width:600px){
|
|
353
|
+
.cli-ui .bp.bp-big,.cli-ui .cli-fv.cli-fv-big{inset:max(6px,env(safe-area-inset-top)) max(6px,env(safe-area-inset-right)) max(6px,env(safe-area-inset-bottom)) max(6px,env(safe-area-inset-left));}
|
|
354
|
+
}
|
package/src/ui/Popover.tsx
CHANGED
|
@@ -14,12 +14,16 @@ export function Popover({ children, onClose }: { children: React.ReactNode; onCl
|
|
|
14
14
|
const vv = window.visualViewport;
|
|
15
15
|
const topEdge = vv?.offsetTop ?? 0;
|
|
16
16
|
const vh = vv?.height ?? window.innerHeight;
|
|
17
|
-
const
|
|
18
|
-
|
|
17
|
+
const leftEdge = vv?.offsetLeft ?? 0;
|
|
18
|
+
const vw = vv?.width ?? window.innerWidth;
|
|
19
|
+
menu.style.minWidth = "0";
|
|
20
|
+
menu.style.maxWidth = `${Math.max(0, vw - 16)}px`;
|
|
21
|
+
menu.style.maxHeight = `${Math.max(0, vh - 16)}px`;
|
|
19
22
|
const mw = menu.offsetWidth, mh = menu.offsetHeight;
|
|
20
|
-
const left = Math.max(8, Math.min(r.right - mw, vw - mw - 8));
|
|
23
|
+
const left = Math.max(leftEdge + 8, Math.min(r.right - mw, leftEdge + vw - mw - 8));
|
|
21
24
|
const below = r.bottom + 6;
|
|
22
|
-
const
|
|
25
|
+
const preferredTop = below + mh <= topEdge + vh - 8 ? below : r.top - mh - 6;
|
|
26
|
+
const top = Math.max(topEdge + 8, Math.min(preferredTop, topEdge + vh - mh - 8));
|
|
23
27
|
setPos({ top, left });
|
|
24
28
|
};
|
|
25
29
|
place();
|
|
@@ -27,7 +31,8 @@ export function Popover({ children, onClose }: { children: React.ReactNode; onCl
|
|
|
27
31
|
observer.observe(menu);
|
|
28
32
|
window.addEventListener("resize", place);
|
|
29
33
|
window.visualViewport?.addEventListener("resize", place);
|
|
30
|
-
|
|
34
|
+
window.visualViewport?.addEventListener("scroll", place);
|
|
35
|
+
return () => { observer.disconnect(); window.removeEventListener("resize", place); window.visualViewport?.removeEventListener("resize", place); window.visualViewport?.removeEventListener("scroll", place); };
|
|
31
36
|
}, []);
|
|
32
37
|
|
|
33
38
|
const placed = pos !== null;
|
package/src/ui/Transcript.tsx
CHANGED
|
@@ -186,8 +186,13 @@ function InputView({ input, agentLabel, onSubmit, onBrowser }: { input: InputReq
|
|
|
186
186
|
if (!complete || busy || !onSubmit) return;
|
|
187
187
|
setBusy(true);
|
|
188
188
|
setErr(null);
|
|
189
|
-
|
|
190
|
-
|
|
189
|
+
try {
|
|
190
|
+
const e = await onSubmit(input.id, vals.map((v, i) => input.fields[i].secret ? v : v.trim()));
|
|
191
|
+
if (e) { setErr(e); setBusy(false); }
|
|
192
|
+
} catch (error) {
|
|
193
|
+
setErr(error instanceof Error ? error.message : "提交失败,请重试");
|
|
194
|
+
setBusy(false);
|
|
195
|
+
}
|
|
191
196
|
};
|
|
192
197
|
return (
|
|
193
198
|
<div className="ask">
|