feat(web): 会话级角色 — 角色跟随激活会话 + 选角入口 + 面板挂角色(B 批次 1 前端)
按 DevDocs/design/22 §4.6/§5(7) 实现(后端由并行代理在做,按已定契约先行)。
- currentPersona 由「全局单值」改为「跟随激活会话」:新增 lib/sessionPersona.ts 解析(personaId 空 → 回落默认角色),App/TopBar/chat/lib/persona.ts 全部改为按激活会话取角色(头像/背景/名字/心情随会话切换)
- 交互:新建次级会话可选角色;会话换角色入口;管理面板 Sessions 页加「挂角色」下拉(走 PATCH /sessions/{id})
- 组件拆分:Nav.tsx 228→143(抽出 NavItem.tsx 53)、新增 SessionRail.tsx 275(会话侧栏)
- 契约:types.ts / lib/api/sessions.ts 加 personaId?(建会话/改会话支持)
- mood 改为**按角色分桶**(同一角色的多个会话共享该角色心情);升级后旧 mood 值不保留(刷新即空)——属行为变化,已在报告注明
- 测试:新增 personaSession.test.ts 40 checks(角色解析/回落/乐观更新与回滚)+ 既有 messageSync 54 checks 回归
- 验收:npm run build 绿;**未做浏览器端到端**(无 jsdom/渲染测试底座),UI 交互与失败路径提示未渲染验证
- 已知余量:useAppStore.ts 494/500 行(下次改动前应先拆 selectCurrentPersona/useCurrentPersona/useCurrentMood)
This commit is contained in:
1 parent
38a403afca
commit
66a96d2f93
16 files changed
+1044
-205
No files matched your search
@@ -0,0 +1,254 @@
|
||||
// Logic test for per-session role selection (design/22 §4.6/§7, batch 1 frontend):
|
||||
// the role the UI renders is *derived* from the active session — never a global
|
||||
// value — an unbound (empty/absent) `personaId` falls back to the default role, a
|
||||
// re-bind is written locally first and rolled back when the PATCH is rejected, and
|
||||
// the role-scoped `mood` frame lands in its own role's bucket. The project has no
|
||||
// frontend test runner and no jsdom, so this is a dependency-free plain-node check
|
||||
// that drives the real resolver (lib/persona.ts), the real re-bind helper
|
||||
// (lib/sessionPersona.ts), the real WS frame router (lib/ws.ts) and the real
|
||||
// store, and throws on the first failure.
|
||||
//
|
||||
// Run it with the esbuild that already ships with vite (no new dependency, no
|
||||
// package.json change); the banner supplies the `window.localStorage` the store's
|
||||
// persist middleware probes, which is simply absent under node:
|
||||
//
|
||||
// cd frontend/web \
|
||||
// && ./node_modules/.bin/esbuild src/store/personaSession.test.ts \
|
||||
// --bundle --platform=node --format=esm \
|
||||
// --banner:js='globalThis.window={localStorage:{getItem:()=>null,setItem:()=>{},removeItem:()=>{}}};' \
|
||||
// --outfile=/tmp/cyrene-persona-session.test.mjs \
|
||||
// && node /tmp/cyrene-persona-session.test.mjs
|
||||
//
|
||||
// `tsc --noEmit` (part of `npm run build`) type-checks this file, which is why it
|
||||
// imports nothing from node:*.
|
||||
|
||||
import { resolveMessagePersona, resolveSessionPersona } from "../lib/persona";
|
||||
import { setSessionPersona, type SessionPersonaIO } from "../lib/sessionPersona";
|
||||
import { dispatchFrame } from "../lib/ws";
|
||||
import { selectCurrentPersona, useAppStore } from "./useAppStore";
|
||||
import type { PersonaRecord, SessionMeta } from "../lib/types";
|
||||
|
||||
let checks = 0;
|
||||
|
||||
function ok(cond: boolean, what: string): void {
|
||||
checks += 1;
|
||||
if (!cond) throw new Error(`FAILED: ${what}`);
|
||||
}
|
||||
|
||||
function same<T>(got: T, want: T, what: string): void {
|
||||
ok(
|
||||
JSON.stringify(got) === JSON.stringify(want),
|
||||
`${what} — got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`
|
||||
);
|
||||
}
|
||||
|
||||
const MAIN = "web";
|
||||
const SUB_A = "web:sub-a";
|
||||
const SUB_B = "web:sub-b";
|
||||
|
||||
const PERSONAS: PersonaRecord[] = [
|
||||
{ id: "cyrene", name: "昔涟", character: "cyrene", isDefault: true, mode: "chat", memoryNamespace: "persona.cyrene" },
|
||||
{ id: "yuki", name: "雪", character: "yuki", isDefault: false, mode: "chat", memoryNamespace: "persona.yuki" },
|
||||
];
|
||||
|
||||
/** Three sessions: A bound to role Y, B explicitly unbound, main with no field at
|
||||
* all (the shape an older backend returns). */
|
||||
function sessionFixtures(): Record<string, SessionMeta> {
|
||||
return {
|
||||
[MAIN]: { id: MAIN, type: "main", createdAt: 1 },
|
||||
[SUB_A]: { id: SUB_A, type: "sub", createdAt: 2, personaId: "yuki" },
|
||||
[SUB_B]: { id: SUB_B, type: "sub", createdAt: 3, personaId: "" },
|
||||
};
|
||||
}
|
||||
|
||||
const fixtures = sessionFixtures();
|
||||
|
||||
/** 1. The rendered role follows the ACTIVE session (design/22 §4.6/§7-5). */
|
||||
function testResolutionFollowsActiveSession(): void {
|
||||
same(resolveSessionPersona(SUB_A, fixtures, PERSONAS)?.id, "yuki", "session A resolves role Y");
|
||||
same(resolveSessionPersona(SUB_B, fixtures, PERSONAS)?.id, "cyrene", "session B resolves the default role");
|
||||
same(resolveSessionPersona(MAIN, fixtures, PERSONAS)?.id, "cyrene", "a session without personaId resolves the default");
|
||||
same(resolveSessionPersona("", fixtures, PERSONAS)?.id, "cyrene", "no active session resolves the default");
|
||||
|
||||
// …and the store selector is that same answer, so the avatar/name/wallpaper
|
||||
// cannot disagree with the picker.
|
||||
useAppStore.setState({ personas: PERSONAS, sessions: fixtures, activeSessionId: SUB_A });
|
||||
same(selectCurrentPersona(useAppStore.getState())?.name, "雪", "active session A renders role Y");
|
||||
useAppStore.setState({ activeSessionId: SUB_B });
|
||||
same(selectCurrentPersona(useAppStore.getState())?.name, "昔涟", "switching to B follows its own role");
|
||||
useAppStore.setState({ activeSessionId: SUB_A });
|
||||
same(selectCurrentPersona(useAppStore.getState())?.name, "雪", "switching back follows A's role again");
|
||||
useAppStore.setState({ activeSessionId: "" });
|
||||
same(selectCurrentPersona(useAppStore.getState())?.name, "昔涟", "still loading / no session falls back to the default");
|
||||
}
|
||||
|
||||
/** 2. An empty binding — and one we cannot resolve — falls back to the default. */
|
||||
function testFallbackToDefaultRole(): void {
|
||||
const deleted: Record<string, SessionMeta> = {
|
||||
[SUB_A]: { id: SUB_A, type: "sub", createdAt: 2, personaId: "gone" },
|
||||
};
|
||||
same(resolveSessionPersona(SUB_A, deleted, PERSONAS)?.id, "cyrene", "a binding to a deleted role falls back to the default");
|
||||
same(resolveSessionPersona(SUB_A, deleted, []), null, "an empty catalog resolves nothing (neutral placeholder)");
|
||||
|
||||
// A catalog that flags no default still yields the first listed role.
|
||||
const noFlag: PersonaRecord[] = [{ ...PERSONAS[1], isDefault: false }];
|
||||
same(resolveSessionPersona(MAIN, fixtures, noFlag)?.id, "yuki", "no isDefault flag falls back to the first role");
|
||||
}
|
||||
|
||||
/** 3. A re-bind writes locally first, then persists; a rejection restores it. */
|
||||
async function testOptimisticRebind(): Promise<void> {
|
||||
const calls: string[] = [];
|
||||
const state: Record<string, string> = { [SUB_A]: "yuki" };
|
||||
const io: SessionPersonaIO = {
|
||||
read: (id) => state[id] ?? "",
|
||||
write: (id, personaId) => {
|
||||
state[id] = personaId;
|
||||
calls.push(`write ${id}=${personaId}`);
|
||||
},
|
||||
persist: (id, personaId) => {
|
||||
calls.push(`persist ${id}=${personaId}`);
|
||||
return Promise.resolve();
|
||||
},
|
||||
};
|
||||
|
||||
same(await setSessionPersona(io, SUB_A, "cyrene"), true, "an accepted re-bind resolves true");
|
||||
same(state[SUB_A], "cyrene", "local binding after a successful PATCH");
|
||||
same(
|
||||
calls,
|
||||
[`write ${SUB_A}=cyrene`, `persist ${SUB_A}=cyrene`],
|
||||
"the local write happens before the request (no request would mean no UI change)"
|
||||
);
|
||||
|
||||
// Rejection: the local value must go back, or the UI would show a role the
|
||||
// server never accepted until the next GET /sessions corrects it.
|
||||
const failedCalls: string[] = [];
|
||||
const failedState: Record<string, string> = { [SUB_A]: "yuki" };
|
||||
const failing: SessionPersonaIO = {
|
||||
read: (id) => failedState[id] ?? "",
|
||||
write: (id, personaId) => {
|
||||
failedState[id] = personaId;
|
||||
failedCalls.push(`write ${id}=${personaId}`);
|
||||
},
|
||||
persist: (id, personaId) => {
|
||||
failedCalls.push(`persist ${id}=${personaId}`);
|
||||
return Promise.reject(new Error("patch failed"));
|
||||
},
|
||||
};
|
||||
same(await setSessionPersona(failing, SUB_A, "cyrene"), false, "a rejected re-bind resolves false");
|
||||
same(failedState[SUB_A], "yuki", "local binding restored after a failed PATCH");
|
||||
same(
|
||||
failedCalls,
|
||||
[`write ${SUB_A}=cyrene`, `persist ${SUB_A}=cyrene`, `write ${SUB_A}=yuki`],
|
||||
"the rollback writes the previous value back"
|
||||
);
|
||||
|
||||
// Unchanged binding: no write, no request at all.
|
||||
let writes = 0;
|
||||
let persists = 0;
|
||||
const noop: SessionPersonaIO = {
|
||||
read: () => "yuki",
|
||||
write: () => {
|
||||
writes += 1;
|
||||
},
|
||||
persist: () => {
|
||||
persists += 1;
|
||||
return Promise.resolve();
|
||||
},
|
||||
};
|
||||
same(await setSessionPersona(noop, SUB_A, "yuki"), true, "re-picking the bound role resolves true");
|
||||
same([writes, persists], [0, 0], "re-picking the bound role fires no write and no request");
|
||||
same(await setSessionPersona(noop, "", "yuki"), false, "an empty session id is refused");
|
||||
|
||||
// A second pick that lands while the first request is in flight owns the value:
|
||||
// the older failure must not revert it.
|
||||
let rejectFirst: (err: Error) => void = () => {};
|
||||
let firstPersist = true;
|
||||
const state2: Record<string, string> = { [SUB_A]: "yuki" };
|
||||
const racing: SessionPersonaIO = {
|
||||
read: (id) => state2[id] ?? "",
|
||||
write: (id, personaId) => {
|
||||
state2[id] = personaId;
|
||||
},
|
||||
persist: () =>
|
||||
firstPersist
|
||||
? new Promise<void>((_, reject) => {
|
||||
firstPersist = false;
|
||||
rejectFirst = reject;
|
||||
})
|
||||
: Promise.resolve(),
|
||||
};
|
||||
const first = setSessionPersona(racing, SUB_A, "cyrene");
|
||||
const second = setSessionPersona(racing, SUB_A, "yuki");
|
||||
rejectFirst(new Error("patch failed"));
|
||||
same(await first, false, "the superseded request still reports its failure");
|
||||
same(await second, true, "the newer pick is accepted");
|
||||
same(state2[SUB_A], "yuki", "a stale failure does not revert the newer choice");
|
||||
}
|
||||
|
||||
/** 4. The store's optimistic write re-resolves the rendered role at once. */
|
||||
function testStoreRebind(): void {
|
||||
useAppStore.setState({ personas: PERSONAS, sessions: sessionFixtures(), activeSessionId: SUB_B });
|
||||
useAppStore.getState().setSessionPersona(SUB_B, "yuki");
|
||||
same(selectCurrentPersona(useAppStore.getState())?.id, "yuki", "the optimistic re-bind re-resolves the rendered role");
|
||||
same(useAppStore.getState().sessions[SUB_B].personaId, "yuki", "the session list carries the new binding");
|
||||
|
||||
const after = useAppStore.getState().sessions;
|
||||
useAppStore.getState().setSessionPersona("web:missing", "yuki");
|
||||
ok(useAppStore.getState().sessions === after, "an unknown session id is a no-op (map untouched)");
|
||||
same(
|
||||
Object.keys(useAppStore.getState().sessions).sort(),
|
||||
[MAIN, SUB_A, SUB_B].sort(),
|
||||
"an unknown session id cannot invent a row"
|
||||
);
|
||||
useAppStore.getState().setSessionPersona(SUB_A, "yuki");
|
||||
ok(useAppStore.getState().sessions === after, "re-picking the already-bound role is a no-op (map untouched)");
|
||||
}
|
||||
|
||||
/** 5. A message keeps the role that spoke it (design/22 §7-5): switching a
|
||||
* session's role must not re-attribute turns that were already sent. */
|
||||
function testMessageAuthoringRole(): void {
|
||||
const yuki = PERSONAS[1];
|
||||
const cyrene = PERSONAS[0];
|
||||
// The session speaks as Cyrene now; this reply was authored by Yuki.
|
||||
const before = resolveMessagePersona("yuki", PERSONAS, cyrene);
|
||||
same(before.persona?.id, "yuki", "a reply keeps its own authoring role");
|
||||
same(before.switched, true, "…and is flagged as coming from another role");
|
||||
// Same role as the session: no label worth showing (and no mis-attribution).
|
||||
const same2 = resolveMessagePersona("cyrene", PERSONAS, cyrene);
|
||||
same(same2.persona?.id, "cyrene", "a reply from the current role resolves to it");
|
||||
same(same2.switched, false, "a reply from the current role is not flagged");
|
||||
// '' (inherit the default) / absent / unknown all fall back to the session's
|
||||
// role rather than inventing a name.
|
||||
const blank = resolveMessagePersona("", PERSONAS, yuki);
|
||||
same([blank.persona?.id, blank.switched], ["yuki", false], "an empty id falls back to the session's role");
|
||||
const absent = resolveMessagePersona(undefined, PERSONAS, yuki);
|
||||
same([absent.persona?.id, absent.switched], ["yuki", false], "an absent id falls back to the session's role");
|
||||
const gone = resolveMessagePersona("gone", PERSONAS, yuki);
|
||||
same([gone.persona?.id, gone.switched], ["yuki", false], "an unknown role id falls back to the session's role");
|
||||
}
|
||||
|
||||
/** 6. `mood` frames are role-scoped: each role keeps its own mood. */
|
||||
function testMoodBuckets(): void {
|
||||
useAppStore.setState({ moods: {}, personas: PERSONAS, sessions: sessionFixtures(), activeSessionId: SUB_A });
|
||||
dispatchFrame({ type: "mood", data: { mood: "happy", personaId: "yuki" } });
|
||||
same(useAppStore.getState().moods.yuki, "happy", "a role-scoped mood frame lands in that role's bucket");
|
||||
dispatchFrame({ type: "mood", data: { mood: "sad", personaId: "cyrene" } });
|
||||
same(useAppStore.getState().moods.cyrene, "sad", "another role's mood does not overwrite it");
|
||||
dispatchFrame({ type: "mood", data: "calm" });
|
||||
same(useAppStore.getState().moods[""], "calm", "an unscoped (legacy) mood frame keeps its own bucket");
|
||||
useAppStore.getState().setMood(null, "yuki");
|
||||
same(useAppStore.getState().moods.yuki, undefined, "a null mood clears only that role's bucket");
|
||||
same(useAppStore.getState().moods[""], "calm", "clearing one bucket leaves the others alone");
|
||||
}
|
||||
|
||||
async function run(): Promise<void> {
|
||||
testResolutionFollowsActiveSession();
|
||||
testFallbackToDefaultRole();
|
||||
await testOptimisticRebind();
|
||||
testStoreRebind();
|
||||
testMessageAuthoringRole();
|
||||
testMoodBuckets();
|
||||
console.log(`personaSession.test: ${checks} checks passed`);
|
||||
}
|
||||
|
||||
void run();
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
ToolUsed,
|
||||
WsStatus,
|
||||
} from "../lib/types";
|
||||
import { resolveSessionPersona } from "../lib/persona";
|
||||
import { now, uid } from "../lib/util";
|
||||
|
||||
interface ChatContext {
|
||||
@@ -32,7 +33,12 @@ export interface AppStore {
|
||||
/** Messages of the *active* session, in display order (oldest first). Reset on
|
||||
* session switch and refilled from the backend (design/16 §4/§5). */
|
||||
messages: ChatMessage[];
|
||||
mood: string | null;
|
||||
/** Latest mood per role id (design/22 §4.6). The `mood` WS event is
|
||||
* persona-scoped and carries no `sessionId`, so each role gets its own bucket
|
||||
* and the UI shows the bucket of the *active session's* role — role X's mood
|
||||
* must not sit above role Y's conversation. `""` holds events that arrived
|
||||
* without a persona (legacy/unscoped frames). */
|
||||
moods: Record<string, string>;
|
||||
context: ChatContext;
|
||||
agents: AgentInfo[];
|
||||
backthink: BackthinkItem[];
|
||||
@@ -53,16 +59,27 @@ export interface AppStore {
|
||||
/** Push one `channel_persisted` message (newest first, dedup by id, capped ~200). */
|
||||
pushChannelEvent: (msg: AdminChannelMessage) => void;
|
||||
|
||||
/** 当前 persona(web 主会话=默认 persona)。加载前/失败时为 null,头像回退中性默认占位。 */
|
||||
currentPersona: PersonaRecord | null;
|
||||
/** The role catalog from `GET /personas` (design/22 §4.6). The role the UI
|
||||
* renders is *derived* from it plus the active session — never stored — so the
|
||||
* picker, the avatar and the mood cannot drift apart. Empty until loaded (or
|
||||
* when the fetch fails), and the UI then keeps its neutral placeholder. */
|
||||
personas: PersonaRecord[];
|
||||
/** Replace the role catalog (`GET /personas`). */
|
||||
setPersonas: (list: PersonaRecord[]) => void;
|
||||
|
||||
setWsStatus: (status: WsStatus) => void;
|
||||
setCurrentPersona: (p: PersonaRecord | null) => void;
|
||||
setContext: (patch: Partial<ChatContext>) => void;
|
||||
setMood: (mood: string | null) => void;
|
||||
/** Record one role's new mood. `personaId` is the role the event named; "" keeps
|
||||
* an unscoped/legacy frame in its own bucket. A null mood drops the bucket. */
|
||||
setMood: (mood: string | null, personaId?: string) => void;
|
||||
|
||||
/** Replace the session metadata list. */
|
||||
setSessions: (list: SessionMeta[]) => void;
|
||||
/** Re-bind one session to a role locally — the optimistic write and the rollback
|
||||
* target of `lib/sessionPersona.ts#setSessionPersona`. `""` re-binds it to
|
||||
* "inherit the default role". A no-op for an unknown session or one already
|
||||
* bound to that role. */
|
||||
setSessionPersona: (sessionId: string, personaId: string) => void;
|
||||
/** Zero one session's unread badge locally (Android 协同 B3/D4). Called when the
|
||||
* user opens the session and its page has loaded, and on the `session_read`
|
||||
* frame another end broadcasts — a no-op for an already-read or unknown
|
||||
@@ -121,7 +138,7 @@ export const useAppStore = create<AppStore>()(
|
||||
activeSessionId: "",
|
||||
wsStatus: "idle",
|
||||
messages: [],
|
||||
mood: null,
|
||||
moods: {},
|
||||
context: {},
|
||||
agents: [
|
||||
{ id: "main", name: "昔涟", status: "idle", role: "owner" },
|
||||
@@ -134,20 +151,34 @@ export const useAppStore = create<AppStore>()(
|
||||
awakenTool: null,
|
||||
sessionBusy: false,
|
||||
pendingCount: 0,
|
||||
currentPersona: null,
|
||||
personas: [],
|
||||
taskLogs: {},
|
||||
channelEvents: [],
|
||||
|
||||
setWsStatus: (status) => set({ wsStatus: status }),
|
||||
setCurrentPersona: (p) => set({ currentPersona: p }),
|
||||
setPersonas: (list) => set({ personas: list }),
|
||||
setContext: (patch) =>
|
||||
set((s) => ({ context: { ...s.context, ...patch } })),
|
||||
setMood: (mood) => set({ mood }),
|
||||
setMood: (mood, personaId = "") =>
|
||||
set((s) => {
|
||||
const moods = { ...s.moods };
|
||||
if (mood) moods[personaId] = mood;
|
||||
else delete moods[personaId];
|
||||
return { moods };
|
||||
}),
|
||||
|
||||
setSessions: (list) =>
|
||||
set({
|
||||
sessions: Object.fromEntries(list.map((s) => [s.id, s])),
|
||||
}),
|
||||
setSessionPersona: (sessionId, personaId) =>
|
||||
set((s) => {
|
||||
const meta = s.sessions[sessionId];
|
||||
if (!meta || (meta.personaId ?? "") === personaId) return s;
|
||||
return {
|
||||
sessions: { ...s.sessions, [sessionId]: { ...meta, personaId } },
|
||||
};
|
||||
}),
|
||||
setActiveSession: (id) => set({ activeSessionId: id, messages: [] }),
|
||||
clearUnread: (sessionId) =>
|
||||
set((s) => {
|
||||
@@ -203,6 +234,7 @@ export const useAppStore = create<AppStore>()(
|
||||
image: msg.image ?? m.image,
|
||||
platform: msg.platform ?? m.platform,
|
||||
deviceId: msg.deviceId ?? m.deviceId,
|
||||
personaId: msg.personaId ?? m.personaId,
|
||||
createdAt: msg.createdAt ?? m.createdAt,
|
||||
};
|
||||
return { messages };
|
||||
@@ -369,7 +401,7 @@ export const useAppStore = create<AppStore>()(
|
||||
resetChat: () =>
|
||||
set((s) => ({
|
||||
messages: [],
|
||||
mood: null,
|
||||
moods: {},
|
||||
flowGraph: null,
|
||||
backthink: s.backthink,
|
||||
awakenState: "quiet",
|
||||
@@ -381,38 +413,63 @@ export const useAppStore = create<AppStore>()(
|
||||
}),
|
||||
{
|
||||
name: "cyrene.app",
|
||||
version: 2,
|
||||
// Upgrade from v1 (which persisted a full `messages` array + `sessionId`):
|
||||
// drop those — the backend is now the message truth — and carry forward the
|
||||
// still-meaningful bits (persona avatar, mood, context, backthink).
|
||||
migrate: (persisted) => {
|
||||
version: 3,
|
||||
// v1 persisted the whole chat (`messages` + a single `sessionId`); v2 added
|
||||
// one resolved `currentPersona`, which can no longer describe the active role
|
||||
// now that the role belongs to a *session*. Both shapes are dropped:
|
||||
// `personas` is refilled from `GET /personas` and the role is re-derived from
|
||||
// the session list. `moods` is transient by nature (a restart resets a role's
|
||||
// mood), so it is not carried across either. A v1 state was never trusted for
|
||||
// the session metadata, so those users simply restart on the main session.
|
||||
migrate: (persisted, version) => {
|
||||
const p = (persisted ?? {}) as Partial<AppStore>;
|
||||
const fromV1 = version < 2;
|
||||
return {
|
||||
activeSessionId: "",
|
||||
sessions: {},
|
||||
mood: p.mood ?? null,
|
||||
activeSessionId: fromV1 ? "" : p.activeSessionId ?? "",
|
||||
sessions: fromV1 ? {} : p.sessions ?? {},
|
||||
personas: p.personas ?? [],
|
||||
moods: {},
|
||||
context: p.context ?? {},
|
||||
backthink: p.backthink ?? [],
|
||||
currentPersona: p.currentPersona ?? null,
|
||||
};
|
||||
},
|
||||
partialize: (s) => ({
|
||||
// messages deliberately excluded: the backend is the message truth
|
||||
// (design/16 §1), so we never persist the whole chat into localStorage.
|
||||
// We keep the (small) session metadata + the focused session id so a
|
||||
// refresh lands on the same conversation, plus the resolved persona for
|
||||
// the avatar/background.
|
||||
// refresh lands on the same conversation, plus the role catalog so the
|
||||
// avatar/name/wallpaper resolve before the first fetch returns.
|
||||
activeSessionId: s.activeSessionId,
|
||||
sessions: s.sessions,
|
||||
mood: s.mood,
|
||||
personas: s.personas,
|
||||
moods: s.moods,
|
||||
context: s.context,
|
||||
backthink: s.backthink,
|
||||
currentPersona: s.currentPersona,
|
||||
}),
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
/** The role the UI renders (design/22 §4.6): the active session's bound role,
|
||||
* else the global default. THE single resolution point — the avatar, name,
|
||||
* wallpaper, mood bucket and message labels all read it, so none of them can
|
||||
* disagree about "who is speaking" (design/22 §5 risk row 1). */
|
||||
export function selectCurrentPersona(s: AppStore): PersonaRecord | null {
|
||||
return resolveSessionPersona(s.activeSessionId, s.sessions, s.personas);
|
||||
}
|
||||
|
||||
/** Subscribe to the active session's role. */
|
||||
export function useCurrentPersona(): PersonaRecord | null {
|
||||
return useAppStore(selectCurrentPersona);
|
||||
}
|
||||
|
||||
/** Subscribe to the active role's mood. `mood` frames are role-scoped and carry
|
||||
* no sessionId, so a role's mood is only shown above its own sessions. */
|
||||
export function useCurrentMood(): string | null {
|
||||
const personaId = useAppStore((s) => selectCurrentPersona(s)?.id ?? "");
|
||||
return useAppStore((s) => (personaId ? s.moods[personaId] ?? null : null));
|
||||
}
|
||||
|
||||
/** Convenience selector for the last streaming assistant message id. */
|
||||
export function lastStreamingMessage(s: AppStore): ChatMessage | undefined {
|
||||
const last = s.messages[s.messages.length - 1];
|
||||
|
||||
Reference in new issue
Block a user