@spzxg/cli-ui 0.6.11 → 0.6.12
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 +9 -1
- package/README.md +9 -3
- package/dist/{cli-ui-embed-CbxWozLO.js → cli-ui-embed-B0HDalUb.js} +13958 -13716
- package/dist/{cli-ui-index-ImgEunSf.js → cli-ui-index-B0juE2Jq.js} +2 -2
- package/dist/{cli-ui-jszip.min-DWmufeZi.js → cli-ui-jszip.min-BBS0YWg7.js} +1 -1
- package/dist/{cli-ui-jszip.min-DGdGLbxr.js → cli-ui-jszip.min-CSeehR5Z.js} +2 -2
- package/dist/{cli-ui-xlsx-s2TcknA1.js → cli-ui-xlsx-Bk3fo5jQ.js} +1 -1
- package/dist/cli-ui.css +1 -1
- package/dist/cli-ui.js +1 -1
- package/package.json +1 -1
- package/src/chat/CliChat.tsx +13 -2
- package/src/core/client.ts +4 -0
- package/src/core/conversation.ts +10 -3
- package/src/core/subagents.ts +78 -0
- package/src/core/types.ts +12 -2
- package/src/operator/node/Chat.tsx +10 -4
- package/src/operator/node/Transcript.tsx +7 -2
- package/src/operator/node/modals.tsx +13 -2
- package/src/operator/node/protocol/types.ts +5 -2
- package/src/operator/node/useTaskStream.ts +8 -4
- package/src/operator.base.css +29 -0
- package/src/operator.css +29 -0
- package/src/ui/SubagentChat.tsx +197 -0
- package/src/ui/Transcript.tsx +5 -0
package/dist/cli-ui.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { C as a, b as i, d as r, e as t, f as o, h as C, i as l, j as n, k as m, m as d, l as u, n as w, o as B, p as f } from "./cli-ui-embed-
|
|
1
|
+
import { C as a, b as i, d as r, e as t, f as o, h as C, i as l, j as n, k as m, m as d, l as u, n as w, o as B, p as f } from "./cli-ui-embed-B0HDalUb.js";
|
|
2
2
|
export {
|
|
3
3
|
a as CliBoard,
|
|
4
4
|
i as CliBrowserView,
|
package/package.json
CHANGED
package/src/chat/CliChat.tsx
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// cli-chat:对话。界面取自 Operator 的会话视图(SessionView 的对话部分 + Composer),
|
|
2
2
|
// 数据来自 center 的对话任务(mode=conversation):实时流里的节点转录按 Operator 的规则渲染,
|
|
3
3
|
// 每条消息是中枢的一轮,排队 / 执行 / 结束都跟 center 保持一致。
|
|
4
|
+
import { SubagentChatScope } from "../ui/SubagentChat";
|
|
5
|
+
import { parentMessages } from "../core/subagents";
|
|
4
6
|
import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, type ReactNode, type CSSProperties } from "react";
|
|
5
7
|
import { CliErrorBoundary } from "../ui/ErrorBoundary";
|
|
6
8
|
import type { CliEvent, CliStore, CliStoreOptions } from "../core/store";
|
|
@@ -198,11 +200,18 @@ function ChatInner({ slots, passiveContext = true, configurationBusy, onClear, j
|
|
|
198
200
|
|
|
199
201
|
const allMessages = useMemo(() => (conv ? viewMessages(conv) : []), [conv]);
|
|
200
202
|
// config.transcript.tools=false:去掉工具卡片,只留文字往来(提问 / 输入卡片要用户操作,照常显示)。
|
|
201
|
-
const
|
|
203
|
+
const parent = useMemo(() => parentMessages(allMessages), [allMessages]);
|
|
204
|
+
const messages = useMemo(() => (cfg.transcript.tools ? parent : parent.filter((m) => {
|
|
202
205
|
if (m.role !== "tool") return true;
|
|
203
206
|
const data = toolDataOf(m);
|
|
204
207
|
return !!(data.ask || data.input);
|
|
205
|
-
})), [
|
|
208
|
+
})), [parent, cfg.transcript.tools]);
|
|
209
|
+
const remoteTaskId = conv?.remoteTaskId ?? conv?.task?.remote_task_id ?? null;
|
|
210
|
+
const loadSubagent = useCallback(async (id: string, generation: number, before?: string) => {
|
|
211
|
+
if (!key || !remoteTaskId || !store.client.subagent) throw new Error("当前接入未提供子代理历史接口");
|
|
212
|
+
return store.client.subagent(key, remoteTaskId, id, generation, before);
|
|
213
|
+
}, [store, key, remoteTaskId]);
|
|
214
|
+
const renderSubagentMessage = useCallback((message: Msg, name: string) => <MessageView m={message} initial={false} hideWho={false} handlers={{}} agentLabel={name} view={{ timestamps: true, initialBadge: false, userLabel: "收到的任务", agentName: name }} />, []);
|
|
206
215
|
const running = conv ? isRunning(conv) : false;
|
|
207
216
|
const job = entry?.job;
|
|
208
217
|
// 一次性任务没有轮次:补充消息通过 continueTask 接在当前子任务的对话后面。
|
|
@@ -691,6 +700,7 @@ function ChatInner({ slots, passiveContext = true, configurationBusy, onClear, j
|
|
|
691
700
|
<FileLinkContext.Provider value={resolveFile}>
|
|
692
701
|
<FileOpenContext.Provider value={openFile}>
|
|
693
702
|
<div ref={chatRef} className={`cli-ui cli-chat ${className ?? ""}`} data-theme={theme} style={style}>
|
|
703
|
+
<SubagentChatScope scopeKey={`${key ?? "new"}:${remoteTaskId ?? ""}`} messages={allMessages} load={store.client.subagent ? loadSubagent : undefined} renderMessage={renderSubagentMessage}>
|
|
694
704
|
<div className="session">
|
|
695
705
|
{h.show && (workspaceAvailable || externalBoardVisible || h.crumb || h.export || h.title || h.agent || h.usage || h.model || h.status || h.close) && <div className="sess-head">
|
|
696
706
|
{workspaceTabs ? <WorkspaceTabs title={convTitle} subtitle={h.crumb ? title : undefined} chatActive={!workspaceActive} onChat={showChat} onTabsHost={setTabsHost} /> : (h.crumb || showCurrentTitle) && <div className="sh-main">
|
|
@@ -883,6 +893,7 @@ function ChatInner({ slots, passiveContext = true, configurationBusy, onClear, j
|
|
|
883
893
|
inlineLayout={mobileWorkspace ? undefined : { tabsHost, active: workspaceActive, onActivate: showWorkspace, onChat: showChat }} />}
|
|
884
894
|
</WorkspaceDrawer>
|
|
885
895
|
</div>
|
|
896
|
+
</SubagentChatScope>
|
|
886
897
|
{popup && key && board && (
|
|
887
898
|
<div className="scrim" onClick={(e) => { if (e.target === e.currentTarget) setPopup(null); }}>
|
|
888
899
|
<div className={`modal cli-popup ${popup.kind}`} role="dialog" aria-modal="true" aria-label={popup.kind === "file" ? popup.path : "任务浏览器"}>
|
package/src/core/client.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { SubagentTranscript } from "./subagents";
|
|
1
2
|
import { createSseParser } from "./sse";
|
|
2
3
|
import { NEW_TASK, parseConvKey } from "./conversation";
|
|
3
4
|
import type {
|
|
@@ -107,6 +108,8 @@ export interface ConversationBackend {
|
|
|
107
108
|
/** taskId:只要对话任务里这一个对话的轮次。 */
|
|
108
109
|
listTurns(jobId: string, query?: { afterSeq?: number; limit?: number; taskId?: string }): Promise<{ job_id: string; turns: CenterTurn[] }>;
|
|
109
110
|
listTasks(): Promise<{ tasks: CenterTask[] }>;
|
|
111
|
+
/** 原生子代理的公开运行记录;before 用于读取更早的一页。 */
|
|
112
|
+
subagent?(jobId: string, remote: string, childId: string, generation: number, before?: string): Promise<SubagentTranscript>;
|
|
110
113
|
/**
|
|
111
114
|
* 更早的节点转录(往上翻):remote 是流里给的节点会话,before 是手里最早那条消息的 id。
|
|
112
115
|
* 返回这一页(旧的在前)和再往前还剩几条。旧版中枢没有这个接口(实时流也就给的是全量,用不到)。
|
|
@@ -344,6 +347,7 @@ export function createCenterClient(options: CenterClientOptions = {}): Conversat
|
|
|
344
347
|
return error instanceof Error ? error.message : String(error);
|
|
345
348
|
}
|
|
346
349
|
},
|
|
350
|
+
subagent: (jobId, remote, childId, generation, before) => request<SubagentTranscript>(`/api/jobs/${id(parseConvKey(jobId).jobId)}/subagents/${id(childId)}?remote=${id(remote)}&generation=${generation}&limit=200${before ? `&before=${id(before)}` : ""}`),
|
|
347
351
|
history: (jobId: string, remote: string, before: string, limit = 200) =>
|
|
348
352
|
request<{ messages: Message[]; older: number }>(`/api/jobs/${id(parseConvKey(jobId).jobId)}/history?remote=${id(remote)}&before=${id(before)}&limit=${limit}`),
|
|
349
353
|
createConversation: (firstMessage: string, opts: NewConversationOptions & { businessId?: string } = {}) => {
|
package/src/core/conversation.ts
CHANGED
|
@@ -229,19 +229,25 @@ export function applyNodeEvent(conv: ConvState, ev: NodeEvent): ConvState {
|
|
|
229
229
|
case "assistant":
|
|
230
230
|
return set(upsertMsg(conv.msgs, { id: e.msgId ?? localId("a"), role: "assistant", content: e.content, generation: gen, ts: e.ts }));
|
|
231
231
|
case "tool": {
|
|
232
|
-
const data: ToolData = { title: e.title, detail: e.detail, peek: e.peek, diff: e.diff };
|
|
233
|
-
return set(upsertMsg(conv.msgs, { id: e.msgId ?? localId("t"), role: "tool", content: JSON.stringify(data), generation: gen, toolId: e.id }));
|
|
232
|
+
const data: ToolData = { title: e.title, detail: e.detail, peek: e.peek, diff: e.diff, subagent: e.subagent, subagentMessage: e.subagentMessage };
|
|
233
|
+
return set(upsertMsg(conv.msgs, { id: e.msgId ?? localId("t"), role: "tool", content: JSON.stringify(data), generation: gen, toolId: e.id, ts: e.ts }));
|
|
234
234
|
}
|
|
235
235
|
case "tool_result": {
|
|
236
236
|
// 先按消息 id 找(快照加载的消息也能对上),找不到再按 tool_use id。结果多在末尾,从后往前找。
|
|
237
237
|
const arr = conv.msgs;
|
|
238
238
|
let i = arr.length - 1;
|
|
239
239
|
while (i >= 0 && (arr[i].role !== "tool" || (arr[i].id !== e.msgId && arr[i].toolId !== e.id))) i--;
|
|
240
|
-
if (i < 0)
|
|
240
|
+
if (i < 0) {
|
|
241
|
+
if (!e.msgId || (!e.subagent && !e.subagentMessage)) return conv;
|
|
242
|
+
const data: ToolData = { title: e.subagent?.title ?? e.subagentMessage?.tool?.title ?? "子代理消息", subagent: e.subagent, subagentMessage: e.subagentMessage };
|
|
243
|
+
return set(upsertMsg(arr, { id: e.msgId, role: "tool", content: JSON.stringify(data), generation: gen, toolId: e.id, ts: e.ts }));
|
|
244
|
+
}
|
|
241
245
|
try {
|
|
242
246
|
const d = JSON.parse(arr[i].content) as ToolData;
|
|
243
247
|
d.result = e.content;
|
|
244
248
|
d.isError = e.isError;
|
|
249
|
+
if (e.subagent) d.subagent = e.subagent;
|
|
250
|
+
if (e.subagentMessage) d.subagentMessage = e.subagentMessage;
|
|
245
251
|
if (e.peek) d.peek = e.peek;
|
|
246
252
|
const next = arr.slice();
|
|
247
253
|
next[i] = { ...arr[i], content: JSON.stringify(d) };
|
|
@@ -535,6 +541,7 @@ export function turnCallback(conv: ConvState, turn: CenterTurn, continuedFrom: s
|
|
|
535
541
|
context_reset: turn.context_reset,
|
|
536
542
|
merged: !!turn.merged,
|
|
537
543
|
merge_error: turn.merge_error ?? null,
|
|
544
|
+
...(turn.saved !== undefined ? { saved: turn.saved, saved_at: turn.saved_at ?? null, save_error: turn.save_error ?? null } : {}),
|
|
538
545
|
agent: turn.agent,
|
|
539
546
|
resolved_model: turn.resolved_model,
|
|
540
547
|
total_tokens: turn.total_tokens, input_tokens: turn.input_tokens, output_tokens: turn.output_tokens,
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/** Shared wire format for native CLI subagents. These are not Center tasks. */
|
|
2
|
+
export interface SubagentInfo {
|
|
3
|
+
id: string;
|
|
4
|
+
parentId?: string;
|
|
5
|
+
title: string;
|
|
6
|
+
task?: string;
|
|
7
|
+
status: "running" | "completed" | "failed" | "cancelled" | "unknown";
|
|
8
|
+
detailAvailability: "full" | "partial" | "unavailable";
|
|
9
|
+
detailReason?: string;
|
|
10
|
+
updatedAt?: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface SubagentMessage {
|
|
14
|
+
subagentId: string;
|
|
15
|
+
role: "user" | "assistant" | "tool" | "system";
|
|
16
|
+
content: string;
|
|
17
|
+
tool?: { id: string; title: string; detail?: string; result?: string; isError?: boolean };
|
|
18
|
+
eventId: string;
|
|
19
|
+
updatedAt?: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface SubagentFields {
|
|
23
|
+
subagent?: SubagentInfo;
|
|
24
|
+
subagentMessage?: SubagentMessage;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
type Row = { id: string; role: string; content: string; generation: number; ts?: number; created_at?: number };
|
|
28
|
+
export interface SubagentTranscript { subagent: SubagentInfo; messages: Row[]; refreshable?: boolean; older?: number }
|
|
29
|
+
export type LoadSubagent = (id: string, generation: number, before?: string) => Promise<SubagentTranscript>;
|
|
30
|
+
|
|
31
|
+
const cache = new WeakMap<Row, SubagentFields>();
|
|
32
|
+
export function subagentFields(row: Row): SubagentFields {
|
|
33
|
+
const known = cache.get(row);
|
|
34
|
+
if (known) return known;
|
|
35
|
+
const fields: SubagentFields = {};
|
|
36
|
+
if (row.role === "tool") {
|
|
37
|
+
try {
|
|
38
|
+
const value = JSON.parse(row.content);
|
|
39
|
+
const info = value?.subagent;
|
|
40
|
+
if (info && typeof info.id === "string" && typeof info.title === "string") fields.subagent = {
|
|
41
|
+
id: info.id, title: info.title,
|
|
42
|
+
parentId: typeof info.parentId === "string" ? info.parentId : undefined,
|
|
43
|
+
task: typeof info.task === "string" ? info.task : undefined,
|
|
44
|
+
status: ["running", "completed", "failed", "cancelled", "unknown"].includes(info.status) ? info.status : "unknown",
|
|
45
|
+
detailAvailability: ["full", "partial", "unavailable"].includes(info.detailAvailability) ? info.detailAvailability : "partial",
|
|
46
|
+
detailReason: typeof info.detailReason === "string" ? info.detailReason : undefined,
|
|
47
|
+
updatedAt: typeof info.updatedAt === "number" && Number.isFinite(info.updatedAt) ? info.updatedAt : undefined,
|
|
48
|
+
};
|
|
49
|
+
if (value?.subagentMessage && typeof value.subagentMessage.subagentId === "string" && typeof value.subagentMessage.content === "string"
|
|
50
|
+
&& ["user", "assistant", "tool", "system"].includes(value.subagentMessage.role)) fields.subagentMessage = value.subagentMessage;
|
|
51
|
+
} catch { /* Ordinary or malformed tool output is handled by the existing renderer. */ }
|
|
52
|
+
}
|
|
53
|
+
cache.set(row, fields);
|
|
54
|
+
return fields;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Keep child activity out of the parent timeline, but retain an entry if its card is outside the loaded tail. */
|
|
58
|
+
export function parentMessages<T extends Row>(rows: T[]): T[] {
|
|
59
|
+
const cardKeys = new Set(rows.flatMap(row => {
|
|
60
|
+
const info = subagentFields(row).subagent;
|
|
61
|
+
return info ? [`${row.generation}:${info.id}`] : [];
|
|
62
|
+
}));
|
|
63
|
+
const emitted = new Set<string>();
|
|
64
|
+
return rows.flatMap(row => {
|
|
65
|
+
const { subagent, subagentMessage } = subagentFields(row);
|
|
66
|
+
if (subagent?.parentId && cardKeys.has(`${row.generation}:${subagent.parentId}`)) return [];
|
|
67
|
+
if (!subagentMessage) return [row];
|
|
68
|
+
const key = `${row.generation}:${subagentMessage.subagentId}`;
|
|
69
|
+
if (cardKeys.has(key) || emitted.has(key)) return [];
|
|
70
|
+
emitted.add(key);
|
|
71
|
+
const info: SubagentInfo = { id: subagentMessage.subagentId, title: "子代理", status: "unknown", detailAvailability: "partial", detailReason: "正在读取子代理详情" };
|
|
72
|
+
return [{ ...row, id: `subagent-entry:${key}`, role: "tool", content: JSON.stringify({ title: info.title, subagent: info }) }];
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function childMessages(rows: Row[], id: string, generation: number) {
|
|
77
|
+
return rows.filter(row => row.generation === generation && (subagentFields(row).subagentMessage?.subagentId === id || subagentFields(row).subagent?.parentId === id));
|
|
78
|
+
}
|
package/src/core/types.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { SubagentInfo, SubagentMessage } from "./subagents";
|
|
1
2
|
/**
|
|
2
3
|
* 两套数据形状:
|
|
3
4
|
* · center 服务(cli-task-center)对话任务接口的返回(snake_case,原样保留,便于对照 API 文档);
|
|
@@ -37,6 +38,10 @@ export interface CenterTurn {
|
|
|
37
38
|
/** 这一轮的改动是否已合并回项目基线。 */
|
|
38
39
|
merged?: boolean;
|
|
39
40
|
merge_error?: string | null;
|
|
41
|
+
/** 保存本任务分支与合并项目主分支分别记录;旧版中枢不返回。 */
|
|
42
|
+
saved?: boolean;
|
|
43
|
+
saved_at?: string | null;
|
|
44
|
+
save_error?: string | null;
|
|
40
45
|
created_at: string;
|
|
41
46
|
started_at: string | null;
|
|
42
47
|
ended_at: string | null;
|
|
@@ -203,6 +208,8 @@ export type ToolPeek =
|
|
|
203
208
|
| { kind: "todos"; items: { text: string; status: string }[] };
|
|
204
209
|
|
|
205
210
|
export interface ToolData {
|
|
211
|
+
subagent?: SubagentInfo;
|
|
212
|
+
subagentMessage?: SubagentMessage;
|
|
206
213
|
title: string;
|
|
207
214
|
detail?: string;
|
|
208
215
|
result?: string;
|
|
@@ -228,8 +235,8 @@ export type NodeEvent =
|
|
|
228
235
|
| { type: "dequeued"; msgId: string }
|
|
229
236
|
| { type: "model"; model: string }
|
|
230
237
|
| { type: "assistant"; content: string; msgId?: string; generation?: number; ts?: number }
|
|
231
|
-
| { type: "tool"; id: string; title: string; detail: string; peek?: ToolPeek; diff?: DiffLine[]; msgId?: string; generation?: number }
|
|
232
|
-
| { type: "tool_result"; id: string; content: string; isError: boolean; peek?: ToolPeek; msgId?: string }
|
|
238
|
+
| { type: "tool"; id: string; title: string; detail: string; peek?: ToolPeek; diff?: DiffLine[]; subagent?: SubagentInfo; subagentMessage?: SubagentMessage; msgId?: string; generation?: number; ts?: number }
|
|
239
|
+
| { type: "tool_result"; id: string; content: string; isError: boolean; peek?: ToolPeek; subagent?: SubagentInfo; subagentMessage?: SubagentMessage; msgId?: string; generation?: number; ts?: number }
|
|
233
240
|
| { type: "ask"; id: string; questions: AskQuestion[]; msgId?: string; generation?: number }
|
|
234
241
|
| { type: "ask_answered"; id: string; answers: AskAnswers; msgId?: string; awaiting_input?: 0 | 1 }
|
|
235
242
|
| { type: "input"; input: InputRequest; msgId?: string; generation?: number; awaiting_input?: 0 | 1 }
|
|
@@ -338,6 +345,9 @@ export interface ConversationTurnCallback {
|
|
|
338
345
|
context_reset: boolean;
|
|
339
346
|
merged: boolean;
|
|
340
347
|
merge_error: string | null;
|
|
348
|
+
saved?: boolean;
|
|
349
|
+
saved_at?: string | null;
|
|
350
|
+
save_error?: string | null;
|
|
341
351
|
agent: string | null;
|
|
342
352
|
resolved_model: string | null;
|
|
343
353
|
total_tokens: number;
|
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
|
+
import { SubagentChatScope, SubagentHistoryBoundary } from "../../ui/SubagentChat";
|
|
3
|
+
import { parentMessages, type SubagentTranscript } from "../../core/subagents";
|
|
4
|
+
import { jget } from "./api";
|
|
2
5
|
import { useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
|
3
6
|
import type { AskQuestion, AskAnswers } from "./protocol/types";
|
|
4
7
|
import { Icon } from "./icons";
|
|
@@ -79,6 +82,9 @@ export type ChatProps = {
|
|
|
79
82
|
export function Chat(props: ChatProps) { return <ChatSession key={props.task.id} {...props} />; }
|
|
80
83
|
function ChatSession({ project, task, tasks, agents, messages, running, transcriptLoading, older=0, onLoadOlder,
|
|
81
84
|
controls, configBusy, composer, active=true, onSelectTask, onFork, onSend, onStop, onClear, onReconnect, onAnswer, onCancelQueued }: ChatProps) {
|
|
85
|
+
const parent = useMemo(() => parentMessages(messages), [messages]);
|
|
86
|
+
const loadSubagent = useCallback((id: string, generation: number, before?: string) => jget<SubagentTranscript>(`/api/tasks/${encodeURIComponent(task.id)}/subagents/${encodeURIComponent(id)}?generation=${generation}&limit=200${before ? `&before=${encodeURIComponent(before)}` : ""}`), [task.id]);
|
|
87
|
+
const renderSubagentMessage = useCallback((message: Msg, name: string) => <MessageView m={message} initial={false} hideWho={false} agent={task.agent} agentLabel={name} userLabel="收到的任务" speakerLabel={name} readOnly />, [task.agent]);
|
|
82
88
|
const scrollRef = useRef<HTMLDivElement>(null);
|
|
83
89
|
useChatScrollbar(scrollRef, task.id);
|
|
84
90
|
const [quoteRequest, setQuoteRequest] = useState<Quote|null>(null);
|
|
@@ -182,7 +188,7 @@ function ChatSession({ project, task, tasks, agents, messages, running, transcri
|
|
|
182
188
|
// reveals RENDER_STEP more above at a time (paging older messages in from the
|
|
183
189
|
// server once the loaded ones run out) and drops rows far below; scrolling down
|
|
184
190
|
// brings those back. "Jump to latest" returns to following.
|
|
185
|
-
const items = useMemo(() => buildTranscriptItems(
|
|
191
|
+
const items = useMemo(() => buildTranscriptItems(parent, older > 0), [parent, older]);
|
|
186
192
|
// First / last rendered row by key; null = derived (newest rows).
|
|
187
193
|
const [win, setWin] = useState<{ start: string | null; end: string | null }>({ start: null, end: null });
|
|
188
194
|
const { startIdx, endIdx } = useMemo(() => {
|
|
@@ -443,11 +449,11 @@ function ChatSession({ project, task, tasks, agents, messages, running, transcri
|
|
|
443
449
|
useEffect(()=>{if(!task.parent_task_id && active) scrollToBottom("auto");},[active,task.id]);
|
|
444
450
|
const queued = <QueuedMessages scopeKey={task.id} messages={messages.filter(message => message.role === "queued")} onCancel={message => stableCancelQueued(message.id)} attachmentUrl={attachment => `/api/tasks/${encodeURIComponent(attachment.taskId)}/uploads/${encodeURIComponent(attachment.name)}`} renderMessage={message => <MessageView m={message} initial={false} hideWho readOnly />} />;
|
|
445
451
|
return (<FileLinkContext.Provider value={fileScope}>
|
|
446
|
-
|
|
452
|
+
<SubagentChatScope scopeKey={task.id} messages={messages} load={loadSubagent} renderMessage={renderSubagentMessage}>
|
|
447
453
|
<div className="transcript-wrap">
|
|
448
454
|
<div className="transcript" ref={scrollRef} onScroll={onScroll}>
|
|
449
455
|
<div className="tw">
|
|
450
|
-
<BranchHistory entries={flow.entries} tasks={tasks} agents={agents} onSelect={onSelectTask} onRetry={flow.retry} onQuote={(m,source,selected)=>quote(m,source,selected)}
|
|
456
|
+
<SubagentHistoryBoundary><BranchHistory entries={flow.entries} tasks={tasks} agents={agents} onSelect={onSelectTask} onRetry={flow.retry} onQuote={(m,source,selected)=>quote(m,source,selected)} /></SubagentHistoryBoundary>
|
|
451
457
|
{task.parent_task_id && <div className="flow-task-heading current"><span>沿此分支继续</span><strong>{task.title}</strong></div>}
|
|
452
458
|
{transcriptLoading && messages.length === 0 && (
|
|
453
459
|
// Snapshot hasn't streamed in yet — sketch a user turn and an agent
|
|
@@ -513,7 +519,7 @@ function ChatSession({ project, task, tasks, agents, messages, running, transcri
|
|
|
513
519
|
</div>
|
|
514
520
|
{composer?.show !== false ? <Composer tasks={tasks} onFork={onFork} task={task} agentLabel={agentLabel(agents, task.agent)} disabled={task.started!==1} configBusy={configBusy} running={running} onSend={async text => { const sent = await onSend(text); if (sent && !running) scrollToBottom("auto"); return sent; }} onStop={onStop} onClear={onClear} quoteRequest={quoteRequest} onQuoteHandled={()=>setQuoteRequest(null)} controls={controls}
|
|
515
521
|
fileUpload={composer?.fileUpload} queued={queued} /> : queued}
|
|
516
|
-
|
|
522
|
+
</SubagentChatScope></FileLinkContext.Provider>
|
|
517
523
|
);
|
|
518
524
|
|
|
519
525
|
}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
|
+
import { subagentFields } from "../../core/subagents";
|
|
3
|
+
import { SubagentEntry } from "../../ui/SubagentChat";
|
|
2
4
|
import { useContext } from "react";
|
|
3
5
|
import { OperatorBoardContext } from "./boardInteraction";
|
|
4
6
|
import { FileLinkContext } from "./fileLinks";
|
|
@@ -322,7 +324,7 @@ function QuotedUserText({ text }: { text: string }) {
|
|
|
322
324
|
return quoted ? <><ReferenceCard title={quoted.quote.author} text={quoted.quote.text} renderContent={value => <Markdown>{value}</Markdown>} />{quoted.text && <Markdown>{quoted.text}</Markdown>}</> : <Markdown>{text}</Markdown>;
|
|
323
325
|
}
|
|
324
326
|
|
|
325
|
-
export const MessageView = memo(function MessageView({ m, initial, hideWho, running, agent, agentLabel = "智能体", onAnswer, onInput, onBrowser, onCancelQueued, onClear, onReconnect, onRetry, onQuote, readOnly }: { readOnly?: boolean; onQuote?: (m: Msg, selected?: string) => void; m: Msg; initial: boolean; hideWho: boolean; running?: boolean; agent?: string | null; agentLabel?: string; onAnswer?: (askId: string, questions: AskQuestion[], answers: AskAnswers) => void; onInput?: (inputId: string, values: string[]) => Promise<string | null>; onBrowser?: (action: "handback") => Promise<string | null>; onCancelQueued?: (pendingId: string) => void; onClear?: () => void; onReconnect?: () => void; onRetry?: (msgId: string) => void }) {
|
|
327
|
+
export const MessageView = memo(function MessageView({ m, initial, hideWho, running, agent, agentLabel = "智能体", userLabel = "你", speakerLabel = "智能体", onAnswer, onInput, onBrowser, onCancelQueued, onClear, onReconnect, onRetry, onQuote, readOnly }: { readOnly?: boolean; userLabel?: string; speakerLabel?: string; onQuote?: (m: Msg, selected?: string) => void; m: Msg; initial: boolean; hideWho: boolean; running?: boolean; agent?: string | null; agentLabel?: string; onAnswer?: (askId: string, questions: AskQuestion[], answers: AskAnswers) => void; onInput?: (inputId: string, values: string[]) => Promise<string | null>; onBrowser?: (action: "handback") => Promise<string | null>; onCancelQueued?: (pendingId: string) => void; onClear?: () => void; onReconnect?: () => void; onRetry?: (msgId: string) => void }) {
|
|
326
328
|
const openBoard=useContext(OperatorBoardContext);
|
|
327
329
|
const fileScope=useContext(FileLinkContext);
|
|
328
330
|
const onShowBrowser=()=>{if(fileScope&&openBoard)openBoard({kind:'browser',taskId:fileScope.taskId});else showTaskBrowser(fileScope?.taskId);};
|
|
@@ -345,6 +347,9 @@ export const MessageView = memo(function MessageView({ m, initial, hideWho, runn
|
|
|
345
347
|
}
|
|
346
348
|
if (m.role === "tool") {
|
|
347
349
|
const data = toolDataOf(m);
|
|
350
|
+
const child = subagentFields(m);
|
|
351
|
+
if (child.subagentMessage) return null;
|
|
352
|
+
if (child.subagent) return <SubagentEntry info={child.subagent} generation={m.generation} />;
|
|
348
353
|
if (data.input) {
|
|
349
354
|
return <div className="msg msg-tool" inert={readOnly||undefined}><InputView input={data.input} agentLabel={agentLabel} onSubmit={onInput} onShowBrowser={onShowBrowser} /></div>;
|
|
350
355
|
}
|
|
@@ -445,7 +450,7 @@ export const MessageView = memo(function MessageView({ m, initial, hideWho, runn
|
|
|
445
450
|
{!hideWho && (
|
|
446
451
|
<div className="who">
|
|
447
452
|
<Avatar who={isUser ? "user" : "cc"} agent={agent} />
|
|
448
|
-
{isUser ?
|
|
453
|
+
{isUser ? userLabel : speakerLabel}
|
|
449
454
|
{initial && <span className="badge">初始提示</span>}
|
|
450
455
|
{m.ts != null && <span className="msg-time">{clockTime(m.ts)}</span>}
|
|
451
456
|
</div>
|
|
@@ -200,6 +200,9 @@ export function NewTaskModal({ project, agents, tasks, onClose, onCreate, onOpen
|
|
|
200
200
|
const [sendContext, setSendContext] = useState(project.send_context !== 0);
|
|
201
201
|
const [deps, setDeps] = useState<string[]>([]);
|
|
202
202
|
const [autoStart, setAutoStart] = useState(false);
|
|
203
|
+
const submitting = useRef(false);
|
|
204
|
+
const [creating, setCreating] = useState(false);
|
|
205
|
+
const [createError, setCreateError] = useState<string | null>(null);
|
|
203
206
|
// The task's real browser (docs/BROWSER.md): offered only while the feature is on.
|
|
204
207
|
const showBrowser = clientFeatures().browser;
|
|
205
208
|
const [browser, setBrowser] = useState(false);
|
|
@@ -219,7 +222,14 @@ export function NewTaskModal({ project, agents, tasks, onClose, onCreate, onOpen
|
|
|
219
222
|
// launch otherwise, so offering "start now" here would just produce a 400).
|
|
220
223
|
const agentReady = selAgent ? !agentStateHint(selAgent) : true;
|
|
221
224
|
const canStart = !blocked && agentReady;
|
|
222
|
-
const create = () =>
|
|
225
|
+
const create = async () => {
|
|
226
|
+
if (!can || submitting.current) return;
|
|
227
|
+
submitting.current = true; setCreating(true); setCreateError(null);
|
|
228
|
+
try {
|
|
229
|
+
await onCreate({ title: title.trim(), desc: desc.trim(), priority, agent, startNow: startNow && canStart, sendContext, depends_on: deps, auto_start: autoStart && deps.length > 0, browser: showBrowser && browser });
|
|
230
|
+
} catch (error) { setCreateError(error instanceof Error ? error.message : String(error)); }
|
|
231
|
+
finally { submitting.current = false; setCreating(false); }
|
|
232
|
+
};
|
|
223
233
|
return (
|
|
224
234
|
<Modal title="新建任务" sub={`${project.name} · 标题与描述构成 ${agentLabel(agents, agent)} 的任务上下文`} onClose={onClose}
|
|
225
235
|
footer={<>
|
|
@@ -227,9 +237,10 @@ export function NewTaskModal({ project, agents, tasks, onClose, onCreate, onOpen
|
|
|
227
237
|
title={blocked ? "暂时无法开始 —— 该任务被尚未完成的任务阻塞" : !agentReady ? `${selAgent?.label} ${agentStateHint(selAgent) === "未安装" ? "的命令行工具未安装在服务器上" : "尚未连接"},无法启动会话` : undefined}>
|
|
228
238
|
<input type="checkbox" checked={startNow && canStart} disabled={!canStart} onChange={(e) => setStartNow(e.target.checked)} /> 立即开始会话
|
|
229
239
|
</label>
|
|
240
|
+
{createError && <span className="hlp" role="alert" style={{ color: "var(--red)", margin: 0 }}>创建未完成:{createError}</span>}
|
|
230
241
|
<span className="spacer" />
|
|
231
242
|
<button className="btn btn-ghost" onClick={onClose}>取消</button>
|
|
232
|
-
<button className="btn btn-accent" disabled={!can} onClick={create}>{Icon.plus()}
|
|
243
|
+
<button className="btn btn-accent" disabled={!can || creating} onClick={create}>{Icon.plus()} {creating ? "创建任务中…" : "创建任务"}</button>
|
|
233
244
|
</>}>
|
|
234
245
|
<div className="field">
|
|
235
246
|
<div className="lab">标题</div>
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { SubagentInfo, SubagentMessage } from "../../../core/subagents";
|
|
1
2
|
interface AgentProfile { base_url: string; token: string; model: string; wire_api?: string | null; api?: string | null; reasoning?: string | null }
|
|
2
3
|
|
|
3
4
|
export type Priority = "hi" | "med" | "lo";
|
|
@@ -236,8 +237,8 @@ export type StreamEvent =
|
|
|
236
237
|
| { type: "session"; sessionId: string }
|
|
237
238
|
| { type: "model"; model: string }
|
|
238
239
|
| { type: "assistant"; content: string }
|
|
239
|
-
| { type: "tool"; id: string; title: string; detail: string; peek?: ToolPeek; diff?: DiffLine[] }
|
|
240
|
-
| { type: "tool_result"; id: string; content: string; isError: boolean; peek?: ToolPeek }
|
|
240
|
+
| { type: "tool"; id: string; title: string; detail: string; peek?: ToolPeek; diff?: DiffLine[]; subagent?: SubagentInfo; subagentMessage?: SubagentMessage }
|
|
241
|
+
| { type: "tool_result"; id: string; content: string; isError: boolean; peek?: ToolPeek; subagent?: SubagentInfo; subagentMessage?: SubagentMessage }
|
|
241
242
|
| { type: "ask"; id: string; questions: AskQuestion[] }
|
|
242
243
|
| { type: "ask_answered"; id: string; answers: AskAnswers; awaiting_input?: 0 | 1 }
|
|
243
244
|
// An input card was raised or settled (the whole card each time; upsert by msgId).
|
|
@@ -311,6 +312,8 @@ export type GlobalEvent = GlobalTaskEvent | AgentAuthEvent;
|
|
|
311
312
|
|
|
312
313
|
// How a tool call is stored (JSON) in a "tool" message's content.
|
|
313
314
|
export interface ToolData {
|
|
315
|
+
subagent?: SubagentInfo;
|
|
316
|
+
subagentMessage?: SubagentMessage;
|
|
314
317
|
title: string;
|
|
315
318
|
detail?: string;
|
|
316
319
|
result?: string;
|
|
@@ -119,8 +119,8 @@ export function useTaskStream({ selTask, selProjRef, agentsRef, setTaskRunning,
|
|
|
119
119
|
else if (ev.type === "model") setTasks((prev) => prev.map((x) => (x.id === taskId ? { ...x, resolved_model: ev.model } : x)));
|
|
120
120
|
else if (ev.type === "assistant") upsertMsg(taskId, { id: ev.msgId ?? `a-${Date.now()}-${Math.random()}`, role: "assistant", content: ev.content, generation: gen, ts: ev.ts });
|
|
121
121
|
else if (ev.type === "tool") {
|
|
122
|
-
const data: ToolData = { title: ev.title, detail: ev.detail, peek: ev.peek, diff: ev.diff };
|
|
123
|
-
upsertMsg(taskId, { id: ev.msgId ?? `t-${Date.now()}-${Math.random()}`, role: "tool", content: JSON.stringify(data), generation: gen, toolId: ev.id });
|
|
122
|
+
const data: ToolData = { title: ev.title, detail: ev.detail, peek: ev.peek, diff: ev.diff, subagent: ev.subagent, subagentMessage: ev.subagentMessage };
|
|
123
|
+
upsertMsg(taskId, { id: ev.msgId ?? `t-${Date.now()}-${Math.random()}`, role: "tool", content: JSON.stringify(data), generation: gen, toolId: ev.id, ts: ev.ts });
|
|
124
124
|
} else if (ev.type === "tool_result") {
|
|
125
125
|
// Match by DB message id (works on snapshot-loaded messages too), falling
|
|
126
126
|
// back to the in-memory tool_use id. Newest first: results land near the end.
|
|
@@ -128,10 +128,14 @@ export function useTaskStream({ selTask, selProjRef, agentsRef, setTaskRunning,
|
|
|
128
128
|
const arr = prev[taskId] ?? [];
|
|
129
129
|
let i = arr.length - 1;
|
|
130
130
|
while (i >= 0 && (arr[i].role !== "tool" || (arr[i].id !== ev.msgId && arr[i].toolId !== ev.id))) i--;
|
|
131
|
-
if (i < 0)
|
|
131
|
+
if (i < 0) {
|
|
132
|
+
if (!ev.msgId || (!ev.subagent && !ev.subagentMessage)) return prev;
|
|
133
|
+
const data: ToolData = { title: ev.subagent?.title ?? ev.subagentMessage?.tool?.title ?? "子代理消息", subagent: ev.subagent, subagentMessage: ev.subagentMessage };
|
|
134
|
+
return { ...prev, [taskId]: [...arr, { id: ev.msgId, role: "tool", content: JSON.stringify(data), generation: gen, toolId: ev.id, ts: ev.ts }] };
|
|
135
|
+
}
|
|
132
136
|
const m = arr[i];
|
|
133
137
|
let content: string;
|
|
134
|
-
try { const d = JSON.parse(m.content) as ToolData; d.result = ev.content; d.isError = ev.isError; if (ev.peek) d.peek = ev.peek; content = JSON.stringify(d); } catch { return prev; }
|
|
138
|
+
try { const d = JSON.parse(m.content) as ToolData; d.result = ev.content; d.isError = ev.isError; if (ev.subagent) d.subagent = ev.subagent; if (ev.subagentMessage) d.subagentMessage = ev.subagentMessage; if (ev.peek) d.peek = ev.peek; content = JSON.stringify(d); } catch { return prev; }
|
|
135
139
|
const next = arr.slice();
|
|
136
140
|
next[i] = { ...m, content };
|
|
137
141
|
return { ...prev, [taskId]: next };
|
package/src/operator.base.css
CHANGED
|
@@ -4,6 +4,35 @@
|
|
|
4
4
|
(ported from the Claude Design hi-fi prototype)
|
|
5
5
|
========================================================================== */
|
|
6
6
|
|
|
7
|
+
/* Native CLI subagents share the transcript renderer and keep their own viewer. */
|
|
8
|
+
.subagent-chat-scope{display:flex;position:relative;flex:1;min-height:0;min-width:0;overflow:hidden;}
|
|
9
|
+
.subagent-chat-main{display:flex;flex-direction:column;flex:1;min-height:0;min-width:0;}
|
|
10
|
+
.subagent-chat-main>.session{flex:1;min-height:0;}
|
|
11
|
+
.subagent-entry{display:flex;align-items:center;gap:9px;width:100%;padding:11px 13px;border:1px solid var(--line-strong);border-radius:var(--r);background:var(--surface);color:var(--ink);text-align:left;}
|
|
12
|
+
.subagent-entry:not(:disabled):hover{border-color:var(--accent);background:var(--accent-soft);}
|
|
13
|
+
.subagent-entry:disabled{cursor:default;}
|
|
14
|
+
.subagent-entry-icon{display:flex;color:var(--accent);}
|
|
15
|
+
.subagent-entry-name{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:600;}
|
|
16
|
+
.subagent-entry svg{flex:none;width:16px;height:16px;}
|
|
17
|
+
.subagent-status{font-size:11px;color:var(--ink-3);white-space:nowrap;}
|
|
18
|
+
.subagent-status.running{color:var(--accent);}
|
|
19
|
+
.subagent-status.completed{color:var(--green);}
|
|
20
|
+
.subagent-status.failed{color:var(--red);}
|
|
21
|
+
.subagent-chat-panel{display:flex;flex-direction:column;position:relative;flex:0 0 46%;width:46%;min-width:0;max-width:560px;min-height:0;border-left:1px solid var(--line-strong);background:var(--panel);z-index:30;}
|
|
22
|
+
.subagent-chat-header{display:flex;align-items:center;gap:10px;padding:14px 16px;border-bottom:1px solid var(--line);}
|
|
23
|
+
.subagent-chat-header>div{display:flex;flex:1;min-width:0;flex-direction:column;gap:3px;}
|
|
24
|
+
.subagent-chat-header strong{font-size:14px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
|
|
25
|
+
.subagent-chat-caption{font-size:10px;color:var(--ink-3);}
|
|
26
|
+
.subagent-chat-header .icon-btn{flex:none;}
|
|
27
|
+
.subagent-chat-transcript{min-height:0;flex:1;overflow:auto;overscroll-behavior:contain;}
|
|
28
|
+
.subagent-chat-transcript .tw{width:100%;max-width:none;padding:16px;}
|
|
29
|
+
.subagent-chat-transcript .msg{max-width:100%;}
|
|
30
|
+
.subagent-chat-note{font-size:12px;color:var(--ink-3);line-height:1.6;margin-bottom:14px;overflow-wrap:anywhere;}
|
|
31
|
+
.subagent-chat-note.error{color:var(--red);}
|
|
32
|
+
.subagent-chat-latest{align-self:center;margin:6px 0;}
|
|
33
|
+
.subagent-chat-scrim{position:absolute;inset:0;background:rgba(0,0,0,.25);z-index:29;}
|
|
34
|
+
.subagent-chat-scope.narrow>.subagent-chat-panel{position:absolute;inset:0 0 0 auto;width:min(100%,560px);max-width:100%;box-shadow:-8px 0 24px rgba(0,0,0,.16);}
|
|
35
|
+
|
|
7
36
|
:root{
|
|
8
37
|
/* ---- cool near-black "mission control" palette (dark = default) ---- */
|
|
9
38
|
--bg:#080a0e;
|
package/src/operator.css
CHANGED
|
@@ -6,6 +6,35 @@
|
|
|
6
6
|
(ported from the Claude Design hi-fi prototype)
|
|
7
7
|
========================================================================== */
|
|
8
8
|
|
|
9
|
+
/* Native CLI subagents share the transcript renderer and keep their own viewer. */
|
|
10
|
+
.cli-ui .subagent-chat-scope{display:flex;position:relative;flex:1;min-height:0;min-width:0;overflow:hidden;}
|
|
11
|
+
.cli-ui .subagent-chat-main{display:flex;flex-direction:column;flex:1;min-height:0;min-width:0;}
|
|
12
|
+
.cli-ui .subagent-chat-main>.session{flex:1;min-height:0;}
|
|
13
|
+
.cli-ui .subagent-entry{display:flex;align-items:center;gap:9px;width:100%;padding:11px 13px;border:1px solid var(--line-strong);border-radius:var(--r);background:var(--surface);color:var(--ink);text-align:left;}
|
|
14
|
+
.cli-ui .subagent-entry:not(:disabled):hover{border-color:var(--accent);background:var(--accent-soft);}
|
|
15
|
+
.cli-ui .subagent-entry:disabled{cursor:default;}
|
|
16
|
+
.cli-ui .subagent-entry-icon{display:flex;color:var(--accent);}
|
|
17
|
+
.cli-ui .subagent-entry-name{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:600;}
|
|
18
|
+
.cli-ui .subagent-entry svg{flex:none;width:16px;height:16px;}
|
|
19
|
+
.cli-ui .subagent-status{font-size:11px;color:var(--ink-3);white-space:nowrap;}
|
|
20
|
+
.cli-ui .subagent-status.running{color:var(--accent);}
|
|
21
|
+
.cli-ui .subagent-status.completed{color:var(--green);}
|
|
22
|
+
.cli-ui .subagent-status.failed{color:var(--red);}
|
|
23
|
+
.cli-ui .subagent-chat-panel{display:flex;flex-direction:column;position:relative;flex:0 0 46%;width:46%;min-width:0;max-width:560px;min-height:0;border-left:1px solid var(--line-strong);background:var(--panel);z-index:30;}
|
|
24
|
+
.cli-ui .subagent-chat-header{display:flex;align-items:center;gap:10px;padding:14px 16px;border-bottom:1px solid var(--line);}
|
|
25
|
+
.cli-ui .subagent-chat-header>div{display:flex;flex:1;min-width:0;flex-direction:column;gap:3px;}
|
|
26
|
+
.cli-ui .subagent-chat-header strong{font-size:14px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
|
|
27
|
+
.cli-ui .subagent-chat-caption{font-size:10px;color:var(--ink-3);}
|
|
28
|
+
.cli-ui .subagent-chat-header .icon-btn{flex:none;}
|
|
29
|
+
.cli-ui .subagent-chat-transcript{min-height:0;flex:1;overflow:auto;overscroll-behavior:contain;}
|
|
30
|
+
.cli-ui .subagent-chat-transcript .tw{width:100%;max-width:none;padding:16px;}
|
|
31
|
+
.cli-ui .subagent-chat-transcript .msg{max-width:100%;}
|
|
32
|
+
.cli-ui .subagent-chat-note{font-size:12px;color:var(--ink-3);line-height:1.6;margin-bottom:14px;overflow-wrap:anywhere;}
|
|
33
|
+
.cli-ui .subagent-chat-note.error{color:var(--red);}
|
|
34
|
+
.cli-ui .subagent-chat-latest{align-self:center;margin:6px 0;}
|
|
35
|
+
.cli-ui .subagent-chat-scrim{position:absolute;inset:0;background:rgba(0,0,0,.25);z-index:29;}
|
|
36
|
+
.cli-ui .subagent-chat-scope.narrow>.subagent-chat-panel{position:absolute;inset:0 0 0 auto;width:min(100%,560px);max-width:100%;box-shadow:-8px 0 24px rgba(0,0,0,.16);}
|
|
37
|
+
|
|
9
38
|
.cli-ui{
|
|
10
39
|
/* ---- cool near-black "mission control" palette (dark = default) ---- */
|
|
11
40
|
--bg:#080a0e;
|