feat(webui): refresh session titles from live updates

This commit is contained in:
Xubin Ren
2026-05-13 13:10:21 +00:00
parent 79e528119c
commit fb508a302a
8 changed files with 154 additions and 22 deletions
-5
View File
@@ -294,11 +294,6 @@ export function useNanobotStream(
return;
}
if (ev.event === "session_updated") {
onTurnEnd?.();
return;
}
if (ev.event === "message") {
if (
suppressStreamUntilTurnEndRef.current &&
+6
View File
@@ -91,6 +91,12 @@ export function useSessions(): {
void refresh();
}, [refresh]);
useEffect(() => {
return client.onSessionUpdate(() => {
void refresh();
});
}, [client, refresh]);
const createChat = useCallback(async (): Promise<string> => {
const chatId = await client.newChat();
const key = `websocket:${chatId}`;
+20
View File
@@ -15,6 +15,7 @@ type Unsubscribe = () => void;
type EventHandler = (ev: InboundEvent) => void;
type StatusHandler = (status: ConnectionStatus) => void;
type RuntimeModelHandler = (modelName: string | null, modelPreset?: string | null) => void;
type SessionUpdateHandler = (chatId: string) => void;
/** Structured connection-level errors surfaced to the UI.
*
@@ -60,6 +61,7 @@ export class NanobotClient {
private socket: WebSocket | null = null;
private statusHandlers = new Set<StatusHandler>();
private runtimeModelHandlers = new Set<RuntimeModelHandler>();
private sessionUpdateHandlers = new Set<SessionUpdateHandler>();
private errorHandlers = new Set<ErrorHandler>();
// chat_id -> handlers listening on it
private chatHandlers = new Map<string, Set<EventHandler>>();
@@ -116,6 +118,13 @@ export class NanobotClient {
};
}
onSessionUpdate(handler: SessionUpdateHandler): Unsubscribe {
this.sessionUpdateHandlers.add(handler);
return () => {
this.sessionUpdateHandlers.delete(handler);
};
}
/** Subscribe to transport-level faults (see :type:`StreamError`). */
onError(handler: ErrorHandler): Unsubscribe {
this.errorHandlers.add(handler);
@@ -259,6 +268,11 @@ export class NanobotClient {
return;
}
if (parsed.event === "session_updated") {
this.emitSessionUpdate(parsed.chat_id);
return;
}
const chatId = (parsed as { chat_id?: string }).chat_id;
if (chatId) this.dispatch(chatId, parsed);
}
@@ -269,6 +283,12 @@ export class NanobotClient {
}
}
private emitSessionUpdate(chatId: string): void {
for (const handler of this.sessionUpdateHandlers) {
handler(chatId);
}
}
private dispatch(chatId: string, ev: InboundEvent): void {
const handlers = this.chatHandlers.get(chatId);
if (!handlers) return;
+19
View File
@@ -109,6 +109,25 @@ describe("NanobotClient", () => {
expect(handler).toHaveBeenCalledWith("openai/gpt-4.1", "fast");
});
it("dispatches session updates globally", () => {
const client = new NanobotClient({
url: "ws://test",
reconnect: false,
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
});
const globalHandler = vi.fn();
const chatHandler = vi.fn();
client.onSessionUpdate(globalHandler);
client.onChat("chat-title", chatHandler);
client.connect();
lastSocket().fakeOpen();
lastSocket().fakeMessage({ event: "session_updated", chat_id: "chat-title" });
expect(globalHandler).toHaveBeenCalledWith("chat-title");
expect(chatHandler).not.toHaveBeenCalled();
});
it("resolves newChat() via the server-assigned chat_id", async () => {
const client = new NanobotClient({
url: "ws://test",
-16
View File
@@ -477,20 +477,4 @@ describe("useNanobotStream", () => {
expect(onTurnEnd).toHaveBeenCalledTimes(1);
});
it("refreshes session metadata when the server reports a session update", () => {
const fake = fakeClient();
const onTurnEnd = vi.fn();
renderHook(() => useNanobotStream("chat-title", EMPTY_MESSAGES, false, onTurnEnd), {
wrapper: wrap(fake.client),
});
act(() => {
fake.emit("chat-title", {
event: "session_updated",
chat_id: "chat-title",
});
});
expect(onTurnEnd).toHaveBeenCalledTimes(1);
});
});
+47
View File
@@ -17,12 +17,20 @@ vi.mock("@/lib/api", async (importOriginal) => {
});
function fakeClient() {
const sessionUpdateHandlers = new Set<(chatId: string) => void>();
return {
status: "open" as const,
defaultChatId: null as string | null,
onStatus: () => () => {},
onError: () => () => {},
onChat: () => () => {},
onSessionUpdate: (handler: (chatId: string) => void) => {
sessionUpdateHandlers.add(handler);
return () => sessionUpdateHandlers.delete(handler);
},
emitSessionUpdate: (chatId: string) => {
for (const handler of sessionUpdateHandlers) handler(chatId);
},
sendMessage: vi.fn(),
newChat: vi.fn(),
attach: vi.fn(),
@@ -87,6 +95,45 @@ describe("useSessions", () => {
expect(result.current.sessions.map((s) => s.key)).toEqual(["websocket:chat-b"]);
});
it("refreshes sessions when the websocket reports a session update", async () => {
vi.mocked(api.listSessions)
.mockResolvedValueOnce([
{
key: "websocket:chat-a",
channel: "websocket",
chatId: "chat-a",
createdAt: "2026-04-16T10:00:00Z",
updatedAt: "2026-04-16T10:00:00Z",
preview: "",
},
])
.mockResolvedValueOnce([
{
key: "websocket:chat-a",
channel: "websocket",
chatId: "chat-a",
createdAt: "2026-04-16T10:00:00Z",
updatedAt: "2026-04-16T10:01:00Z",
title: "生成的小标题",
preview: "用户第一句话",
},
]);
const client = fakeClient();
const { result } = renderHook(() => useSessions(), {
wrapper: wrap(client),
});
await waitFor(() => expect(result.current.sessions[0]?.title).toBeUndefined());
act(() => {
client.emitSessionUpdate("chat-a");
});
await waitFor(() => expect(result.current.sessions[0]?.title).toBe("生成的小标题"));
expect(api.listSessions).toHaveBeenCalledTimes(2);
});
it("hydrates media_urls from historical user turns into UIMessage.images", async () => {
// Round-trip check for the signed-media replay: the backend emits
// ``media_urls`` on a historical user row and the hook must surface them