Merge upstream/main into codex/btw-side-question

同步官方 main 的任务会话与 spawn_task 更新,保留 /btw 旁路上下文能力。
This commit is contained in:
ji ruo committed 2026-09-11 08:47:57 +08:00
commit 272004c2e4
10 files changed
+410 -64

No files matched your search

@@ -14,6 +14,7 @@ import {
Loader2Icon,
PaperclipIcon,
PauseIcon,
PlusIcon,
RadioIcon,
RotateCwIcon,
ShieldAlertIcon,
@@ -142,7 +143,7 @@ function emptyState(): SessionState {
// BOTH the Goal Agent's round-0 decomposition and the Planner (single Plan session).
function sessionKeyOf(a: Activity): string {
if (a.worker === "system" || a.kind === "llm_switch" || a.kind === "llm_failover") return "system";
if (a.worker === "mainagent") return "main";
if (a.worker === "mainagent") return `main:${a.main_seg ?? 0}`; // one key per conversation segment
if (a.worker === "planner") return "plan";
if (a.intent_id) return `intent:${a.intent_id}`;
return "unknown";
@@ -238,14 +239,22 @@ const roleMeta = {
// The main-agent session is the interactive entry point of this tab and has no
// dedicated backend "sessions" endpoint — it is a fixed UI affordance whose
// transcript is the main-agent activity stream (worker="mainagent") for the task.
const MAIN_ID = "s-main";
// A main-agent session is one resettable conversation segment. Segment 0 is the
// original session; "新建会话" creates further segments (seq 1,2,…) so the agent
// starts on a clean transcript while the task's graph/assets/goal stay shared. Each
// segment is a switchable UI session; only the current (highest) one is writable.
const mainSessionId = (seg: number) => `s-main-${seg}`;
const mainSessionKey = (seg: number) => `main:${seg}`;
const mainSessionTitle = (seg: number) => `主 Agent · 会话 #${seg + 1}`;
const MAIN_ID = mainSessionId(0);
const MAIN_SESSION: Session = {
id: MAIN_ID,
role: "mainagent",
title: "主 Agent · 编排会话",
title: mainSessionTitle(0),
status: "running",
live: true,
last_activity: "",
seg: 0,
};
// The planner session is, like the main-agent session, a fixed UI affordance with
@@ -275,9 +284,9 @@ const SYSTEM_SESSION: Session = {
last_activity: "",
};
// keyForSession maps a UI Session → its stable store key (main | plan | intent:<id>).
// keyForSession maps a UI Session → its stable store key (main:<seg> | plan | intent:<id>).
function keyForSession(s: Session): string {
if (s.role === "mainagent") return "main";
if (s.role === "mainagent") return `main:${s.seg ?? 0}`;
if (s.role === "planner") return "plan";
if (s.role === "system") return "system";
return `intent:${s.intent_id}`;
@@ -461,6 +470,11 @@ function WorkerAssetBadge({ assets }: { assets: IntentAsset[] }) {
export function SessionsTab({ taskId }: { taskId: string }) {
const [activeId, setActiveId] = React.useState(MAIN_ID);
// Main-agent conversation segments (newest-first); currentSeg is the writable one.
const [mainSegs, setMainSegs] = React.useState<{ seq: number; created_at: string }[]>([{ seq: 0, created_at: "" }]);
const [currentSeg, setCurrentSeg] = React.useState(0);
const [creatingMain, setCreatingMain] = React.useState(false);
const [confirmNewMain, setConfirmNewMain] = React.useState(false);
// 手机端(<lg)会话列表默认折叠:屏幕高度本就紧张,列表若固定占掉 10~15rem,
// 下方的会话记录会被挤到只剩标题与输入框。折叠后记录区拿到几乎全部高度,
// 点标题栏可展开选会话,选完自动收起。桌面端不受影响(lg 起始终展开)。
@@ -504,6 +518,29 @@ export function SessionsTab({ taskId }: { taskId: string }) {
}
}
// Start a fresh main-agent session: only the segment counter advances — the task's
// graph/assets/goal are untouched, so the agent continues over the same task with a
// clean context. The old segment stays as read-only history you can switch back to.
async function createMainSession() {
if (creatingMain) return;
setCreatingMain(true);
try {
const r = await api.newMainSession(taskId);
setMainSegs((prev) => [{ seq: r.seq, created_at: r.created_at }, ...prev.filter((m) => m.seq !== r.seq)]);
setCurrentSeg(r.current ?? r.seq);
// Seed an empty, loaded state so the new (empty) session renders immediately.
setStore((prev) => ({ ...prev, [mainSessionKey(r.seq)]: { ...emptyState(), loaded: true } }));
setActiveId(mainSessionId(r.seq));
setListOpen(false);
setInput("");
} catch (e) {
toast.error(`新建会话失败:${(e as Error).message}`);
} finally {
setCreatingMain(false);
setConfirmNewMain(false);
}
}
const patchIntentState = React.useCallback((intentId: string, state?: string) => {
const patch = (rows: TaskNode[]) =>
state
@@ -558,7 +595,7 @@ export function SessionsTab({ taskId }: { taskId: string }) {
// Refs backing SSE/loading without re-render churn.
const snapshotRef = React.useRef(0); // task-level snapshot cursor → SSE since=
const esRef = React.useRef<EventSource | null>(null);
const activeKeyRef = React.useRef("main"); // current session key (for SSE dispatch/unread)
const activeKeyRef = React.useRef(mainSessionKey(0)); // current session key (for SSE dispatch/unread)
const atBottomRef = React.useRef(true); // transcript pinned to bottom?
const llmToastSeqRef = React.useRef<Set<number>>(new Set());
const chatStatusRequestRef = React.useRef(0);
@@ -802,12 +839,14 @@ export function SessionsTab({ taskId }: { taskId: string }) {
setLLMResolutions(null);
setSseLive(false);
setActiveId(MAIN_ID); // a stale worker id from the previous task must not leak in
setMainSegs([{ seq: 0, created_at: "" }]);
setCurrentSeg(0);
snapshotRef.current = 0;
llmToastSeqRef.current = new Set();
reqTokenRef.current = {};
// Reserve "main" so the active-session effect (which also fires for main on mount)
// won't double-load it and pre-empt the SSE opened here.
loadingKeysRef.current = new Set(["main"]);
// Reserve the initial main key so the active-session effect (which fires for main on
// mount) won't double-load it and pre-empt the SSE opened here.
loadingKeysRef.current = new Set([mainSessionKey(0)]);
let alive = true;
// MOCK demo: no SSE backend — pull one activity snapshot and bucket by session.
@@ -830,13 +869,13 @@ export function SessionsTab({ taskId }: { taskId: string }) {
st.earliestSeq = st.items.length ? st.items[0].seq : 0;
st.lastTs = st.items.length ? st.items[st.items.length - 1].ts : "";
}
buckets.main ??= { ...emptyState(), loaded: true };
buckets[mainSessionKey(0)] ??= { ...emptyState(), loaded: true };
buckets.system ??= { ...emptyState(), loaded: true };
setStore(buckets);
})
.catch(() =>
setStore({
main: { ...emptyState(), loaded: true },
[mainSessionKey(0)]: { ...emptyState(), loaded: true },
system: { ...emptyState(), loaded: true },
}),
);
@@ -845,15 +884,30 @@ export function SessionsTab({ taskId }: { taskId: string }) {
};
}
const token = (reqTokenRef.current.main ?? 0) + 1;
reqTokenRef.current.main = token;
patchStore("main", (s) => ({ ...s, loading: true }));
const token = (reqTokenRef.current.mainboot ?? 0) + 1;
reqTokenRef.current.mainboot = token;
let bootKey = mainSessionKey(0);
// Resolve the main-agent segments first, then load the CURRENT segment's history and
// open the SSE from its snapshot cursor. The SSE tails all segments and routes each
// frame by session_key (main:<seg>), so switching segments needs no new stream.
api
.activityHistory(taskId, "main", 0, PAGE)
.mainSessions(taskId)
.then((ms) => {
if (!alive || reqTokenRef.current.mainboot !== token) throw new Error("superseded");
const segs = ms.sessions.length ? ms.sessions : [{ seq: 0, created_at: "" }];
setMainSegs(segs);
setCurrentSeg(ms.current);
bootKey = mainSessionKey(ms.current);
if (bootKey !== mainSessionKey(0)) loadingKeysRef.current.delete(mainSessionKey(0));
loadingKeysRef.current.add(bootKey);
setActiveId(mainSessionId(ms.current));
patchStore(bootKey, (s) => ({ ...s, loading: true }));
return api.activityHistory(taskId, bootKey, 0, PAGE);
})
.then((r) => {
if (!alive || reqTokenRef.current.main !== token) return;
if (!alive || reqTokenRef.current.mainboot !== token) return;
snapshotRef.current = r.snapshotCursor;
patchStore("main", (s) => {
patchStore(bootKey, (s) => {
const items = mergeBySeq(r.items, s.items);
return {
...s,
@@ -931,10 +985,11 @@ export function SessionsTab({ taskId }: { taskId: string }) {
};
})
.catch((err) => {
if (!alive || reqTokenRef.current.main !== token) return;
patchStore("main", (s) => ({ ...s, loading: false, error: (err as Error).message || "加载失败" }));
if (!alive || reqTokenRef.current.mainboot !== token) return;
if ((err as Error).message === "superseded") return;
patchStore(bootKey, (s) => ({ ...s, loading: false, error: (err as Error).message || "加载失败" }));
})
.finally(() => loadingKeysRef.current.delete("main"));
.finally(() => loadingKeysRef.current.delete(bootKey));
return () => {
alive = false;
@@ -1100,17 +1155,52 @@ export function SessionsTab({ taskId }: { taskId: string }) {
},
[store],
);
const mainLive = sending || (mainChatRunning ?? recentLive("main"));
const currentMainKey = mainSessionKey(currentSeg);
const plannerLive = recentLive("plan");
// Which main segment is streaming RIGHT NOW. A main turn is serialized per task, so at
// most one segment is live. Gate on the real running flag (sending / mainChatRunning)
// rather than "recent activity", and drop it the moment the turn's terminal record
// (kind='result', or an error) lands — otherwise the badge lingers for STREAM_WINDOW_MS
// after the agent already finished. null = nothing running.
const liveMainSeg = React.useMemo<number | null>(() => {
if (!(sending || mainChatRunning)) return null;
// the streaming segment is the one with the freshest activity (incl. the just-sent turn)
let seg = currentSeg;
let bestTs = -1;
for (const m of mainSegs) {
const raw = store[mainSessionKey(m.seq)]?.lastTs;
const ts = raw ? Date.parse(raw) : -1;
if (ts > bestTs) {
bestTs = ts;
seg = m.seq;
}
}
const items = store[mainSessionKey(seg)]?.items ?? [];
const last = items[items.length - 1];
if (last && (last.kind === "result" || (last.kind === "text" && last.is_error))) return null;
return seg;
}, [sending, mainChatRunning, mainSegs, store, currentSeg]);
// Every main-agent segment is an independent, interactive session (like the top-level
// chat conversations) — you can talk in any of them, newest-first.
const mainSessions = React.useMemo<Session[]>(
() =>
mainSegs.map((m) => ({
id: mainSessionId(m.seq),
role: "mainagent",
title: mainSessionTitle(m.seq),
status: "running",
live: m.seq === liveMainSeg,
last_activity: m.created_at,
seg: m.seq,
})),
[mainSegs, liveMainSeg],
);
const sessions = React.useMemo(
() => [
{ ...MAIN_SESSION, live: mainLive },
{ ...PLANNER_SESSION, live: plannerLive },
...workerSessions,
SYSTEM_SESSION,
],
[workerSessions, mainLive, plannerLive],
() => [...mainSessions, { ...PLANNER_SESSION, live: plannerLive }, ...workerSessions, SYSTEM_SESSION],
[mainSessions, workerSessions, plannerLive],
);
const grouped = {
@@ -1133,6 +1223,15 @@ export function SessionsTab({ taskId }: { taskId: string }) {
const isSystem = active.role === "system";
const activeKey = keyForSession(active);
const activeState = store[activeKey];
// A main turn is serialized per task (chat lock), so "busy" is task-wide: while any
// main segment is mid-turn the active composer is disabled. Clear it the moment the
// active session's terminal record (kind='result', or an error) lands, so the input
// re-enables immediately instead of waiting for the next chat-status poll.
const activeItems = activeState?.items ?? [];
const activeLast = activeItems[activeItems.length - 1];
const activeSettled =
!!activeLast && (activeLast.kind === "result" || (activeLast.kind === "text" && activeLast.is_error));
const mainBusy = isMain && (sending || (!activeSettled && (mainChatRunning ?? recentLive(activeKey))));
// 折叠态(手机端)标题栏要替代整张列表:显示当前会话名 + 其它会话的未读合计,
// 否则收起后既不知道自己在看哪个会话,也看不到别处有新消息。
const activeDisplayTitle = (active.role === "worker" ? sessionMeta.get(active.id)?.title : "") || active.title;
@@ -1321,7 +1420,7 @@ export function SessionsTab({ taskId }: { taskId: string }) {
const text = input.trim();
const atts = attachments;
if (side.handleCommand(text, () => setInput(""))) return;
if ((!text && atts.length === 0) || sending || mainLive) return;
if ((!text && atts.length === 0) || sending || mainBusy) return;
// No optimistic echo: the backend persists+broadcasts the human turn before it
// returns, so it streams back over SSE (worker="mainagent") with its real DB
// seq — the transcript renders it from server data like every other step. Clear
@@ -1331,7 +1430,7 @@ export function SessionsTab({ taskId }: { taskId: string }) {
setSending(true);
chatStatusRequestRef.current++;
api
.chat(text, taskId, atts.length > 0 ? atts : undefined)
.chat(text, taskId, atts.length > 0 ? atts : undefined, active.seg ?? 0)
.then(({ mode }) => {
chatStatusRequestRef.current++;
setMainChatRunning(mode === "llm");
@@ -1372,7 +1471,7 @@ export function SessionsTab({ taskId }: { taskId: string }) {
.finally(() => setWorkerMessageSending(false));
}
const mainLoaded = !!store.main?.loaded;
const mainLoaded = !!store[currentMainKey]?.loaded;
// 发送键位由系统设置决定(localStorage),默认 Enter 发送。
const sendMode = useChatSendMode();
// What the transcript pane should show for the active session.
@@ -1488,6 +1587,23 @@ export function SessionsTab({ taskId }: { taskId: string }) {
<div className="flex items-center gap-1.5 px-2 py-1 text-xs font-medium text-muted-foreground">
<Meta.icon className="size-3.5" />
{Meta.label}
{role === "mainagent" && (
<button
type="button"
onClick={() => setConfirmNewMain(true)}
disabled={creatingMain}
title="新建主 Agent 会话(清空上下文,任务状态保留)"
aria-label="新建主 Agent 会话"
className="ml-auto flex items-center gap-1 rounded px-1.5 py-0.5 text-[11px] text-muted-foreground hover:bg-accent/60 hover:text-foreground disabled:opacity-50"
>
{creatingMain ? (
<Loader2Icon className="size-3.5 animate-spin" />
) : (
<PlusIcon className="size-3.5" />
)}
新建
</button>
)}
</div>
{items.map((s) => {
const meta = s.role === "worker" ? sessionMeta.get(s.id) : undefined;
@@ -1736,7 +1852,7 @@ export function SessionsTab({ taskId }: { taskId: string }) {
<InputGroupTextarea
rows={1}
aria-label="给主 Agent 发消息"
placeholder={mainLive ? "主 Agent 正在运行,可输入 /btw 提问…" : "给主 Agent 发消息,引导探索方向…"}
placeholder={mainBusy ? "主 Agent 正在运行,可输入 /btw 提问…" : "给主 Agent 发消息,引导探索方向…"}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
@@ -1751,18 +1867,18 @@ export function SessionsTab({ taskId }: { taskId: string }) {
size="icon-xs"
variant="ghost"
onClick={() => fileInputRef.current?.click()}
disabled={mainLive || uploading}
disabled={mainBusy || uploading}
title="上传文件"
aria-label="上传文件"
>
{uploading ? <Loader2Icon className="animate-spin" /> : <PaperclipIcon />}
</InputGroupButton>
{mainLive && isBtwCommand(input) && (
{mainBusy && isBtwCommand(input) && (
<InputGroupButton size="icon-xs" onClick={send} aria-label="发送旁路问题">
<ArrowUpIcon />
</InputGroupButton>
)}
{mainLive ? (
{mainBusy ? (
<InputGroupButton
className="ml-auto"
size="icon-xs"
@@ -1933,6 +2049,23 @@ export function SessionsTab({ taskId }: { taskId: string }) {
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<AlertDialog open={confirmNewMain} onOpenChange={(open) => !open && setConfirmNewMain(false)}>
<AlertDialogContent className="max-w-[min(32rem,calc(100vw-2rem))]">
<AlertDialogHeader>
<AlertDialogTitle>开启新会话?</AlertDialogTitle>
<AlertDialogDescription className="break-words whitespace-normal">
当前会话会被归档(可随时切回),主 Agent 将以干净的上下文继续。任务的图谱、资产、目标不受影响。
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={creatingMain}>取消</AlertDialogCancel>
<AlertDialogAction disabled={creatingMain} onClick={() => void createMainSession()}>
{creatingMain ? "开启中…" : "开启新会话"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</TooltipProvider>
);