diff --git a/webui/src/components/thread/ThreadMessages.tsx b/webui/src/components/thread/ThreadMessages.tsx
index fcd516086..82a189226 100644
--- a/webui/src/components/thread/ThreadMessages.tsx
+++ b/webui/src/components/thread/ThreadMessages.tsx
@@ -4,7 +4,13 @@ import { MessageBubble } from "@/components/MessageBubble";
import { AgentActivityCluster } from "@/components/thread/AgentActivityCluster";
import { AssistantSelectionAction } from "@/components/thread/AssistantSelectionAction";
import { normalizeActivityTimeline, type TurnUnit } from "@/lib/activity-timeline";
-import type { CliAppInfo, McpPresetInfo, SlashCommand, UIMessage } from "@/lib/types";
+import type {
+ CliAppInfo,
+ McpPresetInfo,
+ SessionHandle,
+ SlashCommand,
+ UIMessage,
+} from "@/lib/types";
interface ThreadMessagesProps {
messages: UIMessage[];
@@ -18,6 +24,7 @@ interface ThreadMessagesProps {
cliApps?: CliAppInfo[];
mcpPresets?: McpPresetInfo[];
slashCommands?: SlashCommand[];
+ sessionDirectory?: SessionHandle[];
forkBoundaryMessageCount?: number | null;
onOpenFilePreview?: (path: string) => void;
onForkFromMessage?: (beforeUserIndex: number) => void;
@@ -62,6 +69,7 @@ export function ThreadMessages({
cliApps = [],
mcpPresets = [],
slashCommands = [],
+ sessionDirectory = [],
forkBoundaryMessageCount = null,
onOpenFilePreview,
onForkFromMessage,
@@ -159,6 +167,7 @@ export function ThreadMessages({
cliApps={cliApps}
mcpPresets={mcpPresets}
slashCommands={slashCommands}
+ sessionDirectory={sessionDirectory}
onOpenFilePreview={onOpenFilePreview}
onForkFromMessage={onForkFromMessage}
/>
@@ -240,6 +249,7 @@ interface ThreadDisplayUnitProps {
cliApps: CliAppInfo[];
mcpPresets: McpPresetInfo[];
slashCommands: SlashCommand[];
+ sessionDirectory: SessionHandle[];
onOpenFilePreview?: (path: string) => void;
onForkFromMessage?: (beforeUserIndex: number) => void;
}
@@ -258,6 +268,7 @@ const ThreadDisplayUnit = memo(function ThreadDisplayUnit({
cliApps,
mcpPresets,
slashCommands,
+ sessionDirectory,
onOpenFilePreview,
onForkFromMessage,
}: ThreadDisplayUnitProps) {
@@ -296,6 +307,7 @@ const ThreadDisplayUnit = memo(function ThreadDisplayUnit({
cliApps={cliApps}
mcpPresets={mcpPresets}
slashCommands={slashCommands}
+ sessionDirectory={sessionDirectory}
onOpenFilePreview={onOpenFilePreview}
onForkFromHere={forkIndex !== undefined ? onForkFromHere : undefined}
/>
@@ -324,6 +336,7 @@ function threadDisplayUnitPropsEqual(
&& previous.cliApps === next.cliApps
&& previous.mcpPresets === next.mcpPresets
&& previous.slashCommands === next.slashCommands
+ && previous.sessionDirectory === next.sessionDirectory
&& previous.onOpenFilePreview === next.onOpenFilePreview
&& previous.onForkFromMessage === next.onForkFromMessage
);
diff --git a/webui/src/components/thread/ThreadShell.tsx b/webui/src/components/thread/ThreadShell.tsx
index ef563a54a..e98f0ae13 100644
--- a/webui/src/components/thread/ThreadShell.tsx
+++ b/webui/src/components/thread/ThreadShell.tsx
@@ -5,6 +5,7 @@ import { useTranslation } from "react-i18next";
import { FilePreviewAvailabilityProvider } from "@/components/FilePreviewAvailabilityContext";
import { FilePreviewPanel } from "@/components/FilePreviewPanel";
+import { SessionHandleHighlight } from "@/components/CliAppMentionText";
import { PromptNavigator } from "@/components/thread/PromptNavigator";
import { SessionInfoPopover } from "@/components/thread/SessionInfoPopover";
import { ThreadComposer } from "@/components/thread/ThreadComposer";
@@ -36,6 +37,7 @@ import type { CanonicalRunSnapshot, StreamError } from "@/lib/nanobot-client";
import { inferProviderFromModelName, providerDisplayLabel } from "@/lib/provider-brand";
import type {
ChatSummary,
+ SessionHandle,
SettingsPayload,
SlashCommand,
SkillSummary,
@@ -637,7 +639,7 @@ export function ThreadShell({
const { t } = useTranslation();
const chatId = session?.chatId ?? null;
const historyKey = temporary ? null : session?.key ?? null;
- const mentionSessions = useMemo(
+ const referenceSessions = useMemo(
() => sessions.filter((candidate) => (
candidate.key !== historyKey
&& (
@@ -647,6 +649,18 @@ export function ThreadShell({
)),
[historyKey, sessions, workspaceScope],
);
+ const handleSessions = useMemo(() => {
+ if (temporary) return [];
+ return sessions;
+ }, [sessions, temporary]);
+ const sessionDirectory = useMemo
(() => {
+ const handles = new Map();
+ if (session?.handle) handles.set(session.handle.id, session.handle);
+ for (const candidate of handleSessions) {
+ if (candidate.handle) handles.set(candidate.handle.id, candidate.handle);
+ }
+ return [...handles.values()];
+ }, [handleSessions, session?.handle]);
const {
messages: historical,
loading,
@@ -1316,7 +1330,14 @@ export function ThreadShell({
setPendingFirstTargetChatId(newId);
return true;
},
- [booting, client, localModelPreset, onCreateChat, withWorkspaceScope, workspaceScope],
+ [
+ booting,
+ client,
+ localModelPreset,
+ onCreateChat,
+ withWorkspaceScope,
+ workspaceScope,
+ ],
);
const handleThreadSend = useCallback(
@@ -1469,7 +1490,8 @@ export function ThreadShell({
slashCommands={availableSlashCommands}
cliApps={cliApps}
mcpPresets={mcpPresets}
- sessions={mentionSessions}
+ sessions={referenceSessions}
+ handleSessions={handleSessions}
skills={skills}
onStop={stop}
onTranscribeAudio={transcribeAudio}
@@ -1516,7 +1538,8 @@ export function ThreadShell({
slashCommands={availableSlashCommands}
cliApps={cliApps}
mcpPresets={mcpPresets}
- sessions={mentionSessions}
+ sessions={referenceSessions}
+ handleSessions={handleSessions}
skills={skills}
surfaceRef={composerSurfaceRef}
onTranscribeAudio={transcribeAudio}
@@ -1560,6 +1583,7 @@ export function ThreadShell({
const threadHeader = !hideHeader ? (
+ {hideHeaderTitle && !temporary && session?.handle ? (
+
+
+
+ @{session.handle.name}
+
+
+
+ ) : null}
{headerPortalTarget === undefined ? threadHeader : null}
void;
@@ -50,6 +56,7 @@ interface ThreadViewportProps {
cliApps?: CliAppInfo[];
mcpPresets?: McpPresetInfo[];
slashCommands?: SlashCommand[];
+ sessionDirectory?: SessionHandle[];
forkBoundaryMessageCount?: number | null;
hasMoreBefore?: boolean;
loadingOlder?: boolean;
@@ -69,6 +76,7 @@ const SOFT_KEYBOARD_MIN_INSET_PX = 80;
const SESSION_HANDOFF_EXIT_DURATION_MS = 80;
const SESSION_HANDOFF_ENTER_DURATION_MS = 140;
const SESSION_HANDOFF_OPACITY = 0.82;
+const EMPTY_SESSION_DIRECTORY: SessionHandle[] = [];
export const INITIAL_HISTORY_WINDOW = 160;
export const HISTORY_WINDOW_INCREMENT = 120;
@@ -104,11 +112,6 @@ function isKeyboardEditableElement(element: Element | null): element is HTMLElem
].includes(element.type);
}
-function isThreadDisclosureTarget(target: EventTarget | null): boolean {
- return target instanceof Element
- && target.closest("[data-thread-disclosure]") !== null;
-}
-
function isKeyboardControl(element: Element | null): boolean {
return element instanceof HTMLElement
&& element.closest(
@@ -116,6 +119,11 @@ function isKeyboardControl(element: Element | null): boolean {
) !== null;
}
+function isThreadDisclosureTarget(target: EventTarget | null): boolean {
+ return target instanceof Element
+ && target.closest("[data-thread-disclosure]") !== null;
+}
+
type ThreadScrollDirection = "backward" | "forward";
const KEYBOARD_SCROLL_DIRECTIONS: Readonly<
@@ -185,6 +193,7 @@ export const ThreadViewport = forwardRef 1) {
+ return statusCopy(
+ status,
+ "Sending messages",
+ "Sent messages",
+ "Could not send messages",
+ );
+ }
+ return fieldValue(items[0]?.trace, "expect_reply") === "true"
+ ? statusCopy(status, "Asking", "Asked", "Could not reach")
+ : statusCopy(status, "Sending to", "Sent to", "Could not reach");
case "message":
return statusCopy(status, "Sending message", "Sent message", "Could not send message");
case "my":
@@ -281,6 +307,8 @@ function activityDetail(items: GenericToolRunItem[], family: ToolFamily, name: s
switch (name) {
case "spawn":
return safeText(fieldValue(trace, "label"));
+ case "send_session_message":
+ return safeText(fieldValue(trace, "to"));
case "message":
return safeText(fieldValue(trace, "channel"));
case "my":
@@ -301,10 +329,15 @@ function activityDetail(items: GenericToolRunItem[], family: ToolFamily, name: s
}
}
-function activityAside(items: GenericToolRunItem[], family: ToolFamily): string {
+function activityAside(
+ items: GenericToolRunItem[],
+ family: ToolFamily,
+ name: string,
+): string {
const pathCount = uniqueValues(items, ["path", "file_path"]).length;
if (pathCount > 1) return `${pathCount} files`;
if (items.length <= 1) return "";
+ if (name === "send_session_message") return `${items.length} messages`;
if (family === "content-search" || family === "file-search" || family === "memory") {
return `${items.length} searches`;
}
diff --git a/webui/src/globals.css b/webui/src/globals.css
index 91d20e702..b1b08bd48 100644
--- a/webui/src/globals.css
+++ b/webui/src/globals.css
@@ -33,6 +33,14 @@
--input: 40 8% 90.5%;
--ring: 0 0% 3.9%;
--inline-token-highlight: #ef8e30;
+ --session-handle-0: #b45f36;
+ --session-handle-1: #9b6b16;
+ --session-handle-2: #3f7a4f;
+ --session-handle-3: #267b78;
+ --session-handle-4: #3c6fa8;
+ --session-handle-5: #655fb0;
+ --session-handle-6: #98558f;
+ --session-handle-7: #a54f62;
--temporary-control-active: #ef8e30;
--temporary-accent: 24 95% 53%;
--temporary-foreground: 17 88% 32%;
@@ -81,6 +89,14 @@
--input: var(--border);
--ring: 0 0% 83.1%;
--inline-token-highlight: #ef8e30;
+ --session-handle-0: #e58a62;
+ --session-handle-1: #d2a44d;
+ --session-handle-2: #73b985;
+ --session-handle-3: #55b8b2;
+ --session-handle-4: #72a5dc;
+ --session-handle-5: #9a91e3;
+ --session-handle-6: #cf83c5;
+ --session-handle-7: #dc7e91;
--temporary-control-active: #ef8e30;
--temporary-accent: 24 95% 53%;
--temporary-foreground: 32 98% 73%;
diff --git a/webui/src/hooks/useNanobotStream.ts b/webui/src/hooks/useNanobotStream.ts
index e6ddd1831..8b0b8d76f 100644
--- a/webui/src/hooks/useNanobotStream.ts
+++ b/webui/src/hooks/useNanobotStream.ts
@@ -33,6 +33,7 @@ import type {
OutboundCliAppMention,
OutboundMcpPresetMention,
OutboundMedia,
+ SessionHandle,
SessionMention,
GoalStateWsPayload,
MessageDeliveryStatus,
@@ -169,6 +170,7 @@ export interface SendOptions {
cliApps?: OutboundCliAppMention[];
mcpPresets?: OutboundMcpPresetMention[];
sessionMentions?: SessionMention[];
+ sessionHandles?: SessionHandle[];
quotedContext?: string;
workspaceScope?: WorkspaceScopePayload | null;
sideChannel?: boolean;
@@ -188,6 +190,7 @@ function eventExtendsModelActivity(ev: InboundEvent): boolean {
ev.event === "delta"
|| ev.event === "reasoning_delta"
|| ev.event === "file_edit"
+ || ev.event === "session_message"
) return true;
return ev.event === "message"
&& (ev.kind === "tool_hint" || ev.kind === "progress" || ev.kind === "reasoning");
@@ -218,6 +221,33 @@ function transitionTurnDelivery(
return changed ? next : messages;
}
+function appendLiveSessionMessage(
+ messages: UIMessage[],
+ event: Extract,
+): UIMessage[] {
+ const messageId = event.session_message?.message_id?.trim();
+ if (!messageId || event.session_message.direction !== "incoming") return messages;
+ if (messages.some((message) => message.sessionMessage?.message_id === messageId)) return messages;
+
+ const row: UIMessage = {
+ id: `session-message:${messageId}`,
+ role: "user",
+ content: event.text,
+ createdAt: Number.isFinite(event.created_at_ms) ? event.created_at_ms : Date.now(),
+ sessionMessage: event.session_message,
+ ...turnFieldsFromEvent(event, "user"),
+ };
+ const sameTurnIndex = event.turn_id
+ ? messages.findIndex((message) => message.turnId === event.turn_id)
+ : -1;
+ if (sameTurnIndex < 0) return [...messages, row];
+ return [
+ ...messages.slice(0, sameTurnIndex),
+ row,
+ ...messages.slice(sameTurnIndex),
+ ];
+}
+
export function useNanobotStream(
chatId: string | null,
initialMessages: UIMessage[] = [],
@@ -645,6 +675,18 @@ export function useNanobotStream(
});
}, [cancelStreamEndTimer, client]);
+ useEffect(() => {
+ return client.onRunStatus((updatedChatId, startedAt) => {
+ if (updatedChatId !== chatId) return;
+ // Canonical HTTP reconciliation can settle a turn before its delayed
+ // WebSocket completion frame reaches this mounted thread. The client
+ // then fences that duplicate frame, so keep the pane-local timer in
+ // sync with the client's authoritative per-chat run projection.
+ setRunStartedAt(startedAt);
+ if (startedAt !== null) setIsStreaming(true);
+ });
+ }, [chatId, client]);
+
// Reset local state when switching chats. Do not reset on every
// ``initialMessages`` update: a brand-new chat can receive an empty/404
// history response after the optimistic first message has already rendered.
@@ -802,12 +844,20 @@ export function useNanobotStream(
const shouldCloseAnswerBeforeEvent =
ev.event === "file_edit"
+ || ev.event === "session_message"
|| (
ev.event === "message"
&& (ev.kind === "tool_hint" || ev.kind === "progress")
);
flushPendingStreamEvents({ closeAnswerSegment: shouldCloseAnswerBeforeEvent });
+ if (ev.event === "session_message") {
+ clearActivitySegment();
+ setIsStreaming(true);
+ setMessages((prev) => appendLiveSessionMessage(prev, ev));
+ return;
+ }
+
if (ev.event === "reasoning_end") {
if (suppressStreamUntilTurnEndRef.current) return;
setMessages((prev) => closeReasoningStream(prev, Date.now()));
@@ -1138,6 +1188,9 @@ export function useNanobotStream(
...(options?.sessionMentions?.length
? { sessionMentions: options.sessionMentions }
: {}),
+ ...(options?.sessionHandles?.length
+ ? { sessionHandles: options.sessionHandles }
+ : {}),
},
];
});
diff --git a/webui/src/i18n/locales/en/common.json b/webui/src/i18n/locales/en/common.json
index 128a5d04c..d295968c8 100644
--- a/webui/src/i18n/locales/en/common.json
+++ b/webui/src/i18n/locales/en/common.json
@@ -1180,7 +1180,6 @@
"placeholderStreaming": "Model is responding…",
"inputAria": "Message input",
"sendHint": "Enter to send · Shift+Enter for newline",
- "runRuntimeTitle": "Running · {{elapsed}}",
"goalStateStrip": "Goal · {{label}}",
"goalStateFallback": "Goal",
"goalStateExpandAria": "Show full goal",
@@ -1324,9 +1323,7 @@
"cliDescription": "Use @{{name}} as a local CLI app",
"mcpDescription": "Use @{{name}} as an MCP server",
"cliTitle": "CLI app: {{name}}",
- "mcpTitle": "MCP server: {{name}}",
- "sessionBadge": "Nanobot conversation",
- "sessionDescription": "Reference @{{name}} as a previous chat"
+ "mcpTitle": "MCP server: {{name}}"
},
"encoding": "Encoding…",
"remove": "Remove attachment",
diff --git a/webui/src/i18n/locales/es/common.json b/webui/src/i18n/locales/es/common.json
index 268d0344a..da747b3cd 100644
--- a/webui/src/i18n/locales/es/common.json
+++ b/webui/src/i18n/locales/es/common.json
@@ -1167,7 +1167,6 @@
"placeholderStreaming": "El modelo está respondiendo…",
"inputAria": "Entrada de mensaje",
"sendHint": "Enter para enviar · Shift+Enter para nueva línea",
- "runRuntimeTitle": "En ejecución · {{elapsed}}",
"goalStateStrip": "Objetivo · {{label}}",
"goalStateFallback": "Objetivo",
"goalStateExpandAria": "Ver objetivo completo",
@@ -1327,9 +1326,7 @@
"cliDescription": "Usar @{{name}} como aplicación CLI local",
"mcpDescription": "Usar @{{name}} como servidor MCP",
"cliTitle": "Aplicación CLI: {{name}}",
- "mcpTitle": "Servidor MCP: {{name}}",
- "sessionBadge": "Conversación de Nanobot",
- "sessionDescription": "Referenciar @{{name}} como chat anterior"
+ "mcpTitle": "Servidor MCP: {{name}}"
},
"workspace": {
"accessAria": "Modo de acceso al espacio de trabajo",
diff --git a/webui/src/i18n/locales/fr/common.json b/webui/src/i18n/locales/fr/common.json
index 30225cc60..472225a08 100644
--- a/webui/src/i18n/locales/fr/common.json
+++ b/webui/src/i18n/locales/fr/common.json
@@ -1166,7 +1166,6 @@
"placeholderStreaming": "Le modèle est en train de répondre…",
"inputAria": "Champ de message",
"sendHint": "Entrée pour envoyer · Maj+Entrée pour un retour à la ligne",
- "runRuntimeTitle": "Exécution · {{elapsed}}",
"goalStateStrip": "Objectif · {{label}}",
"goalStateFallback": "Objectif",
"goalStateExpandAria": "Afficher l’objectif complet",
@@ -1326,9 +1325,7 @@
"cliDescription": "Utiliser @{{name}} comme application CLI locale",
"mcpDescription": "Utiliser @{{name}} comme serveur MCP",
"cliTitle": "Application CLI : {{name}}",
- "mcpTitle": "Serveur MCP : {{name}}",
- "sessionBadge": "Conversation Nanobot",
- "sessionDescription": "Référencer @{{name}} comme discussion précédente"
+ "mcpTitle": "Serveur MCP : {{name}}"
},
"workspace": {
"accessAria": "Mode d’accès à l’espace de travail",
diff --git a/webui/src/i18n/locales/id/common.json b/webui/src/i18n/locales/id/common.json
index fe6636610..b96dc7a89 100644
--- a/webui/src/i18n/locales/id/common.json
+++ b/webui/src/i18n/locales/id/common.json
@@ -1166,7 +1166,6 @@
"placeholderStreaming": "Model sedang merespons…",
"inputAria": "Input pesan",
"sendHint": "Enter untuk kirim · Shift+Enter untuk baris baru",
- "runRuntimeTitle": "Berjalan · {{elapsed}}",
"goalStateStrip": "Tujuan · {{label}}",
"goalStateFallback": "Tujuan",
"goalStateExpandAria": "Lihat tujuan lengkap",
@@ -1326,9 +1325,7 @@
"cliDescription": "Gunakan @{{name}} sebagai aplikasi CLI lokal",
"mcpDescription": "Gunakan @{{name}} sebagai server MCP",
"cliTitle": "Aplikasi CLI: {{name}}",
- "mcpTitle": "Server MCP: {{name}}",
- "sessionBadge": "Percakapan Nanobot",
- "sessionDescription": "Referensikan @{{name}} sebagai chat sebelumnya"
+ "mcpTitle": "Server MCP: {{name}}"
},
"workspace": {
"accessAria": "Mode akses ruang kerja",
diff --git a/webui/src/i18n/locales/ja/common.json b/webui/src/i18n/locales/ja/common.json
index a28c659cb..153c56884 100644
--- a/webui/src/i18n/locales/ja/common.json
+++ b/webui/src/i18n/locales/ja/common.json
@@ -1166,7 +1166,6 @@
"placeholderStreaming": "モデルが応答しています…",
"inputAria": "メッセージ入力欄",
"sendHint": "Enter で送信 · Shift+Enter で改行",
- "runRuntimeTitle": "実行中 · {{elapsed}}",
"goalStateStrip": "目標 · {{label}}",
"goalStateFallback": "目標",
"goalStateExpandAria": "目標の全文を表示",
@@ -1326,9 +1325,7 @@
"cliDescription": "@{{name}} をローカル CLI アプリとして使用",
"mcpDescription": "@{{name}} を MCP サーバーとして使用",
"cliTitle": "CLI アプリ: {{name}}",
- "mcpTitle": "MCP サーバー: {{name}}",
- "sessionBadge": "Nanobot の会話",
- "sessionDescription": "@{{name}} を過去のチャットとして参照"
+ "mcpTitle": "MCP サーバー: {{name}}"
},
"workspace": {
"accessAria": "ワークスペースのアクセスモード",
diff --git a/webui/src/i18n/locales/ko/common.json b/webui/src/i18n/locales/ko/common.json
index f3bbd31ad..65819920c 100644
--- a/webui/src/i18n/locales/ko/common.json
+++ b/webui/src/i18n/locales/ko/common.json
@@ -1166,7 +1166,6 @@
"placeholderStreaming": "모델이 응답 중입니다…",
"inputAria": "메시지 입력",
"sendHint": "Enter로 전송 · Shift+Enter로 줄바꿈",
- "runRuntimeTitle": "실행 중 · {{elapsed}}",
"goalStateStrip": "목표 · {{label}}",
"goalStateFallback": "목표",
"goalStateExpandAria": "전체 목표 보기",
@@ -1326,9 +1325,7 @@
"cliDescription": "@{{name}}을 로컬 CLI 앱으로 사용",
"mcpDescription": "@{{name}}을 MCP 서버로 사용",
"cliTitle": "CLI 앱: {{name}}",
- "mcpTitle": "MCP 서버: {{name}}",
- "sessionBadge": "Nanobot 대화",
- "sessionDescription": "@{{name}}을 이전 채팅으로 참조"
+ "mcpTitle": "MCP 서버: {{name}}"
},
"workspace": {
"accessAria": "작업공간 접근 모드",
diff --git a/webui/src/i18n/locales/pt-BR/common.json b/webui/src/i18n/locales/pt-BR/common.json
index f4a2d7307..4bcca10e6 100644
--- a/webui/src/i18n/locales/pt-BR/common.json
+++ b/webui/src/i18n/locales/pt-BR/common.json
@@ -1180,7 +1180,6 @@
"placeholderStreaming": "O modelo está respondendo…",
"inputAria": "Campo de mensagem",
"sendHint": "Enter para enviar · Shift+Enter para nova linha",
- "runRuntimeTitle": "Executando · {{elapsed}}",
"goalStateStrip": "Objetivo · {{label}}",
"goalStateFallback": "Objetivo",
"goalStateExpandAria": "Mostrar objetivo completo",
@@ -1324,9 +1323,7 @@
"cliDescription": "Usar @{{name}} como aplicativo CLI local",
"mcpDescription": "Usar @{{name}} como servidor MCP",
"cliTitle": "Aplicativo CLI: {{name}}",
- "mcpTitle": "Servidor MCP: {{name}}",
- "sessionBadge": "Conversa do Nanobot",
- "sessionDescription": "Referenciar @{{name}} como chat anterior"
+ "mcpTitle": "Servidor MCP: {{name}}"
},
"encoding": "Codificando…",
"remove": "Remover anexo",
diff --git a/webui/src/i18n/locales/vi/common.json b/webui/src/i18n/locales/vi/common.json
index 50b2eafb7..95412f257 100644
--- a/webui/src/i18n/locales/vi/common.json
+++ b/webui/src/i18n/locales/vi/common.json
@@ -1166,7 +1166,6 @@
"placeholderStreaming": "Mô hình đang trả lời…",
"inputAria": "Ô nhập tin nhắn",
"sendHint": "Enter để gửi · Shift+Enter để xuống dòng",
- "runRuntimeTitle": "Đang chạy · {{elapsed}}",
"goalStateStrip": "Mục tiêu · {{label}}",
"goalStateFallback": "Mục tiêu",
"goalStateExpandAria": "Xem đầy đủ mục tiêu",
@@ -1326,9 +1325,7 @@
"cliDescription": "Dùng @{{name}} như ứng dụng CLI cục bộ",
"mcpDescription": "Dùng @{{name}} như máy chủ MCP",
"cliTitle": "Ứng dụng CLI: {{name}}",
- "mcpTitle": "Máy chủ MCP: {{name}}",
- "sessionBadge": "Cuộc trò chuyện Nanobot",
- "sessionDescription": "Tham chiếu @{{name}} như cuộc trò chuyện trước"
+ "mcpTitle": "Máy chủ MCP: {{name}}"
},
"workspace": {
"accessAria": "Chế độ truy cập không gian làm việc",
diff --git a/webui/src/i18n/locales/zh-CN/common.json b/webui/src/i18n/locales/zh-CN/common.json
index 4e334ee95..96647c018 100644
--- a/webui/src/i18n/locales/zh-CN/common.json
+++ b/webui/src/i18n/locales/zh-CN/common.json
@@ -1180,7 +1180,6 @@
"placeholderStreaming": "模型正在回复…",
"inputAria": "消息输入框",
"sendHint": "Enter 发送 · Shift+Enter 换行",
- "runRuntimeTitle": "运行中 · {{elapsed}}",
"goalStateStrip": "目标 · {{label}}",
"goalStateFallback": "目标",
"goalStateExpandAria": "查看完整目标",
@@ -1323,9 +1322,7 @@
"cliDescription": "使用 @{{name}} 调用本地 CLI",
"mcpDescription": "使用 @{{name}} 调用 MCP 服务",
"cliTitle": "CLI 应用:{{name}}",
- "mcpTitle": "MCP 服务:{{name}}",
- "sessionBadge": "Nanobot 对话",
- "sessionDescription": "引用历史会话 @{{name}}"
+ "mcpTitle": "MCP 服务:{{name}}"
},
"encoding": "处理中…",
"remove": "移除附件",
diff --git a/webui/src/i18n/locales/zh-TW/common.json b/webui/src/i18n/locales/zh-TW/common.json
index 7d39631de..7b9b2412a 100644
--- a/webui/src/i18n/locales/zh-TW/common.json
+++ b/webui/src/i18n/locales/zh-TW/common.json
@@ -1166,7 +1166,6 @@
"placeholderStreaming": "模型正在回覆…",
"inputAria": "訊息輸入框",
"sendHint": "Enter 送出 · Shift+Enter 換行",
- "runRuntimeTitle": "執行中 · {{elapsed}}",
"goalStateStrip": "目標 · {{label}}",
"goalStateFallback": "目標",
"goalStateExpandAria": "檢視完整目標",
@@ -1326,9 +1325,7 @@
"cliDescription": "將 @{{name}} 作為本機 CLI 應用程式使用",
"mcpDescription": "將 @{{name}} 作為 MCP 伺服器使用",
"cliTitle": "CLI 應用程式:{{name}}",
- "mcpTitle": "MCP 伺服器:{{name}}",
- "sessionBadge": "Nanobot 對話",
- "sessionDescription": "引用先前的對話 @{{name}}"
+ "mcpTitle": "MCP 伺服器:{{name}}"
},
"workspace": {
"accessAria": "工作區存取模式",
diff --git a/webui/src/lib/api.ts b/webui/src/lib/api.ts
index aaf23c6ca..982d4c891 100644
--- a/webui/src/lib/api.ts
+++ b/webui/src/lib/api.ts
@@ -23,6 +23,7 @@ import type {
ProviderOAuthLoginResult,
ProviderSettingsUpdate,
SessionDeleteResult,
+ SessionListHandle,
SessionAutomationsPayload,
SettingsPayload,
SettingsUpdate,
@@ -166,6 +167,22 @@ function splitKey(key: string): { channel: string; chatId: string } {
return { channel: key.slice(0, idx), chatId: key.slice(idx + 1) };
}
+function normalizeSessionListHandle(value: unknown): SessionListHandle | null {
+ if (!value || typeof value !== "object") return null;
+ const handle = value as Partial;
+ const id = typeof handle.id === "string" ? handle.id.trim() : "";
+ const name = typeof handle.name === "string" ? handle.name.trim() : "";
+ if (
+ !/^handle_[a-f0-9]{32}$/i.test(id)
+ || !name
+ || !/^[\p{L}\p{N}_-]+$/u.test(name)
+ || !Number.isInteger(handle.color_slot)
+ || (handle.color_slot ?? -1) < 0
+ || (handle.color_slot ?? 8) >= 8
+ ) return null;
+ return { id, name, color_slot: handle.color_slot as number };
+}
+
export async function listSessions(
token: string,
base: string = "",
@@ -179,6 +196,7 @@ export async function listSessions(
model_preset?: string | null;
run_started_at?: number | null;
workspace_scope?: WorkspaceScopePayload | null;
+ handle?: SessionListHandle | null;
};
const body = await request<{ sessions: Row[] }>(
`${base}/api/sessions`,
@@ -186,17 +204,22 @@ export async function listSessions(
undefined,
API_READ_TIMEOUT_MS,
);
- return body.sessions.map((s) => ({
- key: s.key,
- ...splitKey(s.key),
- createdAt: s.created_at,
- updatedAt: s.updated_at,
- title: s.title ?? "",
- preview: s.preview ?? "",
- modelPreset: s.model_preset ?? null,
- runStartedAt: s.run_started_at ?? null,
- workspaceScope: s.workspace_scope ?? null,
- }));
+ return body.sessions.map((s) => {
+ const rawSession = normalizeSessionListHandle(s.handle);
+ const handle = rawSession ? { ...rawSession, session_key: s.key } : null;
+ return {
+ key: s.key,
+ ...splitKey(s.key),
+ createdAt: s.created_at,
+ updatedAt: s.updated_at,
+ title: s.title ?? "",
+ preview: s.preview ?? "",
+ modelPreset: s.model_preset ?? null,
+ runStartedAt: s.run_started_at ?? null,
+ workspaceScope: s.workspace_scope ?? null,
+ handle,
+ };
+ });
}
/** Disk-backed WebUI display thread snapshot (separate from agent session). */
diff --git a/webui/src/lib/nanobot-client.ts b/webui/src/lib/nanobot-client.ts
index 75e221681..888f30c26 100644
--- a/webui/src/lib/nanobot-client.ts
+++ b/webui/src/lib/nanobot-client.ts
@@ -5,6 +5,7 @@ import type {
OutboundCliAppMention,
OutboundMcpPresetMention,
OutboundMedia,
+ SessionHandle,
SessionMention,
SidebarStatePayload,
GoalStateWsPayload,
@@ -195,7 +196,7 @@ export class NanobotClient {
private knownChats = new Set();
/** Temporary chats are connection-owned and intentionally not reattached. */
private temporaryChatIds = new Set();
- /** Wall-clock run strip: updated from ``goal_status`` even with no ``onChat`` subscriber. */
+ /** Per-chat run projection, started optimistically and reconciled by lifecycle events. */
private runStartedAtByChatId = new Map();
/** Per-turn clocks let a rejected newer turn fall back without borrowing its timer. */
private runStartedAtByTurnKey = new Map();
@@ -537,6 +538,14 @@ export class NanobotClient {
}
}
+ private startRunLocally(chatId: string, turnId: string): void {
+ const startedAt = Date.now() / 1000;
+ this.runStartedAtByTurnKey.set(this.runSendKey(chatId, turnId), startedAt);
+ const previous = this.runStartedAtByChatId.get(chatId);
+ this.runStartedAtByChatId.set(chatId, startedAt);
+ if (previous !== startedAt) this.emitRunStatus(chatId, startedAt);
+ }
+
private settleRunTurn(chatId: string, turnId?: string): void {
if (!turnId) return;
this.clearPendingMessageSend(chatId, turnId);
@@ -716,7 +725,7 @@ export class NanobotClient {
}
}
- private recordGoalStatusForRunStrip(chatId: string, ev: InboundEvent): void {
+ private recordRunStatus(chatId: string, ev: InboundEvent): void {
if (ev.event === "turn_end") {
this.recordRunCompletion(chatId, ev.turn_id);
return;
@@ -967,6 +976,7 @@ export class NanobotClient {
cliApps?: OutboundCliAppMention[];
mcpPresets?: OutboundMcpPresetMention[];
sessionMentions?: SessionMention[];
+ sessionHandles?: SessionHandle[];
quotedContext?: string;
workspaceScope?: WorkspaceScopePayload | null;
turnId?: string;
@@ -986,6 +996,9 @@ export class NanobotClient {
...(options?.sessionMentions?.length
? { session_mentions: options.sessionMentions }
: {}),
+ ...(options?.sessionHandles?.length
+ ? { session_handles: options.sessionHandles }
+ : {}),
...(options?.quotedContext?.trim() ? { quoted_context: options.quotedContext.trim() } : {}),
...(options?.workspaceScope ? { workspace_scope: options.workspaceScope } : {}),
...(options?.turnId ? { turn_id: options.turnId } : {}),
@@ -1004,7 +1017,10 @@ export class NanobotClient {
}
if (options?.turnId && !isSystemCommandTurnId(options.turnId)) {
const startsNewRun = options.startsNewRun !== false;
- if (startsNewRun) this.advanceRunGeneration(chatId, options.turnId);
+ if (startsNewRun) {
+ this.advanceRunGeneration(chatId, options.turnId);
+ this.startRunLocally(chatId, options.turnId);
+ }
this.trackPendingMessageSend(chatId, options.turnId, startsNewRun);
}
this.queueSend(frame);
@@ -1240,7 +1256,7 @@ export class NanobotClient {
if (chatId) {
if (this.isCanonicalCompletedTurnEvent(chatId, parsed)) return;
const supersededRunCompletion = this.isSupersededRunCompletion(chatId, parsed);
- this.recordGoalStatusForRunStrip(chatId, parsed);
+ this.recordRunStatus(chatId, parsed);
if (supersededRunCompletion) return;
this.recordGoalStateSnapshot(chatId, parsed);
this.dispatch(chatId, parsed);
diff --git a/webui/src/lib/types.ts b/webui/src/lib/types.ts
index 9038d9f13..f6b83449c 100644
--- a/webui/src/lib/types.ts
+++ b/webui/src/lib/types.ts
@@ -66,6 +66,8 @@ export interface UIMessage {
mcpPresets?: UIMcpPresetAttachment[];
/** Persisted sessions explicitly referenced by this user turn. */
sessionMentions?: SessionMention[];
+ /** Active session handles structurally selected by this user turn. */
+ sessionHandles?: SessionHandle[];
/** Assistant turn: accumulated model reasoning / thinking text. Built up
* incrementally from ``reasoning_delta`` frames; finalized when
* ``reasoning_end`` arrives. */
@@ -79,6 +81,8 @@ export interface UIMessage {
completedAt?: number;
/** Lightweight provenance for proactive assistant messages. */
source?: UIMessageSource;
+ /** Structured provenance for a message delivered by another session. */
+ sessionMessage?: UISessionMessage;
/** Stable protocol metadata for grouping all activity emitted by one user turn. */
turnId?: string;
turnPhase?: UITurnPhase;
@@ -110,13 +114,31 @@ export interface UIMcpPresetAttachment {
}
export interface SessionMention {
- /** Text token inserted in the composer, without the leading @. */
+ /** Text token inserted in the composer, without the leading #. */
name: string;
/** Stable persisted-session identifier used by read_session. */
session_key: string;
title: string;
}
+/** Exact public handle DTO returned by the session-list endpoint. */
+export interface SessionListHandle {
+ id: string;
+ name: string;
+ color_slot: number;
+}
+
+/** Public session handle enriched with its UI navigation target. */
+export interface SessionHandle extends SessionListHandle {
+ session_key: string;
+}
+
+export interface UISessionMessage {
+ direction: "incoming" | "outgoing";
+ message_id: string;
+ session: SessionListHandle;
+}
+
export interface SessionAutomationJob {
id: string;
name: string;
@@ -337,6 +359,8 @@ export interface ChatSummary {
/** Unix epoch seconds when this session currently has a turn in flight. */
runStartedAt?: number | null;
workspaceScope?: WorkspaceScopePayload | null;
+ /** Stable, server-owned @handle for this session. */
+ handle?: SessionHandle | null;
}
export type WorkspaceAccessMode = "restricted" | "full";
@@ -1248,6 +1272,13 @@ export type InboundEvent =
/** Optional structured payload on progress frames (channel-specific). */
agent_ui?: AgentUIBlob;
} & InboundTurnMetadata)
+ | ({
+ event: "session_message";
+ chat_id: string;
+ text: string;
+ created_at_ms: number;
+ session_message: UISessionMessage;
+ } & InboundTurnMetadata)
| ({
event: "file_edit";
chat_id: string;
@@ -1442,6 +1473,7 @@ export type Outbound =
cli_apps?: OutboundCliAppMention[];
mcp_presets?: OutboundMcpPresetMention[];
session_mentions?: SessionMention[];
+ session_handles?: SessionHandle[];
quoted_context?: string;
workspace_scope?: WorkspaceScopePayload;
turn_id?: string;
diff --git a/webui/src/tests/api.test.ts b/webui/src/tests/api.test.ts
index 66e93dc13..d37fe4507 100644
--- a/webui/src/tests/api.test.ts
+++ b/webui/src/tests/api.test.ts
@@ -1049,7 +1049,7 @@ describe("webui API helpers", () => {
);
});
- it("maps generated session titles from the sessions list", async () => {
+ it("maps title-free handle handles", async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({
@@ -1061,6 +1061,11 @@ describe("webui API helpers", () => {
title: "优化 WebUI 标题",
model_preset: "fast",
run_started_at: 1_700_000_000,
+ handle: {
+ id: "handle_1234567890abcdef1234567890abcdef",
+ name: "webui-review",
+ color_slot: 5,
+ },
},
],
}),
@@ -1073,10 +1078,39 @@ describe("webui API helpers", () => {
preview: "",
modelPreset: "fast",
runStartedAt: 1_700_000_000,
+ handle: {
+ id: "handle_1234567890abcdef1234567890abcdef",
+ name: "webui-review",
+ color_slot: 5,
+ session_key: "websocket:chat-1",
+ },
},
]);
});
+ it("rejects malformed session-list handle DTOs instead of trusting enriched fields", async () => {
+ vi.mocked(fetch).mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({
+ sessions: [
+ {
+ key: "websocket:chat-1",
+ created_at: null,
+ updated_at: null,
+ handle: {
+ id: "handle_1234567890abcdef1234567890abcdef",
+ name: "valid-handle",
+ color_slot: 8,
+ session_key: "websocket:attacker-controlled",
+ },
+ },
+ ],
+ }),
+ } as Response);
+
+ await expect(listSessions("tok")).resolves.toMatchObject([{ handle: null }]);
+ });
+
it("maps slash command metadata from the commands endpoint", async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
diff --git a/webui/src/tests/app-layout.test.tsx b/webui/src/tests/app-layout.test.tsx
index 501691f07..9d8fccd76 100644
--- a/webui/src/tests/app-layout.test.tsx
+++ b/webui/src/tests/app-layout.test.tsx
@@ -519,7 +519,7 @@ describe("App layout", () => {
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
const firstMessage = "keep this first turn visible";
- fireEvent.change(screen.getByRole("textbox", { name: "Message input" }), {
+ fireEvent.change(screen.getByRole("combobox", { name: "Message input" }), {
target: { value: firstMessage },
});
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
@@ -3375,7 +3375,7 @@ describe("App layout", () => {
.toEqual(["Alpha", "New topic"]);
const activeComposer = screen.getByTestId("active-pane-composer");
- const paneInput = within(activeComposer).getByRole("textbox", {
+ const paneInput = within(activeComposer).getByRole("combobox", {
name: "Message New topic",
});
expect(paneInput).toHaveClass("min-h-[50px]");
diff --git a/webui/src/tests/chat-list.test.tsx b/webui/src/tests/chat-list.test.tsx
index d8ef7333e..730cf3741 100644
--- a/webui/src/tests/chat-list.test.tsx
+++ b/webui/src/tests/chat-list.test.tsx
@@ -66,6 +66,104 @@ describe("ChatList", () => {
expect(onTogglePin).toHaveBeenCalledWith("websocket:review");
});
+ it("keeps each handle handle visible beside its conversation title", () => {
+ render(
+ ,
+ );
+
+ const conversation = screen.getByRole("button", {
+ name: "@mira Review the patch",
+ });
+ expect(conversation).toHaveTextContent("Review the patch");
+ expect(conversation).toHaveTextContent("@mira");
+ expect(conversation.querySelector("[data-sidebar-handle-handle]"))
+ .toHaveClass("max-w-20", "shrink-0");
+ const handle = conversation.querySelector("[data-sidebar-handle-handle]");
+ expect(handle?.querySelector("[aria-hidden]")).toBeNull();
+ const decoration = handle?.querySelector("span[style*='border-bottom-color']");
+ expect(decoration?.getAttribute("style"))
+ .toContain("var(--session-handle-3)");
+ expect(decoration?.querySelector("[data-testid], .text-foreground"))
+ .toHaveClass("text-foreground");
+ const selectionTrack = conversation.querySelector("[data-sidebar-selection-track]");
+ expect(selectionTrack).toHaveAttribute("data-active", "true");
+ expect(selectionTrack?.getAttribute("style")).toContain("var(--session-handle-3)");
+ });
+
+ it("keeps aligned handle handles when conversations become grouped panes", () => {
+ const mira = {
+ id: "handle_1234",
+ name: "mira",
+ color_slot: 3,
+ session_key: "websocket:root",
+ };
+ const nora = {
+ id: "handle_5678",
+ name: "nora",
+ color_slot: 5,
+ session_key: "websocket:child",
+ };
+ render(
+ ,
+ );
+
+ const root = screen.getByRole("button", { name: "@mira Short" });
+ const child = screen.getByRole("button", {
+ name: "@nora A much longer conversation title",
+ });
+ expect(root).toHaveTextContent("@mira");
+ expect(child).toHaveTextContent("@nora");
+ for (const handle of document.querySelectorAll("[data-sidebar-handle-handle]")) {
+ expect(handle).toHaveClass("max-w-20", "shrink-0");
+ }
+ });
+
it("keeps tab grouping out of drag protocols while exposing inactive panes as mention sources", () => {
render(
{
const activeButton = screen.getByRole("button", { name: "Active topic" });
expect(activeButton).toHaveAttribute("aria-current", "page");
- expect(activeButton.querySelector("[data-sidebar-selection-track]"))
- .toHaveClass("origin-left", "scale-x-100", "transition-transform", "bg-current");
+ const activeTrack = activeButton.querySelector("[data-sidebar-selection-track]");
+ expect(activeTrack)
+ .toHaveClass("origin-left", "scale-x-100", "transition-transform");
+ expect(activeTrack?.getAttribute("style")).toContain("currentcolor");
rerender(
{
['generate_image({"prompt":"private launch art"})', "Generated image", ""],
['spawn({"label":"Research competitors","task":"private task"})', "Delegated task", "Research competitors"],
['message({"channel":"telegram","content":"private message"})', "Sent message", "telegram"],
+ ['send_session_message({"to":"@reviewer","content":"private message","expect_reply":true})', "Asked", "@reviewer"],
['my({"action":"check","key":"context_window_tokens"})', "Checked agent settings", "context_window_tokens"],
['my({"action":"set","key":"model","value":"private-model"})', "Updated agent settings", "model"],
['cron({"action":"add","name":"Daily digest","message":"private prompt"})', "Scheduled automation", "Daily digest"],
@@ -40,6 +41,60 @@ describe("generic tool activity semantics", () => {
expect(`${presentation.label} ${presentation.detail}`).not.toMatch(/[{}]|private|tool-results/);
});
+ it("renders a handle target once and uses plural copy for grouped messages", () => {
+ const first = parseGenericToolTrace(
+ 'send_session_message({"to":"@kai","content":"first","expect_reply":false})',
+ )!;
+ const second = parseGenericToolTrace(
+ 'send_session_message({"to":"@mira","content":"second","expect_reply":false})',
+ )!;
+
+ const single = describeGenericToolRun([{ trace: first, status: "done" }]);
+ expect([single.label, single.detail].filter(Boolean).join(" ")).toBe("Sent to @kai");
+
+ const grouped = describeGenericToolRun([
+ { trace: first, status: "done" },
+ { trace: second, status: "done" },
+ ]);
+ expect(grouped).toMatchObject({
+ label: "Sent messages",
+ detail: "",
+ aside: "2 messages",
+ });
+ });
+
+ it.each([
+ [true, "running", "Asking"],
+ [true, "done", "Asked"],
+ [false, "running", "Sending to"],
+ [false, "done", "Sent to"],
+ [false, "error", "Could not reach"],
+ ] as const)(
+ "describes expect_reply=%s handle activity while %s",
+ (expectReply, status, label) => {
+ const presentation = describeRun(
+ `send_session_message({"to":"@kai","content":"private","expect_reply":${expectReply}})`,
+ status,
+ );
+ expect(presentation).toMatchObject({ label, detail: "@kai" });
+ },
+ );
+
+ it.each([
+ ["true", "Asked"],
+ ["1", "Asked"],
+ ["yes", "Asked"],
+ ["false", "Sent to"],
+ ["0", "Sent to"],
+ ["no", "Sent to"],
+ ])("matches backend boolean casting for expect_reply=%s", (expectReply, label) => {
+ const presentation = describeRun(
+ `send_session_message({"to":"@kai","content":"private","expect_reply":"${expectReply}"})`,
+ "done",
+ );
+ expect(presentation).toMatchObject({ label, detail: "@kai" });
+ });
+
it.each([
["running", "Generating image"],
["done", "Generated image"],
diff --git a/webui/src/tests/markdown-text-renderer.test.tsx b/webui/src/tests/markdown-text-renderer.test.tsx
index 6252025a8..38c95e058 100644
--- a/webui/src/tests/markdown-text-renderer.test.tsx
+++ b/webui/src/tests/markdown-text-renderer.test.tsx
@@ -28,6 +28,53 @@ describe("MarkdownTextRenderer", () => {
);
});
+ it("highlights only known handle handles in prose with their identity color", () => {
+ render(
+
+ {"已直接回复 @jules;未知 @ghost;邮箱 hello@jules.test;代码 `@jules`。"}
+ ,
+ );
+
+ const mention = screen.getByTestId("message-handle-mention-jules");
+ expect(mention).toHaveTextContent("@jules");
+ expect(mention).toHaveClass("text-foreground");
+ expect(mention.parentElement?.getAttribute("style"))
+ .toContain("var(--session-handle-0)");
+ expect(mention.closest("a")).toHaveAttribute(
+ "href",
+ "#/chat/websocket%3Ajules",
+ );
+ expect(screen.getByText("@jules", { selector: "code" })).toBeInTheDocument();
+ expect(screen.getByText(/未知 @ghost/)).toBeInTheDocument();
+ expect(screen.getByText(/hello@jules\.test/)).toBeInTheDocument();
+ expect(screen.getAllByText("@jules")).toHaveLength(2);
+ });
+
+ it("does not highlight handle handles inside raw or normalized HTML", () => {
+ render(
+
+ {"@jules @jules @jules outside @jules"}
+ ,
+ );
+
+ expect(screen.getAllByTestId("message-handle-mention-jules")).toHaveLength(1);
+ expect(screen.getByTestId("message-handle-mention-jules")).toHaveTextContent("@jules");
+ });
+
it("does not link non-WebUI session references", () => {
const { container } = render(
diff --git a/webui/src/tests/message-bubble.test.tsx b/webui/src/tests/message-bubble.test.tsx
index f8c7db701..6d9f5939c 100644
--- a/webui/src/tests/message-bubble.test.tsx
+++ b/webui/src/tests/message-bubble.test.tsx
@@ -2,6 +2,7 @@ import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"
import { describe, expect, it, vi } from "vitest";
import { MessageBubble } from "@/components/MessageBubble";
+import { preloadMarkdownText } from "@/components/MarkdownText";
import { fmtDateTime, formatMessageEndTime } from "@/lib/format";
import type {
CliAppInfo,
@@ -593,11 +594,11 @@ describe("MessageBubble", () => {
expect(screen.getByTestId("message-mcp-mention-logo-browserbase")).toBeInTheDocument();
});
- it("renders persisted session mentions inside sent user messages", () => {
+ it("renders new # session references as links", () => {
const message: UIMessage = {
id: "u-session",
role: "user",
- content: "Use @收费设计 as context",
+ content: "Use #收费设计",
createdAt: Date.now(),
sessionMentions: [{
name: "收费设计",
@@ -608,13 +609,113 @@ describe("MessageBubble", () => {
render();
- const token = screen.getByTestId("message-session-mention-收费设计");
- expect(token).toHaveTextContent("@收费设计");
+ const token = screen.getByTestId("message-session-reference-收费设计");
+ expect(token).toHaveTextContent("#收费设计");
expect(token).toHaveAttribute("title", "Session: 收费设计");
expect(token.closest("a")).toHaveAttribute("href", "#/chat/websocket%3Apricing");
- expect(token.closest("a")?.getAttribute("style")).toContain(
- "text-decoration-color: var(--inline-token-highlight)",
+ });
+
+ it("prefers legacy @ session metadata over a same-name catalog capability", () => {
+ const message: UIMessage = {
+ id: "u-legacy-session",
+ role: "user",
+ content: "Review @zoom",
+ createdAt: Date.now(),
+ sessionMentions: [{
+ name: "zoom",
+ session_key: "websocket:zoom-notes",
+ title: "Zoom notes",
+ }],
+ };
+
+ render();
+
+ const token = screen.getByTestId("message-session-reference-zoom");
+ expect(token).toHaveTextContent("@zoom");
+ expect(token.closest("a")).toHaveAttribute("href", "#/chat/websocket%3Azoom-notes");
+ expect(screen.queryByTestId("message-cli-mention-zoom")).not.toBeInTheDocument();
+ });
+
+ it("keeps a new # reference distinct from a structured same-name capability", () => {
+ const message: UIMessage = {
+ id: "u-session-and-cli",
+ role: "user",
+ content: "Compare #zoom with @zoom",
+ createdAt: Date.now(),
+ sessionMentions: [{
+ name: "zoom",
+ session_key: "websocket:zoom-notes",
+ title: "Zoom notes",
+ }],
+ cliApps: [{ name: "zoom" }],
+ };
+
+ render();
+
+ expect(screen.getByTestId("message-session-reference-zoom")).toHaveTextContent("#zoom");
+ expect(screen.getByTestId("message-cli-mention-zoom")).toHaveTextContent("@zoom");
+ });
+
+ it("renders incoming handle input as assistant markdown with session provenance", async () => {
+ await act(async () => {
+ await preloadMarkdownText();
+ });
+ const message: UIMessage = {
+ id: "handle-input-1",
+ role: "user",
+ content: "**Please verify** the release notes.",
+ createdAt: Date.now(),
+ sessionMessage: {
+ direction: "incoming",
+ message_id: "handle-message-1",
+ session: {
+ id: "handle_reviewer",
+ name: "reviewer",
+ color_slot: 4,
+ session_key: "websocket:reviewer",
+ },
+ },
+ };
+
+ const { container } = render(
+ ,
);
+
+ const sessionMessage = container.querySelector('[data-handle-message="incoming"]');
+ expect(sessionMessage).toHaveClass("w-full");
+ expect(screen.getByText("Please verify").tagName).toBe("STRONG");
+ const sessionLink = screen.getByRole("link", { name: "@reviewer" });
+ expect(sessionLink).toHaveAttribute("href", "#/chat/websocket%3Areviewer");
+ const sessionRange = sessionMessage?.querySelector("[data-handle-message-body]");
+ expect(sessionRange).toHaveClass("border-s-2", "rounded-es-[16px]", "ps-2.5");
+ expect(sessionRange?.getAttribute("style")).toContain("var(--session-handle-4)");
+ });
+
+ it("renders provenance for a deleted handle as plain text", async () => {
+ await act(async () => {
+ await preloadMarkdownText();
+ });
+ const message: UIMessage = {
+ id: "handle-input-deleted",
+ role: "user",
+ content: "This message remains in history.",
+ createdAt: Date.now(),
+ sessionMessage: {
+ direction: "incoming",
+ message_id: "handle-message-deleted",
+ session: {
+ id: "handle_deleted",
+ name: "noah",
+ color_slot: 2,
+ session_key: "websocket:noah",
+ },
+ },
+ };
+
+ render();
+
+ expect(screen.getByText("@noah")).toBeInTheDocument();
+ expect(screen.queryByRole("link", { name: "@noah" })).not.toBeInTheDocument();
});
it("copies completed assistant replies from the action row", async () => {
diff --git a/webui/src/tests/nanobot-client.test.ts b/webui/src/tests/nanobot-client.test.ts
index 0112d73fb..4b42ce62f 100644
--- a/webui/src/tests/nanobot-client.test.ts
+++ b/webui/src/tests/nanobot-client.test.ts
@@ -504,7 +504,7 @@ describe("NanobotClient", () => {
expect(handler).toHaveBeenCalledTimes(3);
});
- it("records goal_status run strip without an onChat subscriber", () => {
+ it("records canonical run status without an onChat subscriber", () => {
const client = new NanobotClient({
url: "ws://test",
reconnect: false,
@@ -527,7 +527,50 @@ describe("NanobotClient", () => {
expect(client.getRunStartedAt("chat-strip")).toBeNull();
});
- it("clears the local run strip immediately when a stop is requested", () => {
+ it("starts the run projection immediately when a lifecycle message is submitted", () => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date("2026-08-13T10:00:00.000Z"));
+ const client = new NanobotClient({
+ url: "ws://test",
+ reconnect: false,
+ socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
+ });
+ const handler = vi.fn();
+ client.onRunStatus(handler);
+ client.connect();
+ lastSocket().fakeOpen();
+
+ client.sendMessage("chat-optimistic", "hello", undefined, {
+ turnId: "turn-optimistic",
+ });
+
+ const submittedAt = Date.now() / 1000;
+ expect(client.getRunStartedAt("chat-optimistic")).toBe(submittedAt);
+ expect(handler).toHaveBeenLastCalledWith("chat-optimistic", submittedAt);
+ expect(client.hasUnsettledRun("chat-optimistic")).toBe(true);
+ });
+
+ it("does not start a separate run projection for side-channel guidance", () => {
+ const client = new NanobotClient({
+ url: "ws://test",
+ reconnect: false,
+ socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
+ });
+ const handler = vi.fn();
+ client.onRunStatus(handler);
+ client.connect();
+ lastSocket().fakeOpen();
+
+ client.sendMessage("chat-guidance-only", "focus here", undefined, {
+ turnId: "turn-guidance-only",
+ startsNewRun: false,
+ });
+
+ expect(client.getRunStartedAt("chat-guidance-only")).toBeNull();
+ expect(handler).not.toHaveBeenCalled();
+ });
+
+ it("clears the local run status immediately when a stop is requested", () => {
const client = new NanobotClient({
url: "ws://test",
reconnect: false,
@@ -552,7 +595,7 @@ describe("NanobotClient", () => {
expect(handler).toHaveBeenLastCalledWith("chat-stop", null);
});
- it("clears stale run strip when reconnecting after a dropped socket", async () => {
+ it("clears stale run status when reconnecting after a dropped socket", async () => {
const client = new NanobotClient({
url: "ws://test",
reconnect: true,
@@ -578,7 +621,7 @@ describe("NanobotClient", () => {
expect(FakeSocket.instances.length).toBeGreaterThan(1);
});
- it("clears run strip when a turn_end arrives without idle", () => {
+ it("clears run status when a turn_end arrives without idle", () => {
const client = new NanobotClient({
url: "ws://test",
reconnect: false,
@@ -728,6 +771,7 @@ describe("NanobotClient", () => {
expect(
client.reconcileCanonicalCompletion("chat-rejected", requestGeneration, []),
).toBe(true);
+ expect(client.getRunStartedAt("chat-rejected")).toBeNull();
});
it("does not let an older rejection settle or stop a newer run", () => {
@@ -2062,7 +2106,7 @@ describe("NanobotClient", () => {
);
});
- it("includes session mentions in outbound messages", () => {
+ it("keeps session references and handle mentions separate on the wire", () => {
const client = new NanobotClient({
url: "ws://test",
reconnect: false,
@@ -2071,23 +2115,35 @@ describe("NanobotClient", () => {
client.connect();
lastSocket().fakeOpen();
- client.sendMessage("chat-current", "Use @pricing", undefined, {
+ client.sendMessage("chat-current", "Use #pricing and ask @mira", undefined, {
sessionMentions: [{
name: "pricing",
session_key: "websocket:pricing",
title: "Pricing",
}],
+ sessionHandles: [{
+ id: "handle_mira",
+ name: "mira",
+ session_key: "websocket:mira",
+ color_slot: 3,
+ }],
});
expect(lastSocket().sent).toContain(JSON.stringify({
type: "message",
chat_id: "chat-current",
- content: "Use @pricing",
+ content: "Use #pricing and ask @mira",
session_mentions: [{
name: "pricing",
session_key: "websocket:pricing",
title: "Pricing",
}],
+ session_handles: [{
+ id: "handle_mira",
+ name: "mira",
+ session_key: "websocket:mira",
+ color_slot: 3,
+ }],
webui: true,
}));
});
diff --git a/webui/src/tests/thread-composer.test.tsx b/webui/src/tests/thread-composer.test.tsx
index 05ec25dd7..15af878ca 100644
--- a/webui/src/tests/thread-composer.test.tsx
+++ b/webui/src/tests/thread-composer.test.tsx
@@ -127,7 +127,12 @@ const MCP_PRESETS: McpPresetInfo[] = [
},
];
-function session(chatId: string, title: string, preview = ""): ChatSummary {
+function session(
+ chatId: string,
+ title: string,
+ preview = "",
+ mentionName = title,
+): ChatSummary {
return {
key: `websocket:${chatId}`,
channel: "websocket",
@@ -136,6 +141,12 @@ function session(chatId: string, title: string, preview = ""): ChatSummary {
updatedAt: null,
title,
preview,
+ handle: {
+ id: `handle_${chatId}`,
+ name: mentionName,
+ color_slot: 2,
+ session_key: `websocket:${chatId}`,
+ },
};
}
@@ -1722,30 +1733,31 @@ describe("ThreadComposer", () => {
const input = screen.getByLabelText("Message input");
fireEvent.change(input, {
- target: { value: "普通文字 @收费设计", selectionStart: 10 },
+ target: { value: "普通文字 #收费设计", selectionStart: 10 },
});
- expect(screen.queryByTestId("composer-session-mention-收费设计")).not.toBeInTheDocument();
+ expect(screen.queryByTestId("composer-session-reference-收费设计")).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
- expect(onSend).toHaveBeenLastCalledWith("普通文字 @收费设计", undefined, undefined);
+ expect(onSend).toHaveBeenLastCalledWith("普通文字 #收费设计", undefined, undefined);
fireEvent.change(input, {
- target: { value: "参考 @收费", selectionStart: 6 },
+ target: { value: "参考 #收费", selectionStart: 6 },
});
expect(screen.getByRole("group", { name: "Nanobot conversations" })).toBeInTheDocument();
- expect(screen.getByRole("option", { name: /@收费设计/i })).toBeInTheDocument();
+ expect(screen.getByRole("option", { name: /^收费设计 #收费设计$/i }))
+ .toBeInTheDocument();
fireEvent.keyDown(input, { key: "Tab" });
- expect(input).toHaveValue("参考 @收费设计 ");
- const mention = screen.getByTestId("composer-session-mention-收费设计");
- expect(mention).toHaveTextContent("@收费设计");
+ expect(input).toHaveValue("参考 #收费设计 ");
+ const mention = screen.getByTestId("composer-session-reference-收费设计");
+ expect(mention).toHaveTextContent("#收费设计");
expect(mention).toHaveClass("font-normal");
expect(mention).not.toHaveClass("font-[550]");
expect(mention.closest("a")).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
- expect(onSend).toHaveBeenCalledWith("参考 @收费设计", undefined, {
+ expect(onSend).toHaveBeenCalledWith("参考 #收费设计", undefined, {
sessionMentions: [{
name: "收费设计",
session_key: "websocket:pricing",
@@ -1754,6 +1766,198 @@ describe("ThreadComposer", () => {
});
});
+ it("keeps a selected session reference bound across title refreshes", () => {
+ const onSend = vi.fn();
+ const target = session("planning", "Plan");
+ const { rerender } = render(
+ ,
+ );
+
+ const input = screen.getByLabelText("Message input");
+ fireEvent.change(input, { target: { value: "#Pla", selectionStart: 4 } });
+ fireEvent.keyDown(input, { key: "Tab" });
+
+ rerender(
+ ,
+ );
+
+ expect(screen.getByTestId("composer-session-reference-Plan"))
+ .toHaveTextContent("#Plan");
+ fireEvent.click(screen.getByRole("button", { name: "Send message" }));
+ expect(onSend).toHaveBeenCalledWith("#Plan", undefined, {
+ sessionMentions: [{
+ name: "Plan",
+ session_key: "websocket:planning",
+ title: "Renamed plan",
+ }],
+ });
+ });
+
+ it("does not revive structured session identity after its token is removed", () => {
+ const onSend = vi.fn();
+ render(
+ ,
+ );
+
+ const input = screen.getByLabelText("Message input");
+ fireEvent.change(input, {
+ target: { value: "#收费", selectionStart: 3 },
+ });
+ fireEvent.keyDown(input, { key: "Tab" });
+ expect(screen.getByTestId("composer-session-reference-收费设计")).toBeInTheDocument();
+
+ fireEvent.change(input, { target: { value: "", selectionStart: 0 } });
+ fireEvent.change(input, {
+ target: { value: "普通文字 #收费设计", selectionStart: 10 },
+ });
+
+ expect(screen.queryByTestId("composer-session-reference-收费设计")).not.toBeInTheDocument();
+ fireEvent.click(screen.getByRole("button", { name: "Send message" }));
+ expect(onSend).toHaveBeenCalledWith("普通文字 #收费设计", undefined, undefined);
+ });
+
+ it("does not migrate a structured identity across an atomic select-all replacement", () => {
+ const onSend = vi.fn();
+ render(
+ ,
+ );
+
+ const input = screen.getByLabelText("Message input") as HTMLTextAreaElement;
+ fireEvent.change(input, { target: { value: "#收费", selectionStart: 3 } });
+ fireEvent.keyDown(input, { key: "Tab" });
+ expect(screen.getByTestId("composer-session-reference-收费设计")).toBeInTheDocument();
+
+ input.setSelectionRange(0, input.value.length);
+ fireEvent.select(input);
+ const replacement = "普通文字 #收费设计";
+ fireEvent.change(input, {
+ target: {
+ value: replacement,
+ selectionStart: replacement.length,
+ selectionEnd: replacement.length,
+ },
+ });
+
+ expect(screen.queryByTestId("composer-session-reference-收费设计")).not.toBeInTheDocument();
+ fireEvent.click(screen.getByRole("button", { name: "Send message" }));
+ expect(onSend).toHaveBeenCalledWith(replacement, undefined, undefined);
+ });
+
+ it("keeps same-name session references distinct from capability mentions", () => {
+ const onSend = vi.fn();
+ render(
+ ,
+ );
+
+ const input = screen.getByLabelText("Message input");
+ fireEvent.change(input, { target: { value: "#blend", selectionStart: 6 } });
+ fireEvent.keyDown(input, { key: "Enter" });
+ expect(screen.getByTestId("composer-session-reference-blender")).toBeInTheDocument();
+
+ const next = "#blender @blend";
+ fireEvent.change(input, { target: { value: next, selectionStart: next.length } });
+ fireEvent.keyDown(input, { key: "Enter" });
+
+ expect(screen.getByTestId("composer-session-reference-blender")).toBeInTheDocument();
+ expect(screen.getByTestId("composer-cli-mention-blender")).toBeInTheDocument();
+ fireEvent.click(screen.getByRole("button", { name: "Send message" }));
+ expect(onSend).toHaveBeenCalledWith("#blender @blender", undefined, {
+ cliApps: [expect.objectContaining({ name: "blender" })],
+ sessionMentions: [{
+ name: "blender",
+ session_key: "websocket:blender-chat",
+ title: "blender",
+ }],
+ });
+ });
+
+ it("drops structured session semantics when the identity leaves the current catalog", () => {
+ const onSend = vi.fn();
+ const target = session("pricing", "pricing", "", "pricing");
+ const { rerender } = render(
+ ,
+ );
+
+ const input = screen.getByLabelText("Message input");
+ fireEvent.change(input, { target: { value: "#pricing", selectionStart: 8 } });
+ fireEvent.keyDown(input, { key: "Enter" });
+ expect(screen.getByTestId("composer-session-reference-pricing")).toBeInTheDocument();
+
+ rerender(
+ ,
+ );
+ expect(screen.queryByTestId("composer-session-reference-pricing")).not.toBeInTheDocument();
+ fireEvent.click(screen.getByRole("button", { name: "Send message" }));
+ expect(onSend).toHaveBeenCalledWith("#pricing", undefined, undefined);
+ });
+
+ it("exposes mention suggestions as an aria-activedescendant combobox and ignores IME Enter", () => {
+ render(
+ ,
+ );
+
+ const input = screen.getByLabelText("Message input");
+ fireEvent.change(input, { target: { value: "@", selectionStart: 1 } });
+ const combobox = screen.getByRole("combobox", { name: "Message input" });
+ const listbox = screen.getByRole("listbox", { name: "Mentions" });
+ const firstOption = screen.getByRole("option", { name: /@gimp/i });
+ expect(combobox).toHaveAttribute("aria-expanded", "true");
+ expect(combobox).toHaveAttribute("aria-controls", listbox.id);
+ expect(combobox).toHaveAttribute("aria-activedescendant", firstOption.id);
+ expect(firstOption).toHaveAttribute("tabindex", "-1");
+
+ fireEvent.keyDown(input, { key: "Enter", isComposing: true });
+ expect(input).toHaveValue("@");
+ expect(listbox).toBeInTheDocument();
+
+ fireEvent.keyDown(input, { key: "ArrowDown" });
+ const secondOption = screen.getByRole("option", { name: /@blender/i });
+ expect(combobox).toHaveAttribute("aria-activedescendant", secondOption.id);
+ });
+
+ it("keeps combobox semantics when the mention popup is closed", () => {
+ render();
+
+ const input = screen.getByRole("combobox", { name: "Message input" });
+ expect(input).toHaveAttribute("aria-autocomplete", "list");
+ expect(input).toHaveAttribute("aria-expanded", "false");
+ expect(input).not.toHaveAttribute("aria-controls");
+ expect(input).not.toHaveAttribute("aria-activedescendant");
+ });
+
it("turns a dropped sidebar session into the shared structured mention", () => {
const onSend = vi.fn();
render(
@@ -1782,7 +1986,7 @@ describe("ThreadComposer", () => {
expect(input).toHaveValue("Compare notes");
expect(screen.getByTestId("composer-session-drag-preview"))
- .toHaveTextContent("@收费设计");
+ .toHaveTextContent("#收费设计");
fireEvent.dragEnd(document);
expect(screen.queryByTestId("composer-session-drag-preview")).not.toBeInTheDocument();
@@ -1792,13 +1996,13 @@ describe("ThreadComposer", () => {
fireEvent.drop(input, { dataTransfer });
- expect(input).toHaveValue("Compare @收费设计 notes");
+ expect(input).toHaveValue("Compare #收费设计 notes");
expect(screen.queryByTestId("composer-session-drag-preview")).not.toBeInTheDocument();
- expect(screen.getByTestId("composer-session-mention-收费设计"))
- .toHaveTextContent("@收费设计");
+ expect(screen.getByTestId("composer-session-reference-收费设计"))
+ .toHaveTextContent("#收费设计");
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
- expect(onSend).toHaveBeenCalledWith("Compare @收费设计 notes", undefined, {
+ expect(onSend).toHaveBeenCalledWith("Compare #收费设计 notes", undefined, {
sessionMentions: [{
name: "收费设计",
session_key: "websocket:pricing",
@@ -1829,17 +2033,19 @@ describe("ThreadComposer", () => {
expect(screen.queryByTestId("composer-session-drag-preview")).not.toBeInTheDocument();
});
- it("disambiguates duplicate and capability-colliding session names", () => {
+ it("uses stable handle identities without exposing session titles", () => {
+ const handles = [
+ session("a", "First planning title", "", "Plan"),
+ session("b", "Second planning title", "", "Plan-2"),
+ session("blender-chat", "3D notes", "", "Blender"),
+ ];
render(
session(chatId, "Plan")),
- session("blender-chat", "Blender", "3D notes"),
- ]}
+ handleSessions={handles}
/>,
);
@@ -1849,18 +2055,130 @@ describe("ThreadComposer", () => {
const palette = screen.getByRole("listbox", { name: "Mentions" });
expect(within(palette).getAllByRole("group").map((group) => (
group.getAttribute("aria-label")
- ))).toEqual(["CLI apps", "MCP services", "Nanobot conversations"]);
- const options = screen.getAllByRole("option", { name: /Plan @Plan/i });
- expect(options.map((option) => option.textContent)).toEqual([
- expect.stringContaining("@Plan"),
- expect.stringContaining("@Plan-chat"),
- ]);
- expect(screen.getByRole("group", { name: "Nanobot conversations" })).toBeInTheDocument();
+ ))).toEqual(["Nanobot conversations", "CLI apps", "MCP services"]);
+ const firstSession = screen.getByRole("option", { name: /^@Plan$/i });
+ expect(firstSession).toHaveAttribute("aria-selected", "true");
+ expect(input).toHaveAttribute("aria-activedescendant", firstSession.id);
+ expect(screen.getByRole("option", { name: /^@Plan-2$/i }))
+ .toBeInTheDocument();
+ expect(screen.getByRole("group", { name: "Nanobot conversations" }))
+ .toBeInTheDocument();
expect(screen.getByRole("group", { name: "CLI apps" })).toBeInTheDocument();
- expect(screen.getByRole("option", { name: /Blender @Blender-chat Reference/i }))
+ expect(screen.getByRole("option", { name: /^@Blender$/i }))
.toBeInTheDocument();
expect(screen.getByRole("option", { name: /Blender @blender Use/i }))
.toBeInTheDocument();
+ expect(screen.queryByText("First planning title")).not.toBeInTheDocument();
+ expect(screen.queryByText("Second planning title")).not.toBeInTheDocument();
+ });
+
+ it("binds every same-name occurrence to one selected namespace across queue replay", () => {
+ const onSend = vi.fn();
+ const sameNameSession = session("blender-handle", "Session title", "", "blender");
+ render(
+ ,
+ );
+
+ const input = screen.getByRole("combobox", { name: "Message input" });
+ fireEvent.change(input, { target: { value: "@blend", selectionStart: 6 } });
+ fireEvent.keyDown(input, { key: "Enter" });
+ expect(screen.getByTestId("composer-handle-mention-blender")).toBeInTheDocument();
+
+ const withSecondOccurrence = "@blender then @blender";
+ fireEvent.change(input, {
+ target: { value: withSecondOccurrence, selectionStart: withSecondOccurrence.length },
+ });
+ expect(screen.getAllByTestId("composer-handle-mention-blender")).toHaveLength(2);
+
+ input.setSelectionRange("@blender then ".length, withSecondOccurrence.length);
+ fireEvent.select(input);
+ fireEvent.change(input, {
+ target: { value: "@blender then @blend", selectionStart: 20 },
+ });
+ const cliOption = screen.getByRole("option", { name: /Blender @blender .* CLI/i });
+ fireEvent.mouseDown(cliOption);
+
+ expect(screen.getAllByTestId("composer-cli-mention-blender")).toHaveLength(2);
+ expect(screen.queryByTestId("composer-handle-mention-blender")).not.toBeInTheDocument();
+ fireEvent.keyDown(input, { key: "Enter" });
+ fireEvent.keyDown(input, { key: "Enter" });
+
+ expect(onSend).toHaveBeenCalledWith("@blender then @blender", undefined, {
+ cliApps: [expect.objectContaining({ name: "blender" })],
+ continueActiveTurn: true,
+ });
+ });
+
+ it("does not reinterpret a disappeared handle as a same-name CLI app", () => {
+ const onSend = vi.fn();
+ const handle = session("blender-handle", "Session title", "", "blender");
+ const { rerender } = render(
+ ,
+ );
+
+ const input = screen.getByRole("combobox", { name: "Message input" });
+ fireEvent.change(input, { target: { value: "@blend", selectionStart: 6 } });
+ fireEvent.keyDown(input, { key: "Tab" });
+ expect(screen.getByTestId("composer-handle-mention-blender")).toBeInTheDocument();
+
+ rerender(
+ ,
+ );
+
+ expect(screen.queryByTestId("composer-handle-mention-blender")).not.toBeInTheDocument();
+ expect(screen.queryByTestId("composer-cli-mention-blender")).not.toBeInTheDocument();
+ fireEvent.click(screen.getByRole("button", { name: "Send message" }));
+ expect(onSend).toHaveBeenCalledWith("@blender", undefined, undefined);
+ });
+
+ it("supports a prototype-named MCP through live and queued mention parsing", () => {
+ const onSend = vi.fn();
+ const constructorPreset: McpPresetInfo = {
+ ...MCP_PRESETS[0],
+ name: "constructor",
+ display_name: "Constructor",
+ };
+ render(
+ ,
+ );
+
+ const input = screen.getByRole("combobox", { name: "Message input" });
+ fireEvent.change(input, {
+ target: { value: "use @constructor", selectionStart: 16 },
+ });
+ expect(screen.getByTestId("composer-mcp-mention-constructor")).toBeInTheDocument();
+
+ fireEvent.keyDown(input, { key: "Enter" });
+ fireEvent.keyDown(input, { key: "Enter" });
+ expect(screen.getByText("use @constructor")).toBeInTheDocument();
+ fireEvent.keyDown(input, { key: "Enter" });
+ expect(onSend).toHaveBeenCalledWith("use @constructor", undefined, {
+ mcpPresets: [expect.objectContaining({ name: "constructor" })],
+ continueActiveTurn: true,
+ });
});
it("releases the eight-session limit when a mention is removed", () => {
@@ -1878,11 +2196,21 @@ describe("ThreadComposer", () => {
const input = screen.getByLabelText("Message input") as HTMLTextAreaElement;
for (let index = 0; index < 8; index += 1) {
- const value = `${input.value}${input.value ? " " : ""}@Topic${index}`;
+ const value = `${input.value}${input.value ? " " : ""}#Topic${index}`;
+ input.setSelectionRange(input.value.length, input.value.length);
+ fireEvent.select(input);
fireEvent.change(input, { target: { value, selectionStart: value.length } });
fireEvent.keyDown(input, { key: "Tab" });
}
- const replacement = `${input.value.replace("@Topic0 ", "")} @Topic8`;
+ const withoutFirst = input.value.replace("#Topic0 ", "");
+ input.setSelectionRange(0, "#Topic0 ".length);
+ fireEvent.select(input);
+ fireEvent.change(input, {
+ target: { value: withoutFirst, selectionStart: 0 },
+ });
+ const replacement = `${withoutFirst}#Topic8`;
+ input.setSelectionRange(withoutFirst.length, withoutFirst.length);
+ fireEvent.select(input);
fireEvent.change(input, {
target: { value: replacement, selectionStart: replacement.length },
});
@@ -1896,7 +2224,7 @@ describe("ThreadComposer", () => {
))).toEqual(expect.arrayContaining(["websocket:topic-8"]));
});
- it("keeps a selected session stable across refreshes and queued guidance", () => {
+ it("keeps a selected handle mention when queuing guidance for the active turn", () => {
const onSend = vi.fn();
const target = session("z-target", "Plan", "Original plan");
const { rerender } = render(
@@ -1905,7 +2233,7 @@ describe("ThreadComposer", () => {
onStop={vi.fn()}
isStreaming
placeholder="Type your message..."
- sessions={[target]}
+ handleSessions={[target]}
/>,
);
@@ -1919,22 +2247,26 @@ describe("ThreadComposer", () => {
onStop={vi.fn()}
isStreaming
placeholder="Type your message..."
- sessions={[
+ handleSessions={[
{ ...target, title: "Renamed plan" },
- session("a-new", "Plan", target.preview),
+ session("a-new", "Another title", target.preview, "Other"),
]}
/>,
);
- expect(screen.getByTestId("composer-session-mention-Plan")).toHaveTextContent("@Plan");
+ expect(screen.getByTestId("composer-handle-mention-Plan")).toHaveTextContent("@Plan");
+ fireEvent.keyDown(input, { key: "Enter" });
+ expect(
+ within(screen.getByRole("group", { name: "Queued guidance" })).getByText("@Plan"),
+ ).toBeInTheDocument();
fireEvent.keyDown(input, { key: "Enter" });
- fireEvent.click(screen.getByRole("button", { name: "Guide" }));
expect(onSend).toHaveBeenCalledWith("@Plan", undefined, {
- sessionMentions: [{
+ sessionHandles: [{
+ id: "handle_z-target",
name: "Plan",
session_key: "websocket:z-target",
- title: "Plan",
+ color_slot: 2,
}],
continueActiveTurn: true,
});
@@ -1993,6 +2325,49 @@ describe("ThreadComposer", () => {
expect(input).toHaveValue(`please use $${skillName} `);
});
+ it("keeps a later session occurrence bound while completing an earlier skill", () => {
+ const onSend = vi.fn();
+ const skillName = "arxiv-intelligence-filter";
+ render(
+ ,
+ );
+
+ const input = screen.getByLabelText("Message input") as HTMLTextAreaElement;
+ fireEvent.change(input, { target: { value: "#Pla", selectionStart: 4 } });
+ fireEvent.keyDown(input, { key: "Tab" });
+ expect(screen.getByTestId("composer-session-reference-Plan")).toBeInTheDocument();
+
+ input.setSelectionRange(0, 0);
+ fireEvent.select(input);
+ const withSkillQuery = `$arx ${input.value}`;
+ fireEvent.change(input, {
+ target: { value: withSkillQuery, selectionStart: 4, selectionEnd: 4 },
+ });
+ fireEvent.keyDown(input, { key: "Tab" });
+
+ expect(input).toHaveValue(`$${skillName} #Plan `);
+ expect(screen.getByTestId("composer-session-reference-Plan")).toBeInTheDocument();
+ fireEvent.click(screen.getByRole("button", { name: "Send message" }));
+ expect(onSend).toHaveBeenCalledWith(`$${skillName} #Plan`, undefined, {
+ sessionMentions: [{
+ name: "Plan",
+ session_key: "websocket:plan",
+ title: "Plan",
+ }],
+ });
+ });
+
it("ranks skill name matches ahead of earlier description matches", () => {
render(
{
});
});
+ it("migrates queued guidance from the v1 storage key without losing the prompt", async () => {
+ const legacyKey = "nanobot.webui.composerQueuedGuidance.v1:chat-a";
+ const currentKey = "nanobot.webui.composerQueuedGuidance.v2:chat-a";
+ window.localStorage.setItem(legacyKey, JSON.stringify([{
+ id: "legacy-guidance",
+ text: "keep this older queued prompt",
+ sessionMentions: [{
+ name: "old-handle",
+ session_key: "websocket:old-handle",
+ title: "Old handle",
+ }],
+ }]));
+
+ render(
+ ,
+ );
+
+ expect(await screen.findByText("keep this older queued prompt")).toBeInTheDocument();
+ expect(window.localStorage.getItem(legacyKey)).toBeNull();
+ expect(JSON.parse(window.localStorage.getItem(currentKey) ?? "[]"))
+ .toEqual([expect.objectContaining({
+ id: "legacy-guidance",
+ text: "keep this older queued prompt",
+ })]);
+ });
+
it("keeps temporary chat guidance in memory only", async () => {
const onSend = vi.fn();
const view = render(
@@ -3062,7 +3469,7 @@ describe("ThreadComposer", () => {
expect(await screen.findByText("do not persist this")).toBeInTheDocument();
expect(
window.localStorage.getItem(
- "nanobot.webui.composerQueuedGuidance.v1:temporary-private",
+ "nanobot.webui.composerQueuedGuidance.v2:temporary-private",
),
).toBeNull();
@@ -3082,7 +3489,7 @@ describe("ThreadComposer", () => {
});
expect(
window.localStorage.getItem(
- "nanobot.webui.composerQueuedGuidance.v1:temporary-private",
+ "nanobot.webui.composerQueuedGuidance.v2:temporary-private",
),
).toBeNull();
});
diff --git a/webui/src/tests/thread-shell.test.tsx b/webui/src/tests/thread-shell.test.tsx
index 8bb3d7cde..983d349e9 100644
--- a/webui/src/tests/thread-shell.test.tsx
+++ b/webui/src/tests/thread-shell.test.tsx
@@ -21,6 +21,7 @@ function makeClient() {
(modelName: string | null, modelPreset?: string | null) => void
>();
const sessionUpdateHandlers = new Set<(chatId: string, scope?: string) => void>();
+ const runStatusHandlers = new Set<(chatId: string, startedAt: number | null) => void>();
const runStartedAtByChatId = new Map();
const runGenerationByChatId = new Map();
const latestRunTurnIdByChatId = new Map();
@@ -108,6 +109,13 @@ function makeClient() {
},
getRunStartedAt: (chatId: string) => runStartedAtByChatId.get(chatId) ?? null,
getRunTurnId: (chatId: string) => latestRunTurnIdByChatId.get(chatId) ?? null,
+ onRunStatus: (handler: (chatId: string, startedAt: number | null) => void) => {
+ runStatusHandlers.add(handler);
+ for (const [chatId, startedAt] of runStartedAtByChatId) handler(chatId, startedAt);
+ return () => {
+ runStatusHandlers.delete(handler);
+ };
+ },
finishRunLocally: vi.fn((chatId: string) => {
runStartedAtByChatId.delete(chatId);
latestRunTurnIdByChatId.delete(chatId);
@@ -417,6 +425,164 @@ describe("ThreadShell", () => {
);
});
+ it("keeps the current handle handle visible in the thread header", async () => {
+ const client = makeClient();
+ const currentSession = {
+ ...session("handle-handle"),
+ handle: {
+ id: "handle-current",
+ name: "mira",
+ session_key: "websocket:handle-handle",
+ color_slot: 3,
+ },
+ };
+
+ render(wrap(
+ client,
+ {}}
+ />,
+ ));
+
+ const handle = await screen.findByTestId("thread-handle-handle");
+ expect(handle).toHaveTextContent("@mira");
+ expect(handle.querySelector("[aria-hidden]")).toBeNull();
+ const headerDecoration = handle.querySelector("span[style*='border-bottom-color']");
+ expect(headerDecoration?.getAttribute("style"))
+ .toContain("var(--session-handle-3)");
+ expect(headerDecoration?.querySelector(".text-foreground"))
+ .toHaveClass("text-foreground");
+ });
+
+ it("pins each handle identity inside its workbench pane", async () => {
+ const client = makeClient();
+ const currentSession = {
+ ...session("pane-handle"),
+ handle: {
+ id: "handle-pane",
+ name: "kai",
+ session_key: "websocket:pane-handle",
+ color_slot: 2,
+ },
+ };
+
+ render(wrap(
+ client,
+ {}}
+ hideHeaderTitle
+ headerActive={false}
+ />,
+ ));
+
+ expect(screen.queryByTestId("thread-handle-handle")).not.toBeInTheDocument();
+ const identity = await screen.findByTestId("pane-handle-identity");
+ expect(identity).toHaveAttribute("data-active", "false");
+ expect(identity).toHaveAttribute("aria-label", "Session @kai");
+ expect(identity.querySelector("[data-pane-handle-handle]")).toHaveTextContent("@kai");
+ expect(identity.querySelector("[aria-hidden]")).toBeNull();
+ const paneDecoration = identity.querySelector(
+ "[data-pane-handle-handle] span[style*='border-bottom-color']",
+ );
+ expect(paneDecoration?.getAttribute("style")).toContain("var(--session-handle-2)");
+ const paneText = paneDecoration?.querySelector(".text-foreground");
+ expect(paneText).toHaveClass("text-foreground");
+ expect(paneText).not.toHaveClass("opacity-80");
+ expect(identity).not.toHaveTextContent("Investigate incoming messages");
+ expect(identity.className).not.toContain("bg-");
+ expect(identity.className).not.toContain("border-");
+ });
+
+ it("sends a structured handle mention through the focused thread", async () => {
+ const client = makeClient();
+ const source = {
+ ...session("source"),
+ handle: {
+ id: "handle_00000000000000000000000000000001",
+ name: "source",
+ session_key: "websocket:source",
+ color_slot: 1,
+ },
+ };
+ const reviewer = {
+ ...session("reviewer"),
+ handle: {
+ id: "handle_00000000000000000000000000000002",
+ name: "reviewer",
+ session_key: "websocket:reviewer",
+ color_slot: 2,
+ },
+ };
+ render(wrap(
+ client,
+ {}}
+ />,
+ ));
+
+ const input = await screen.findByLabelText("Message input") as HTMLTextAreaElement;
+ fireEvent.change(input, { target: { value: "@rev", selectionStart: 4 } });
+ fireEvent.keyDown(input, { key: "Tab" });
+ const message = `${input.value}check this`;
+ fireEvent.change(input, { target: { value: message, selectionStart: message.length } });
+ fireEvent.click(screen.getByRole("button", { name: "Send message" }));
+
+ expect(client.sendMessage).toHaveBeenCalledWith(
+ source.chatId,
+ message,
+ undefined,
+ expect.objectContaining({
+ sessionHandles: [reviewer.handle],
+ turnId: expect.any(String),
+ }),
+ );
+ });
+
+ it("offers the focused session's own handle handle as a structured mention", async () => {
+ const client = makeClient();
+ const source = {
+ ...session("source-self"),
+ handle: {
+ id: "handle_00000000000000000000000000000003",
+ name: "bea",
+ session_key: "websocket:source-self",
+ color_slot: 3,
+ },
+ };
+
+ render(wrap(
+ client,
+ {}}
+ />,
+ ));
+
+ const input = await screen.findByLabelText("Message input") as HTMLTextAreaElement;
+ fireEvent.change(input, { target: { value: "@be", selectionStart: 3 } });
+ fireEvent.keyDown(input, { key: "Tab" });
+ fireEvent.click(screen.getByRole("button", { name: "Send message" }));
+
+ expect(client.sendMessage).toHaveBeenCalledWith(
+ source.chatId,
+ "@bea",
+ undefined,
+ expect.objectContaining({
+ sessionHandles: [source.handle],
+ turnId: expect.any(String),
+ }),
+ );
+ });
+
it("keeps inferred file paths non-interactive when the availability probe fails", async () => {
await preloadMarkdownText();
const client = makeClient();
@@ -787,7 +953,7 @@ describe("ThreadShell", () => {
fireEvent.click(badge);
expect(onOpenModelSettings).toHaveBeenCalledTimes(1);
- fireEvent.change(screen.getByRole("textbox", { name: "Message input" }), {
+ fireEvent.change(screen.getByRole("combobox", { name: "Message input" }), {
target: { value: "hello" },
});
fireEvent.click(screen.getByRole("button", { name: "Configure model" }));
@@ -943,6 +1109,39 @@ describe("ThreadShell", () => {
});
});
+ it("does not offer persisted sessions inside a temporary chat", async () => {
+ const client = makeClient();
+ const handle = {
+ ...session("handle"),
+ title: "Reviewer",
+ handle: {
+ id: "handle_11111111111111111111111111111111",
+ name: "reviewer",
+ color_slot: 3,
+ session_key: "websocket:handle",
+ },
+ };
+ render(wrap(
+ client,
+ {}}
+ />,
+ ));
+
+ const input = await screen.findByLabelText("Message input");
+ await act(async () => {
+ fireEvent.change(input, { target: { value: "@", selectionStart: 1 } });
+ });
+
+ expect(screen.queryByRole("group", { name: "Nanobot conversations" }))
+ .not.toBeInTheDocument();
+ });
+
it("highlights sent skill references without skill metadata", async () => {
const client = makeClient();
render(wrap(
@@ -2052,7 +2251,7 @@ describe("ThreadShell", () => {
);
await waitFor(() => expect(historyCalls).toBe(1));
- const input = screen.getByRole("textbox", { name: "Message input" });
+ const input = screen.getByRole("combobox", { name: "Message input" });
fireEvent.change(input, { target: { value: "rejected local turn" } });
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
await waitFor(() => expect(screen.getByText("rejected local turn")).toBeInTheDocument());
@@ -2289,7 +2488,7 @@ describe("ThreadShell", () => {
act(() => client._emitSessionUpdate("chat-version-a"));
await waitFor(() => expect(chatACalls).toBe(2));
- fireEvent.change(screen.getByRole("textbox", { name: "Message input" }), {
+ fireEvent.change(screen.getByRole("combobox", { name: "Message input" }), {
target: { value: "new question" },
});
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
@@ -2390,7 +2589,7 @@ describe("ThreadShell", () => {
turn_id: newTurnId,
});
});
- const input = screen.getByRole("textbox", { name: "Message input" });
+ const input = screen.getByRole("combobox", { name: "Message input" });
fireEvent.change(input, { target: { value: "queued for the new run" } });
fireEvent.keyDown(input, { key: "Enter" });
expect(client.sendMessage).not.toHaveBeenCalled();
@@ -2689,7 +2888,7 @@ describe("ThreadShell", () => {
turn_id: turnId,
});
});
- const input = screen.getByRole("textbox", { name: "Message input" });
+ const input = screen.getByRole("combobox", { name: "Message input" });
fireEvent.change(input, { target: { value: "queued guidance" } });
fireEvent.keyDown(input, { key: "Enter" });
expect(client.sendMessage).not.toHaveBeenCalled();
@@ -2792,7 +2991,7 @@ describe("ThreadShell", () => {
});
});
await waitFor(() => expect(screen.getByText("partial answer")).toBeInTheDocument());
- const input = screen.getByRole("textbox", { name: "Message input" });
+ const input = screen.getByRole("combobox", { name: "Message input" });
fireEvent.change(input, { target: { value: "queued guidance" } });
fireEvent.keyDown(input, { key: "Enter" });
expect(screen.getByText("queued guidance")).toBeInTheDocument();
@@ -2888,7 +3087,7 @@ describe("ThreadShell", () => {
});
await waitFor(() => expect(screen.getByText("Continuing the search.")).toBeInTheDocument());
- const input = screen.getByRole("textbox", { name: "Message input" });
+ const input = screen.getByRole("combobox", { name: "Message input" });
fireEvent.change(input, { target: { value: "How is it going?" } });
fireEvent.keyDown(input, { key: "Enter" });
fireEvent.keyDown(input, { key: "Enter" });
@@ -3930,41 +4129,56 @@ describe("ThreadShell", () => {
);
});
- it("offers only same-project sessions in restricted mode", async () => {
- const client = makeClient();
- const currentScope = {
- project_path: "/projects/current",
- access_mode: "restricted" as const,
- };
- const sameProject = {
- ...session("same-project"),
- title: "Same project",
- workspaceScope: currentScope,
- };
- const otherProject = {
- ...session("other-project"),
- title: "Other project",
- workspaceScope: {
- project_path: "/projects/other",
- access_mode: "restricted" as const,
- },
- };
+ it.each(["restricted", "full"] as const)(
+ "offers routable sessions across projects in %s mode",
+ async (accessMode) => {
+ const client = makeClient();
+ const currentScope = {
+ project_path: "/projects/current",
+ access_mode: accessMode,
+ };
+ const sameProject = {
+ ...session("same-project"),
+ title: "Same project",
+ workspaceScope: currentScope,
+ handle: {
+ id: "handle_same_project",
+ name: "same-project",
+ color_slot: 1,
+ session_key: "websocket:same-project",
+ },
+ };
+ const otherProject = {
+ ...session("other-project"),
+ title: "Other project",
+ workspaceScope: {
+ project_path: "/projects/other",
+ access_mode: accessMode,
+ },
+ handle: {
+ id: "handle_other_project",
+ name: "other-project",
+ color_slot: 2,
+ session_key: "websocket:other-project",
+ },
+ };
- render(wrap(
- client,
- {}}
- workspaceScope={currentScope}
- />,
- ));
+ render(wrap(
+ client,
+ {}}
+ workspaceScope={currentScope}
+ />,
+ ));
- const input = await screen.findByLabelText("Message input");
- fireEvent.change(input, { target: { value: "@", selectionStart: 1 } });
+ const input = await screen.findByLabelText("Message input");
+ fireEvent.change(input, { target: { value: "@", selectionStart: 1 } });
- expect(screen.getByRole("option", { name: /Same project/i })).toBeInTheDocument();
- expect(screen.queryByRole("option", { name: /Other project/i })).not.toBeInTheDocument();
- });
+ expect(screen.getByRole("option", { name: /^@same-project$/i })).toBeInTheDocument();
+ expect(screen.getByRole("option", { name: /^@other-project$/i })).toBeInTheDocument();
+ },
+ );
});
diff --git a/webui/src/tests/thread-viewport.test.tsx b/webui/src/tests/thread-viewport.test.tsx
index e5bdbe05c..3381a98f5 100644
--- a/webui/src/tests/thread-viewport.test.tsx
+++ b/webui/src/tests/thread-viewport.test.tsx
@@ -215,7 +215,7 @@ function ViewportWithPromptNavigator({ messages }: { messages: UIMessage[] }) {
}
describe("ThreadViewport", () => {
- it("keeps reasoning disclosure anchored for pointer and keyboard toggles", () => {
+ it("keeps unmanaged reasoning disclosure anchored for pointer and keyboard toggles", () => {
const takeUserControl = vi.spyOn(
ThreadMotionCoordinator.prototype,
"takeUserControl",
diff --git a/webui/src/tests/useNanobotStream.test.tsx b/webui/src/tests/useNanobotStream.test.tsx
index 2000f6189..3a9972dfc 100644
--- a/webui/src/tests/useNanobotStream.test.tsx
+++ b/webui/src/tests/useNanobotStream.test.tsx
@@ -38,6 +38,8 @@ const SEMANTIC_MESSAGE_FIELDS = [
"cliApps",
"mcpPresets",
"sessionMentions",
+ "sessionHandles",
+ "handle",
"reasoning",
"latencyMs",
"source",
@@ -70,6 +72,7 @@ function fakeClient() {
const handlers = new Map void>>();
const statusHandlers = new Set<(status: ConnectionStatus) => void>();
const errorHandlers = new Set<(error: StreamError) => void>();
+ const runStatusHandlers = new Set<(chatId: string, startedAt: number | null) => void>();
const runStartedAtByChatId = new Map();
const unsettledRunByChatId = new Map();
const goalStateByChatId = new Map();
@@ -113,6 +116,13 @@ function fakeClient() {
errorHandlers.add(handler);
return () => errorHandlers.delete(handler);
},
+ onRunStatus(handler: (chatId: string, startedAt: number | null) => void) {
+ runStatusHandlers.add(handler);
+ for (const [chatId, startedAt] of runStartedAtByChatId) {
+ handler(chatId, startedAt);
+ }
+ return () => runStatusHandlers.delete(handler);
+ },
getRunStartedAt(chatId: string) {
const v = runStartedAtByChatId.get(chatId);
return v === undefined ? null : v;
@@ -154,6 +164,11 @@ function fakeClient() {
emitError(error: StreamError) {
errorHandlers.forEach((handler) => handler(error));
},
+ emitRunStatus(chatId: string, startedAt: number | null) {
+ if (startedAt === null) runStartedAtByChatId.delete(chatId);
+ else runStartedAtByChatId.set(chatId, startedAt);
+ runStatusHandlers.forEach((handler) => handler(chatId, startedAt));
+ },
setUnsettled(chatId: string, unsettled: boolean) {
unsettledRunByChatId.set(chatId, unsettled);
},
@@ -182,6 +197,101 @@ async function flushStreamFrame() {
}
describe("useNanobotStream", () => {
+ it("keeps a handle mention on the focused chat's optimistic and outbound turn", () => {
+ const fake = fakeClient();
+ const { result } = renderHook(
+ () => useNanobotStream("chat-source", EMPTY_MESSAGES),
+ { wrapper: wrap(fake.client) },
+ );
+ const reviewer = {
+ id: "handle_00000000000000000000000000000001",
+ name: "reviewer",
+ session_key: "websocket:chat-reviewer",
+ color_slot: 3,
+ };
+
+ act(() => {
+ result.current.send("@reviewer check this", undefined, {
+ sessionHandles: [reviewer],
+ });
+ });
+
+ expect(result.current.messages).toEqual([
+ expect.objectContaining({
+ role: "user",
+ content: "@reviewer check this",
+ deliveryStatus: "sending",
+ sessionHandles: [reviewer],
+ }),
+ ]);
+ expect(result.current.isStreaming).toBe(true);
+ expect(fake.client.sendMessage).toHaveBeenCalledWith(
+ "chat-source",
+ "@reviewer check this",
+ undefined,
+ expect.objectContaining({
+ sessionHandles: [reviewer],
+ turnId: expect.any(String),
+ }),
+ );
+ });
+
+ it("renders an incoming handle message before the target model responds", async () => {
+ const fake = fakeClient();
+ const { result } = renderHook(
+ () => useNanobotStream("chat-handle", EMPTY_MESSAGES),
+ { wrapper: wrap(fake.client) },
+ );
+ const sessionMessageEvent: InboundEvent = {
+ event: "session_message",
+ chat_id: "chat-handle",
+ text: "What did you change?",
+ created_at_ms: 1_234,
+ turn_id: "handle-turn-1",
+ turn_phase: "user",
+ session_message: {
+ direction: "incoming",
+ message_id: "handle-message-1",
+ session: {
+ id: "handle_11111111111111111111111111111111",
+ name: "kai",
+ session_key: "websocket:source",
+ color_slot: 2,
+ },
+ },
+ };
+
+ act(() => {
+ fake.emit("chat-handle", sessionMessageEvent);
+ fake.emit("chat-handle", sessionMessageEvent);
+ });
+
+ expect(result.current.messages).toHaveLength(1);
+ expect(result.current.messages[0]).toMatchObject({
+ id: "session-message:handle-message-1",
+ role: "user",
+ content: "What did you change?",
+ createdAt: 1_234,
+ turnId: "handle-turn-1",
+ turnPhase: "user",
+ sessionMessage: sessionMessageEvent.session_message,
+ });
+ expect(result.current.isStreaming).toBe(true);
+
+ act(() => fake.emit("chat-handle", {
+ event: "delta",
+ chat_id: "chat-handle",
+ text: "I changed",
+ turn_id: "handle-turn-1",
+ }));
+ await flushStreamFrame();
+
+ expect(result.current.messages.map((message) => message.role)).toEqual([
+ "user",
+ "assistant",
+ ]);
+ });
+
it("batches answer deltas into one animation-frame update", async () => {
const fake = fakeClient();
const requestFrame = vi.spyOn(window, "requestAnimationFrame");
@@ -2865,6 +2975,28 @@ describe("useNanobotStream", () => {
expect(result.current.isStreaming).toBe(false);
});
+ it("clears the pane timer when canonical reconciliation settles the client run", () => {
+ const fake = fakeClient();
+ const { result } = renderHook(() => useNanobotStream("chat-g", EMPTY_MESSAGES), {
+ wrapper: wrap(fake.client),
+ });
+
+ act(() => {
+ fake.emit("chat-g", {
+ event: "goal_status",
+ chat_id: "chat-g",
+ status: "running",
+ started_at: 1700,
+ turn_id: "handle:turn-1",
+ });
+ });
+ expect(result.current.runStartedAt).toBe(1700);
+
+ act(() => fake.emitRunStatus("chat-g", null));
+
+ expect(result.current.runStartedAt).toBeNull();
+ });
+
it("restores runStartedAt after switching away and back when goal_status was recorded without a subscriber", () => {
const fake = fakeClient();
const { result, rerender } = renderHook(