@@ -97,14 +118,23 @@ export function FlowBody({
subtitle="实时 输入 → 思考 → 工具 → 输出 主干;并行子代理独立泳道;点节点看参数 / 结果 / 实时日志;可按轮回放历史"
/>
- {onSelectTurn ? (
+ {onSelectTurn || onSelectSession ? (
}
label="泳道"
- value={grouping?.lanes.length ?? 0}
- sub="并行子代理分支"
+ value={laneCount > 0 ? laneCount : "—"}
+ sub={laneCount > 0 ? "并行子代理分支" : "本回合未派发子代理"}
tone="accent"
/>
void;
+}): React.ReactNode {
+ return (
+
+
+
+
+
+ 换会话后,本页的图 / 轮次列表都跟着切
+
+
+
+ 只列发布会话白名单内的会话(主会话 web / 自主思考 sess-backthink / 你的次级会话
+ web:*)。白名单之外的会话(群聊 / 陌生人的 sess-*)永远没有链路图,因此不在这个列表里
+ —— 那不是缺陷。
+
+
+ );
+}
diff --git a/frontend/web/src/admin/modules/flow/FlowSnapshotHint.tsx b/frontend/web/src/admin/modules/flow/FlowSnapshotHint.tsx
index 4f65e90..c02adc2 100644
--- a/frontend/web/src/admin/modules/flow/FlowSnapshotHint.tsx
+++ b/frontend/web/src/admin/modules/flow/FlowSnapshotHint.tsx
@@ -1,12 +1,4 @@
-import { BACKTHINK_SESSION_ID } from "../../../store/sessionCache";
-
-/** 会话 id → 人话标签(图上「这是哪个会话的图」必须看得懂)。未知会话
- * 原样显示 id,绝不猜一个更好听的名字。 */
-function sessionLabel(sessionId: string): string {
- if (sessionId === BACKTHINK_SESSION_ID) return "后台思考会话";
- if (sessionId === "web") return "Web 主会话";
- return sessionId;
-}
+import { flowSessionLabel } from "./flowSession";
/** 链路图快照的诚实说明条(契约 v0.71 §4.11(b)-1)。
*
@@ -25,7 +17,7 @@ export function FlowSnapshotHint({
sessionId: string;
source: "live" | "snapshot";
}): React.ReactNode {
- const label = sessionLabel(sessionId);
+ const label = flowSessionLabel(sessionId);
return (
{source === "snapshot" ? (
diff --git a/frontend/web/src/admin/modules/flow/flowSession.test.ts b/frontend/web/src/admin/modules/flow/flowSession.test.ts
new file mode 100644
index 0000000..4935c12
--- /dev/null
+++ b/frontend/web/src/admin/modules/flow/flowSession.test.ts
@@ -0,0 +1,215 @@
+// Flow 页**会话选择器**的逻辑测试(切片 FV1)。
+//
+// 为什么需要它:后端端点本来就支持任意会话(白名单 `web` / `web:*` / `sess-backthink`,
+// 见 `cmd/cyrene/wire_runtime.go#flowSessionAllowed`),缺口纯在前端 —— 页面固定看激活
+// 会话的图(兼容字段 `flowGraph`),于是 `sess-backthink` 只能在后台思考页看。
+//
+// 本项目没有前端测试运行器、也没有 jsdom:store 驱动的组件渲染不出真实 state
+// (zustand v5 的 SSR 快照取 `getInitialState()`),因此这里钉的是**取数接线**这个纯函数
+// 家族(`flowPageData` / `flowSessionOptions` / `resolveFlowSessionId`),并驱动**真的**
+// api 客户端与**真的** store 写入路径,验证「选中的会话 id 真的传到了取数层」。
+//
+// 跑法:
+//
+// cd frontend/web \
+// && ./node_modules/.bin/esbuild src/admin/modules/flow/flowSession.test.ts \
+// --bundle --platform=node --format=esm \
+// --banner:js='globalThis.window={localStorage:{getItem:()=>null,setItem:()=>{},removeItem:()=>{}}};' \
+// --outfile=/tmp/cyrene-flow-session.test.mjs \
+// && node /tmp/cyrene-flow-session.test.mjs
+//
+// `tsc --noEmit` 会对本文件做类型检查,因此它不 import node:*(用 globalThis 取 fetch)。
+
+import { adminFlowGraph } from "../../../lib/api";
+import { useAppStore } from "../../../store/useAppStore";
+import { BACKTHINK_SESSION_ID, MAIN_SESSION_ID } from "../../../store/sessionCache";
+import type { FlowGraph, SessionMeta } from "../../../lib/types";
+import {
+ flowPageData,
+ flowSessionLabel,
+ flowSessionOptions,
+ isFlowSessionAllowed,
+ resolveFlowSessionId,
+} from "./flowSession";
+
+let checks = 0;
+
+function ok(cond: boolean, what: string): void {
+ checks += 1;
+ if (!cond) throw new Error(`FAILED: ${what}`);
+}
+
+function same(got: T, want: T, what: string): void {
+ checks += 1;
+ if (got !== want) throw new Error(`FAILED: ${what} — got ${String(got)}, want ${String(want)}`);
+}
+
+const roundGraph = (id: string, sessionId: string): FlowGraph => ({
+ id,
+ sessionId,
+ nodes: [
+ { id: "input", label: "你好", kind: "input", status: "done" },
+ { id: "llm-1", label: "思考", kind: "llm", status: "done" },
+ ],
+ edges: [{ from: "input", to: "llm-1" }],
+});
+
+/** 记下每个请求的 URL 并回一份假响应(同 useFlowSnapshot.test.ts 的手法)。 */
+interface FetchCall {
+ url: string;
+ method: string;
+}
+const calls: FetchCall[] = [];
+const originalFetch = globalThis.fetch;
+const originalLocalStorage = globalThis.localStorage;
+
+/** api 客户端的 `request` 会读 `localStorage` 取 token;node 下没有它。 */
+globalThis.localStorage = {
+ getItem: () => null,
+ setItem: () => {},
+ removeItem: () => {},
+} as unknown as Storage;
+
+function stubFetch(body: unknown, status = 200): void {
+ globalThis.fetch = ((url: string, init?: { method?: string }) => {
+ calls.push({ url: String(url), method: init?.method ?? "GET" });
+ return Promise.resolve({
+ ok: status >= 200 && status < 300,
+ status,
+ headers: { get: () => "application/json" },
+ json: () => Promise.resolve(body),
+ });
+ }) as unknown as typeof globalThis.fetch;
+}
+
+// ── ① 白名单与后端 flowSessionAllowed 逐条对齐 ───────────────────────────
+same(isFlowSessionAllowed(MAIN_SESSION_ID), true, "主会话 web 在白名单内");
+same(isFlowSessionAllowed(BACKTHINK_SESSION_ID), true, "后台思考会话 sess-backthink 在白名单内");
+same(isFlowSessionAllowed("web:7f3a"), true, "web: 前缀的次级会话在白名单内");
+same(isFlowSessionAllowed("web:"), false, "裸前缀 web: 不是会话(后端 sessionread 同样拒绝)");
+same(isFlowSessionAllowed(""), false, "空 id 不在白名单内");
+same(isFlowSessionAllowed("sess-group-1"), false, "群聊的 sess-* 不在白名单内(永远没有图)");
+same(isFlowSessionAllowed("sess-stranger-2"), false, "陌生人的 sess-* 不在白名单内");
+
+// ── ② 选项只含白名单内会话;默认选中主会话 web ───────────────────────────
+const sessionList: SessionMeta[] = [
+ { id: MAIN_SESSION_ID, type: "main", createdAt: 1 },
+ { id: "web:b", type: "sub", createdAt: 200, title: "写周报" },
+ { id: "web:a", type: "sub", createdAt: 100 },
+ { id: "sess-group-1", type: "sub", createdAt: 900 },
+ { id: "sess-backthink", type: "sub", createdAt: 950 },
+];
+useAppStore.getState().resetChat();
+useAppStore.getState().setSessions(sessionList);
+useAppStore.getState().setActiveSession(MAIN_SESSION_ID);
+
+const options = flowSessionOptions(useAppStore.getState().sessions);
+same(
+ options.map((o) => o.id).join(","),
+ "web,sess-backthink,web:b,web:a",
+ "选项 = 固定 web + 固定 sess-backthink + web: 次级会话(按创建时间倒序)"
+);
+ok(
+ !options.some((o) => o.id === "sess-group-1"),
+ "群聊会话绝不进选项(列出来就是「选了永远空态」的假承诺)"
+);
+same(options[0].label, "Web 主会话(web)", "主会话选项文案带人话名 + id");
+same(options[1].label, "后台思考会话(sess-backthink)", "后台思考会话选项文案带人话名 + id");
+same(options[2].label, "写周报(web:b)", "次级会话有标题时用标题 + id");
+same(options[3].label, "次级会话(web:a)", "次级会话无标题时用人话标签 + id");
+
+same(resolveFlowSessionId(MAIN_SESSION_ID, options), "web", "默认选中主会话 web");
+same(resolveFlowSessionId("", options), "web", "还没有选中值时是主会话 web(= 默认行为)");
+same(
+ resolveFlowSessionId("sess-group-1", options),
+ "web",
+ "选中值不在选项里(白名单外 / 会话被删)⇒ 回落 web,绝不出现「显示 A 却取 B」"
+);
+
+// ── ③ 切到 sess-backthink ⇒ 取数参数就是它,图来自它自己的桶 ─────────────
+const webGraph = roundGraph("web-9", MAIN_SESSION_ID);
+const btGraph = roundGraph("sess-backthink-3", BACKTHINK_SESSION_ID);
+useAppStore.getState().setFlowGraph(webGraph, MAIN_SESSION_ID);
+useAppStore.getState().setFlowGraph(btGraph, BACKTHINK_SESSION_ID);
+
+const btPage = flowPageData(
+ useAppStore.getState().sessions,
+ useAppStore.getState().flowGraphs,
+ BACKTHINK_SESSION_ID
+);
+same(
+ btPage.sessionId,
+ BACKTHINK_SESSION_ID,
+ "切到 sess-backthink ⇒ 传给取数层(useFlowSnapshot / useFlowTurns / REST)的 sessionId 就是它"
+);
+same(btPage.graph?.id, "sess-backthink-3", "图取自选中会话自己的图桶");
+
+const compat = useAppStore.getState().flowGraph;
+same(
+ compat?.id,
+ "web-9",
+ "兼容字段 flowGraph 仍是激活会话(web)的图 —— 正因如此,用它就会「切了也白切」"
+);
+ok(
+ btPage.graph?.id !== compat?.id,
+ "切会话后画的图必须与激活会话的图不同(这一条正是本切片要修的洞)"
+);
+same(
+ flowPageData(useAppStore.getState().sessions, useAppStore.getState().flowGraphs, MAIN_SESSION_ID)
+ .graph?.id,
+ "web-9",
+ "切回主会话 ⇒ 画的又是主会话的图"
+);
+same(
+ flowPageData(useAppStore.getState().sessions, useAppStore.getState().flowGraphs, "web:a").graph,
+ null,
+ "切到还没有图的次级会话 ⇒ graph 就是 null(空态由文案负责,绝不画假图)"
+);
+
+// ── ④ 次级会话(web:)一样能取到自己的图 ────────────────────────────────
+useAppStore.getState().setFlowGraph(roundGraph("web:b-1", "web:b"), "web:b");
+const subPage = flowPageData(
+ useAppStore.getState().sessions,
+ useAppStore.getState().flowGraphs,
+ "web:b"
+);
+same(subPage.sessionId, "web:b", "次级会话的 sessionId 原样交给取数层");
+same(subPage.graph?.id, "web:b-1", "次级会话的图来自它自己的桶");
+
+// ── ⑤ 取数层边界:解析出的 sessionId 真的进了请求,回包落进它自己的桶 ────
+stubFetch({
+ data: { sessionId: BACKTHINK_SESSION_ID, graph: roundGraph("sess-backthink-9", BACKTHINK_SESSION_ID) },
+});
+const resp = await adminFlowGraph(btPage.sessionId);
+ok(
+ calls[0].url.includes("/admin/flow/graph") && calls[0].url.includes("sessionId=sess-backthink"),
+ `取数请求必须带上选中的会话:${calls[0].url}`
+);
+same(calls[0].method, "GET", "取数是只读 GET");
+if (resp.graph) useAppStore.getState().setFlowGraph(resp.graph, btPage.sessionId);
+same(
+ useAppStore.getState().flowGraphs[BACKTHINK_SESSION_ID]?.id,
+ "sess-backthink-9",
+ "回包按选中会话写进它的桶(hook 里就是这么写的)"
+);
+same(
+ useAppStore.getState().flowGraphs[MAIN_SESSION_ID]?.id,
+ "web-9",
+ "给后台思考取数绝不顶掉主会话的图"
+);
+
+// ── ⑥ 标签:已知会话给人话名,未知会话原样显示 id ─────────────────────────
+same(flowSessionLabel(MAIN_SESSION_ID), "Web 主会话", "web 的人话标签");
+same(flowSessionLabel(BACKTHINK_SESSION_ID), "后台思考会话", "sess-backthink 的人话标签");
+same(flowSessionLabel("web:7f3a"), "次级会话", "次级会话的人话标签");
+same(
+ flowSessionLabel("sess-group-1"),
+ "sess-group-1",
+ "未知会话原样显示 id,绝不猜一个更好听的名字"
+);
+same(flowSessionLabel(MAIN_SESSION_ID, "主会话"), "主会话", "有标题时用标题");
+
+globalThis.fetch = originalFetch;
+globalThis.localStorage = originalLocalStorage;
+
+console.log(`flow session logic test: ${checks} checks passed`);
diff --git a/frontend/web/src/admin/modules/flow/flowSession.ts b/frontend/web/src/admin/modules/flow/flowSession.ts
new file mode 100644
index 0000000..905b645
--- /dev/null
+++ b/frontend/web/src/admin/modules/flow/flowSession.ts
@@ -0,0 +1,112 @@
+import type { FlowGraph, SessionMeta } from "../../../lib/types";
+import { BACKTHINK_SESSION_ID, MAIN_SESSION_ID } from "../../../store/sessionCache";
+
+/**
+ * Flow 页**会话选择器**的纯逻辑(切片 FV1;design/19 §2 活动状态)。
+ *
+ * 背景(只读调查 agent-283 第 1 问):后端端点本来就支持**任意会话**
+ * (`GET /api/v1/admin/flow/graph?sessionId=`,白名单见
+ * `cmd/cyrene/wire_runtime.go#flowSessionAllowed`),缺口纯在前端 —— 页面固定看
+ * 「激活会话」的图(兼容字段 `flowGraph`),于是 `sess-backthink` 只能在后台思考页看。
+ *
+ * 三条口径:
+ * ① 选项**只列白名单内**的会话:固定 `web`(主会话)、`sess-backthink`(自主思考),
+ * 加上 `web:` 开头的次级会话。白名单外的会话(群聊 / 陌生人的 `sess-*`)永远没有图
+ * ⇒ 不列,绝不用一份假承诺骗用户去选;
+ * ② 取数用的 sessionId 由**选择器的选中值**决定,图取自 `flowGraphs[选中会话]`。
+ * 绝不回落到按 `activeSessionId` 取图的兼容字段 `flowGraph` —— 那样「切了也白切」;
+ * ③ 选中值不在选项里(会话被删 / 会话列表还没加载)⇒ 回落主会话 `web`,
+ * 绝不出现「选择器显示 A、图却取自 B」。
+ *
+ * 从组件里抽成纯函数(同 `flowHistory.ts` 的手法):本项目没有 jsdom,store 驱动的组件
+ * 渲染不出真实 state,而「用哪个会话取数」这条判定必须在逻辑测试里钉得死。
+ */
+
+/** 会话选择器的一项。 */
+export interface FlowSessionOption {
+ id: string;
+ label: string;
+}
+
+/** Flow 页的取数接线:`sessionId` 给取数层(hook / REST)用,`graph` 是屏幕上要画的图。 */
+export interface FlowPageData {
+ /** 取数用的会话 id(`useFlowSnapshot` / `useFlowTurns` / REST 的 `sessionId`)。 */
+ sessionId: string;
+ /** 该会话的图(取自**它自己的**图桶;没有就是没有,绝不编图)。 */
+ graph: FlowGraph | null;
+ /** 会话选择器选项(只含白名单内会话)。 */
+ options: FlowSessionOption[];
+}
+
+/** 会话 id 是否在发布会话白名单内 —— 与后端 `flowSessionAllowed` 逐条对齐
+ * (`web` / `web:*` / `sess-backthink`)。两份白名单必须一致:前端多列一个会话
+ * 就是「选了永远空态」,少列一个就是「有图却不让看」。 */
+export function isFlowSessionAllowed(sessionId: string): boolean {
+ if (!sessionId) return false;
+ if (sessionId === MAIN_SESSION_ID || sessionId === BACKTHINK_SESSION_ID) return true;
+ return isSubSessionId(sessionId);
+}
+
+/** `web:` 前缀的**次级会话**。裸前缀 `web:`(没有后缀)不算会话 —— 后端读取层同样
+ * 拒绝它(`internal/runtime/sessionread` 的用例 `{"web:", false}`)。 */
+function isSubSessionId(sessionId: string): boolean {
+ return (
+ sessionId.startsWith(`${MAIN_SESSION_ID}:`) &&
+ sessionId.length > MAIN_SESSION_ID.length + 1
+ );
+}
+
+/** 会话 id → 人话标签(选择器 / 说明条上「这是哪个会话」必须看得懂)。有标题用标题,
+ * 否则用已知会话的人话名;未知会话原样显示 id,绝不猜一个更好听的名字。 */
+export function flowSessionLabel(sessionId: string, title?: string): string {
+ const named = title?.trim();
+ if (named) return named;
+ if (sessionId === BACKTHINK_SESSION_ID) return "后台思考会话";
+ if (sessionId === MAIN_SESSION_ID) return "Web 主会话";
+ if (isSubSessionId(sessionId)) return "次级会话";
+ return sessionId;
+}
+
+/** 选择器选项:固定 `web` 与 `sess-backthink` 恒在(主会话是固定 id、后台思考会话由后端
+ * 创建,都不依赖 `GET /sessions` 的返回),次级会话按创建时间**倒序**(最近用的先选到)。
+ * 文案一律带 `(id)`:切会话的前提是用户认得出自己切到了哪个会话。 */
+export function flowSessionOptions(sessions: Record): FlowSessionOption[] {
+ const subs = Object.values(sessions)
+ .filter((s) => isSubSessionId(s.id))
+ .sort((a, b) => b.createdAt - a.createdAt);
+ const options: FlowSessionOption[] = [
+ optionOf(MAIN_SESSION_ID, sessions[MAIN_SESSION_ID]?.title),
+ optionOf(BACKTHINK_SESSION_ID, sessions[BACKTHINK_SESSION_ID]?.title),
+ ];
+ for (const s of subs) options.push(optionOf(s.id, s.title));
+ return options;
+}
+
+/** 一项选项(人话标签 + id)。 */
+function optionOf(sessionId: string, title?: string): FlowSessionOption {
+ return { id: sessionId, label: `${flowSessionLabel(sessionId, title)}(${sessionId})` };
+}
+
+/** 选中值 → 真正用于取数的 sessionId。选中值不在选项里(会话被删 / 列表未加载 /
+ * 选中了白名单外的东西)⇒ 回落主会话 `web`(= 默认选中),绝不出现「显示 A 取 B」。 */
+export function resolveFlowSessionId(
+ picked: string,
+ options: readonly FlowSessionOption[],
+): string {
+ return options.some((o) => o.id === picked) ? picked : MAIN_SESSION_ID;
+}
+
+/** Flow 页的取数接线(纯函数):会话列表 + 图桶 + 选择器选中值 ⇒
+ * 「用哪个 sessionId 取数 / 画哪张图 / 有哪些选项」。
+ *
+ * `graph` **只看选中会话自己的图桶**,绝不回落到兼容字段 `flowGraph`(它按
+ * `activeSessionId` 取 ⇒ 切了会话图也不变,本切片要修的就是这个洞)。 */
+export function flowPageData(
+ sessions: Record,
+ flowGraphs: Record,
+ picked: string,
+): FlowPageData {
+ const options = flowSessionOptions(sessions);
+ const sessionId = resolveFlowSessionId(picked, options);
+ return { sessionId, graph: flowGraphs[sessionId] ?? null, options };
+}
diff --git a/frontend/web/src/admin/modules/flow/flowSessionPicker.render.test.ts b/frontend/web/src/admin/modules/flow/flowSessionPicker.render.test.ts
new file mode 100644
index 0000000..f532ea3
--- /dev/null
+++ b/frontend/web/src/admin/modules/flow/flowSessionPicker.render.test.ts
@@ -0,0 +1,215 @@
+// Flow 页**会话选择器**与「泳道 0」文案的**真实渲染**测试(切片 FV1)。
+//
+// 为什么需要它:① 选择器必须**只列白名单内**会话且默认选中主会话 web(列了白名单外的
+// 会话就是假承诺;默认选错会话就是换了主人的页面);② 统计卡「泳道」在 0 泳道时**不得**
+// 显示 `0` —— 数字紧挨标签会被读成「泳道 0」(主人截图里的误读),事实是「本回合没有
+// 派发子代理」;③ 没有图时仍是诚实空态(绝不画假图)。
+//
+// 逻辑测试(flowSession.test.ts)钉住「选哪个会话 ⇒ 用哪个 sessionId 取数」;这里钉住
+// 「界面真的按它渲染」。本项目没有 jsdom ⇒ 渲染的对象是 **props 驱动**的组件(同
+// flowHistory.render.test.ts)。
+//
+// 跑法:
+//
+// cd frontend/web \
+// && ./node_modules/.bin/esbuild src/admin/modules/flow/flowSessionPicker.render.test.ts \
+// --bundle --platform=node --format=cjs \
+// --outfile=/tmp/cyrene-flow-session-model.test.cjs \
+// && node /tmp/cyrene-flow-session-model.test.cjs
+
+import { createElement } from "react";
+import { renderToStaticMarkup } from "react-dom/server";
+import type { FlowGraph, SessionMeta } from "../../../lib/types";
+import { BACKTHINK_SESSION_ID, MAIN_SESSION_ID } from "../../../store/sessionCache";
+import { FlowBody } from "../Flow";
+import { FlowSessionPicker } from "./FlowSessionPicker";
+import { flowSessionOptions } from "./flowSession";
+
+let checks = 0;
+
+function ok(cond: boolean, what: string): void {
+ checks += 1;
+ if (!cond) throw new Error(`FAILED: ${what}`);
+}
+
+function same(got: T, want: T, what: string): void {
+ checks += 1;
+ if (got !== want) throw new Error(`FAILED: ${what} — got ${String(got)}, want ${String(want)}`);
+}
+
+function has(haystack: string, needle: string): boolean {
+ return haystack.includes(needle);
+}
+
+/** SSR 渲染出的 `