{showCopyAction ?
: null}
@@ -342,7 +327,6 @@ export function MessageBubble({
cliApps = [],
mcpPresets = [],
slashCommands = [],
- sessionDirectory = [],
onOpenFilePreview,
onForkFromHere,
}: MessageBubbleProps) {
@@ -360,12 +344,11 @@ export function MessageBubble({
return
;
}
- if (message.role === "user" && message.sessionMessage?.direction === "incoming") {
+ if (message.role === "user" && message.sessionMessage) {
return (
);
@@ -394,9 +377,6 @@ export function MessageBubble({
cliApps={mentionCliApps}
mcpPresets={mentionMcpPresets}
sessionMentions={message.sessionMentions}
- sessionHandles={message.sessionHandles}
- attachedCliApps={message.cliApps}
- attachedMcpPresets={message.mcpPresets}
/>
>
) : (
@@ -405,9 +385,6 @@ export function MessageBubble({
cliApps={mentionCliApps}
mcpPresets={mentionMcpPresets}
sessionMentions={message.sessionMentions}
- sessionHandles={message.sessionHandles}
- attachedCliApps={message.cliApps}
- attachedMcpPresets={message.mcpPresets}
/>
);
return (
@@ -525,7 +502,6 @@ export function MessageBubble({
streaming={!!message.isStreaming}
preserveStreamingLayout
onOpenFilePreview={onOpenFilePreview}
- sessionHandles={sessionDirectory}
>
{message.content}
diff --git a/webui/src/components/SessionHandleLabel.tsx b/webui/src/components/SessionHandleLabel.tsx
new file mode 100644
index 000000000..3e88bd698
--- /dev/null
+++ b/webui/src/components/SessionHandleLabel.tsx
@@ -0,0 +1,20 @@
+import type { ReactNode } from "react";
+
+import { InlineTokenHighlight } from "@/components/InlineTokenHighlight";
+import { sessionHandleColor } from "@/lib/session-handle";
+
+export function SessionHandleLabel({
+ id,
+ children,
+}: {
+ id: string;
+ children: ReactNode;
+}) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/webui/src/components/UserMessageText.tsx b/webui/src/components/UserMessageText.tsx
index 440a9ad9f..0129988c0 100644
--- a/webui/src/components/UserMessageText.tsx
+++ b/webui/src/components/UserMessageText.tsx
@@ -3,24 +3,14 @@ import { useTranslation } from "react-i18next";
import {
CapabilityMentionToken,
- SessionReferenceToken,
splitCapabilityMentionSegments,
- splitSessionReferenceSegments,
type CapabilityMentionSegment,
- type SessionReferenceSegment,
} from "@/components/CliAppMentionText";
import {
INLINE_TOKEN_HIGHLIGHT_COLOR,
InlineTokenHighlight,
} from "@/components/InlineTokenHighlight";
-import type {
- CliAppInfo,
- McpPresetInfo,
- SessionHandle,
- SessionMention,
- UICliAppAttachment,
- UIMcpPresetAttachment,
-} from "@/lib/types";
+import type { CliAppInfo, McpPresetInfo, SessionMention } from "@/lib/types";
type SkillReferenceSegment =
| { kind: "text"; text: string }
@@ -28,7 +18,6 @@ type SkillReferenceSegment =
type UserMessageSegment =
| CapabilityMentionSegment
- | SessionReferenceSegment
| { kind: "skill"; text: string; name: string };
function splitSkillReferenceSegments(value: string): SkillReferenceSegment[] {
@@ -60,75 +49,18 @@ function splitUserMessageSegments(
cliApps: CliAppInfo[],
mcpPresets: McpPresetInfo[],
sessionMentions: SessionMention[],
- sessionHandles: SessionHandle[],
- attachedCliApps: UICliAppAttachment[],
- attachedMcpPresets: UIMcpPresetAttachment[],
): UserMessageSegment[] {
const segments: UserMessageSegment[] = [];
- const structuredAtNamespaces = new Map
();
- sessionHandles.forEach((handle) => {
- structuredAtNamespaces.set(handle.name.toLowerCase(), "handle");
- });
- attachedCliApps.forEach((app) => {
- const name = app.name.toLowerCase();
- if (!structuredAtNamespaces.has(name)) structuredAtNamespaces.set(name, "cli");
- });
- attachedMcpPresets.forEach((preset) => {
- const name = preset.name.toLowerCase();
- if (!structuredAtNamespaces.has(name)) structuredAtNamespaces.set(name, "mcp");
- });
- const structuredAtNames = new Set(structuredAtNamespaces.keys());
- const replayCliApps = cliApps.filter((app) => {
- const owner = structuredAtNamespaces.get(app.name.toLowerCase());
- return owner === undefined || owner === "cli";
- });
- const replayMcpPresets = mcpPresets.filter((preset) => {
- const owner = structuredAtNamespaces.get(preset.name.toLowerCase());
- return owner === undefined || owner === "mcp";
- });
- const replaySessionHandles = sessionHandles.filter((handle) => (
- structuredAtNamespaces.get(handle.name.toLowerCase()) === "handle"
- ));
- const hashSegments = splitSessionReferenceSegments(value, sessionMentions);
- const hashSessionKeys = new Set(hashSegments.flatMap((segment) => (
- segment.kind === "session" ? [segment.mention.session_key] : []
- )));
- const legacySessionMentions = sessionMentions.filter((mention) => (
- !hashSessionKeys.has(mention.session_key)
- && !structuredAtNames.has(mention.name.toLowerCase())
- ));
-
- const appendCapabilitiesAndSkills = (text: string) => {
- for (const capability of splitCapabilityMentionSegments(
- text,
- replayCliApps,
- replayMcpPresets,
- replaySessionHandles,
- )) {
- if (capability.kind === "text") {
- segments.push(...splitSkillReferenceSegments(capability.text));
- } else {
- segments.push(capability);
- }
- }
- };
-
- for (const hashSegment of hashSegments) {
- if (hashSegment.kind === "session") {
- segments.push(hashSegment);
- continue;
- }
- for (const legacySegment of splitSessionReferenceSegments(
- hashSegment.text,
- legacySessionMentions,
- undefined,
- true,
- )) {
- if (legacySegment.kind === "session") {
- segments.push(legacySegment);
- } else {
- appendCapabilitiesAndSkills(legacySegment.text);
- }
+ for (const segment of splitCapabilityMentionSegments(
+ value,
+ cliApps,
+ mcpPresets,
+ sessionMentions,
+ )) {
+ if (segment.kind === "text") {
+ segments.push(...splitSkillReferenceSegments(segment.text));
+ } else {
+ segments.push(segment);
}
}
return segments;
@@ -139,28 +71,14 @@ export function UserMessageText({
cliApps,
mcpPresets,
sessionMentions = [],
- sessionHandles = [],
- attachedCliApps = [],
- attachedMcpPresets = [],
}: {
text: string;
cliApps: CliAppInfo[];
mcpPresets: McpPresetInfo[];
sessionMentions?: SessionMention[];
- sessionHandles?: SessionHandle[];
- attachedCliApps?: UICliAppAttachment[];
- attachedMcpPresets?: UIMcpPresetAttachment[];
}) {
const { t } = useTranslation();
- const segments = splitUserMessageSegments(
- text,
- cliApps,
- mcpPresets,
- sessionMentions,
- sessionHandles,
- attachedCliApps,
- attachedMcpPresets,
- );
+ const segments = splitUserMessageSegments(text, cliApps, mcpPresets, sessionMentions);
return (
<>
{segments.map((segment, index) => {
@@ -177,14 +95,6 @@ export function UserMessageText({
{segment.name}
);
- if (segment.kind === "session") return (
-
- );
return (
void;
surfaceRef?: Ref;
@@ -254,14 +245,10 @@ const SLASH_PALETTE_MIN_HEIGHT_PX = 144;
const SLASH_PALETTE_CHROME_PX = 12;
const SLASH_RECENTS_STORAGE_KEY = "nanobot.webui.slashCommandRecents";
const SLASH_RECENTS_LIMIT = 5;
-const QUEUED_PROMPTS_STORAGE_PREFIX = "nanobot.webui.composerQueuedGuidance.v2:";
-const LEGACY_QUEUED_PROMPTS_STORAGE_PREFIX = "nanobot.webui.composerQueuedGuidance.v1:";
+const QUEUED_PROMPTS_STORAGE_PREFIX = "nanobot.webui.composerQueuedGuidance.v1:";
const QUEUED_PROMPTS_LIMIT = 20;
const QUEUED_PROMPT_MAX_CHARS = 4000;
-const SESSION_HANDLES_LIMIT = 8;
-const SESSION_HANDLE_SELECTIONS_LIMIT = SESSION_HANDLES_LIMIT * 4;
const SESSION_MENTIONS_LIMIT = 8;
-const SESSION_MENTION_SELECTIONS_LIMIT = SESSION_MENTIONS_LIMIT * 4;
function VoiceRecordingMeter({
ariaLabel,
@@ -314,11 +301,7 @@ interface QueuedPrompt {
text: string;
images?: QueuedPromptImage[];
quotedContext?: string;
- sessionHandles?: SessionHandle[];
- sessionHandleSelections?: SessionHandleSelection[];
sessionMentions?: SessionMention[];
- sessionMentionSelections?: SessionMentionSelection[];
- atMentionNamespaces?: AtMentionNamespaces;
}
interface QueuedPromptImage {
@@ -333,25 +316,11 @@ interface CliAppMentionQuery {
end: number;
}
-type AtMentionNamespace = "handle" | "cli" | "mcp";
-type AtMentionNamespaces = Record;
-
-function atMentionNamespace(
- namespaces: AtMentionNamespaces,
- name: string,
-): AtMentionNamespace | undefined {
- const key = name.trim().toLowerCase();
- return Object.prototype.hasOwnProperty.call(namespaces, key)
- ? namespaces[key]
- : undefined;
-}
-
type MentionCandidate = {
name: string;
displayName: string;
} & (
| { kind: "session"; mention: SessionMention }
- | { kind: "handle"; handle: SessionHandle }
| {
kind: "cli" | "mcp";
brandColor: string | null;
@@ -367,39 +336,11 @@ interface MentionInsertion {
tokenEnd: number;
}
-function atMentionNames(value: string): Set {
- const names = new Set();
- const mentionRe = /(^|[\s([{])@([\p{L}\p{N}_-]+)(?=$|[^\p{L}\p{N}_-])/giu;
- let match: RegExpExecArray | null;
- while ((match = mentionRe.exec(value)) !== null) {
- names.add((match[2] ?? "").toLowerCase());
- }
- return names;
-}
-
-function normalizeAtMentionNamespaces(
- value: unknown,
- text: string,
-): AtMentionNamespaces {
- if (!value || typeof value !== "object" || Array.isArray(value)) return {};
- const presentNames = atMentionNames(text);
- return Object.fromEntries(Object.entries(value).flatMap(([rawName, rawKind]) => {
- const name = rawName.trim().toLowerCase();
- if (
- !presentNames.has(name)
- || !/^[\p{L}\p{N}_-]+$/u.test(name)
- || (rawKind !== "handle" && rawKind !== "cli" && rawKind !== "mcp")
- ) return [];
- return [[name, rawKind]];
- }));
-}
-
function mentionInsertion(
value: string,
name: string,
start: number,
end: number,
- sigil: "@" | "#" = "@",
): MentionInsertion {
const from = Math.min(Math.max(start, 0), value.length);
const to = Math.min(Math.max(end, from), value.length);
@@ -410,254 +351,25 @@ function mentionInsertion(
const tokenStart = prefix.length + leadingSpace.length;
const tokenEnd = tokenStart + name.length + 1;
return {
- value: `${prefix}${leadingSpace}${sigil}${name}${trailingSpace}${suffix}`,
+ value: `${prefix}${leadingSpace}@${name}${trailingSpace}${suffix}`,
cursor: tokenEnd + trailingSpace.length,
tokenStart,
tokenEnd,
};
}
-function sessionMentionBase(session: ChatSummary): string {
- const label = session.title?.trim() || session.preview.trim() || "session";
- const slug = label
- .normalize("NFKC")
- .replace(/\s+/g, "-")
- .replace(/[^\p{L}\p{N}_-]+/gu, "")
- .replace(/^-+|-+$/g, "");
- return Array.from(slug || "session").slice(0, 40).join("");
-}
-
function sessionMentionOptions(sessions: ChatSummary[]): SessionMention[] {
- const used = new Set();
- const namesByKey = new Map();
- for (const session of [...sessions].sort((a, b) => a.key.localeCompare(b.key))) {
- const base = sessionMentionBase(session);
- let name = base;
- let suffix = 2;
- while (used.has(name.toLowerCase())) {
- name = `${base}-${suffix}`;
- suffix += 1;
- }
- used.add(name.toLowerCase());
- namesByKey.set(session.key, name);
- }
- return sessions.map((session) => ({
- name: namesByKey.get(session.key) ?? sessionMentionBase(session),
- session_key: session.key,
- title: session.title?.trim() || session.preview.trim(),
- }));
-}
-
-function sessionHandleOptions(sessions: ChatSummary[]): SessionHandle[] {
return sessions.flatMap((session) => {
- const handle = session.handle;
- if (!handle) return [];
- return [{ ...handle, session_key: handle.session_key || session.key }];
- });
-}
-
-interface TextEditRange {
- start: number;
- end: number;
-}
-
-function selectionMatchesText(
- value: string,
- selection: TokenSelection,
- sigil: "@" | "#",
-): boolean {
- return value.slice(selection.start, selection.end).toLowerCase()
- === `${sigil}${selection.mention.name}`.toLowerCase();
-}
-
-function reconcileTokenSelections(
- previousValue: string,
- nextValue: string,
- selections: TokenSelection[],
- sigil: "@" | "#",
- replacedRange?: TextEditRange | null,
-): TokenSelection[] {
- if (previousValue === nextValue) return selections;
- if (replacedRange) {
- const start = Math.min(Math.max(replacedRange.start, 0), previousValue.length);
- const end = Math.min(Math.max(replacedRange.end, start), previousValue.length);
- const insertedLength = nextValue.length - (previousValue.length - (end - start));
- const nextSuffixStart = start + insertedLength;
- const describesEdit = insertedLength >= 0
- && previousValue.slice(0, start) === nextValue.slice(0, start)
- && previousValue.slice(end) === nextValue.slice(nextSuffixStart);
- if (describesEdit) {
- const delta = insertedLength - (end - start);
- return selections.flatMap((selection) => {
- if (selection.start < end && selection.end > start) return [];
- const mapped = selection.start >= end
- ? {
- ...selection,
- start: selection.start + delta,
- end: selection.end + delta,
- }
- : selection;
- return selectionMatchesText(nextValue, mapped, sigil) ? [mapped] : [];
- });
- }
- }
- let prefix = 0;
- while (
- prefix < previousValue.length
- && prefix < nextValue.length
- && previousValue[prefix] === nextValue[prefix]
- ) {
- prefix += 1;
- }
- let suffix = 0;
- while (
- suffix < previousValue.length - prefix
- && suffix < nextValue.length - prefix
- && previousValue[previousValue.length - suffix - 1]
- === nextValue[nextValue.length - suffix - 1]
- ) {
- suffix += 1;
- }
- const oldChangedEnd = previousValue.length - suffix;
- const delta = nextValue.length - previousValue.length;
-
- return selections.flatMap((selection) => {
- const mapped = selection.end <= prefix
- ? selection
- : selection.start >= oldChangedEnd
- ? {
- ...selection,
- start: selection.start + delta,
- end: selection.end + delta,
- }
- : null;
- return mapped && selectionMatchesText(nextValue, mapped, sigil) ? [mapped] : [];
- });
-}
-
-function tokenSelectionsForText(
- value: string,
- mentions: T[],
- sigil: "@" | "#",
-): TokenSelection[] {
- const selections: TokenSelection[] = [];
- for (const mention of mentions) {
- const pattern = new RegExp(
- `(^|[\\s([{])${sigil === "#" ? "#" : "@"}${mention.name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`
- + "(?=$|[^\\p{L}\\p{N}_-])",
- "iu",
- );
- const match = pattern.exec(value);
- if (!match) continue;
- const start = match.index + (match[1]?.length ?? 0);
- selections.push({ mention, start, end: start + mention.name.length + 1 });
- }
- return selections;
-}
-
-function uniqueMentions(
- selections: TokenSelection[],
- limit = SESSION_MENTIONS_LIMIT,
-): T[] {
- const seen = new Set();
- return selections.flatMap(({ mention }) => {
- if (seen.has(mention.session_key)) return [];
- seen.add(mention.session_key);
- return [mention];
- }).slice(0, limit);
-}
-
-function selectionsForTrimmedText(
- value: string,
- selections: TokenSelection[],
- sigil: "@" | "#",
-): TokenSelection[] {
- const trimmed = value.trim();
- const leadingChars = value.length - value.trimStart().length;
- const sourceEnd = leadingChars + trimmed.length;
- return selections.flatMap((selection) => {
- if (selection.start < leadingChars || selection.end > sourceEnd) return [];
- const mapped = {
- ...selection,
- start: selection.start - leadingChars,
- end: selection.end - leadingChars,
- };
- return selectionMatchesText(trimmed, mapped, sigil) ? [mapped] : [];
- });
-}
-
-function validateSessionMentionSelections(
- text: string,
- selections: SessionMentionSelection[],
- availableMentions: SessionMention[],
-): SessionMentionSelection[] {
- const availableByKey = new Map(
- availableMentions.map((mention) => [mention.session_key, mention]),
- );
- return selections.flatMap((selection) => {
- const available = availableByKey.get(selection.mention.session_key);
- if (
- !available
- || !selectionMatchesText(text, selection, "#")
- ) return [];
+ if (!session.handle) return [];
return [{
- ...selection,
- mention: {
- ...available,
- // The visible #slug is occurrence-bound. A later title refresh must
- // not silently detach the already selected session reference.
- name: selection.mention.name,
- },
+ id: session.handle.id,
+ name: session.handle.name,
+ session_key: session.key,
+ title: session.title?.trim() || session.preview.trim(),
}];
});
}
-function validateSessionHandleSelections(
- text: string,
- selections: SessionHandleSelection[],
- availableMentions: SessionHandle[],
-): SessionHandleSelection[] {
- const availableByKey = new Map(
- availableMentions.map((mention) => [mention.session_key, mention]),
- );
- return selections.flatMap((selection) => {
- const available = availableByKey.get(selection.mention.session_key);
- if (
- available?.id !== selection.mention.id
- || available.name.toLowerCase() !== selection.mention.name.toLowerCase()
- || !selectionMatchesText(text, selection, "@")
- ) return [];
- return [{ ...selection, mention: available }];
- });
-}
-
-type ComposerTokenSegment = CapabilityMentionSegment | Exclude;
-
-function splitComposerTokenSegments(
- value: string,
- cliApps: CliAppInfo[],
- mcpPresets: McpPresetInfo[],
- sessionHandles: SessionHandle[],
- handleSelections: SessionHandleSelection[],
- sessionMentions: SessionMention[],
-): ComposerTokenSegment[] {
- const segments: ComposerTokenSegment[] = [];
- for (const segment of splitCapabilityMentionSegments(
- value,
- cliApps,
- mcpPresets,
- sessionHandles,
- handleSelections,
- )) {
- if (segment.kind === "text") {
- segments.push(...splitSessionReferenceSegments(segment.text, sessionMentions));
- } else {
- segments.push(segment);
- }
- }
- return segments;
-}
-
interface SlashPaletteCommand {
command: string;
title: string;
@@ -709,12 +421,9 @@ function storeSlashRecents(commands: string[]): void {
}
}
-function queuedPromptsStorageKey(
- key?: string | null,
- prefix = QUEUED_PROMPTS_STORAGE_PREFIX,
-): string | null {
+function queuedPromptsStorageKey(key?: string | null): string | null {
const clean = key?.trim();
- return clean ? `${prefix}${clean}` : null;
+ return clean ? `${QUEUED_PROMPTS_STORAGE_PREFIX}${clean}` : null;
}
function normalizeQueuedSessionMentions(value: unknown): SessionMention[] {
@@ -730,6 +439,9 @@ function normalizeQueuedSessionMentions(value: unknown): SessionMention[] {
|| !/^[\p{L}\p{N}_-]+$/u.test(name)
) return [];
return [{
+ ...(typeof candidate.id === "string" && /^handle_[a-f0-9]{32}$/i.test(candidate.id)
+ ? { id: candidate.id }
+ : {}),
name,
session_key: sessionKey,
title: candidate.title?.trim().slice(0, 160) ?? "",
@@ -737,85 +449,6 @@ function normalizeQueuedSessionMentions(value: unknown): SessionMention[] {
}).slice(0, SESSION_MENTIONS_LIMIT);
}
-function normalizeQueuedSessionMentionSelections(
- value: unknown,
- text: string,
- fallbackMentions: SessionMention[],
-): SessionMentionSelection[] {
- if (!Array.isArray(value)) {
- return tokenSelectionsForText(text, fallbackMentions, "#");
- }
- return value.flatMap((item) => {
- if (!item || typeof item !== "object") return [];
- const candidate = item as Partial;
- const mention = normalizeQueuedSessionMentions([candidate.mention])[0];
- const start = candidate.start;
- const end = candidate.end;
- if (
- !mention
- || !Number.isInteger(start)
- || !Number.isInteger(end)
- || typeof start !== "number"
- || typeof end !== "number"
- || start < 0
- || end <= start
- || end > text.length
- ) return [];
- const selection = { mention, start, end };
- return selectionMatchesText(text, selection, "#") ? [selection] : [];
- }).slice(0, SESSION_MENTION_SELECTIONS_LIMIT);
-}
-
-function normalizeQueuedSessionHandles(value: unknown): SessionHandle[] {
- if (!Array.isArray(value)) return [];
- return value.flatMap((item) => {
- if (!item || typeof item !== "object") return [];
- const candidate = item as Partial;
- const id = candidate.id?.trim().slice(0, 80);
- const name = candidate.name?.trim().slice(0, 80);
- const sessionKey = candidate.session_key?.trim().slice(0, 512);
- const colorSlot = candidate.color_slot;
- if (
- !id
- || !name
- || !sessionKey?.startsWith("websocket:")
- || !/^[\p{L}\p{N}_-]+$/u.test(name)
- || !Number.isInteger(colorSlot)
- || typeof colorSlot !== "number"
- ) return [];
- return [{ id, name, session_key: sessionKey, color_slot: colorSlot }];
- }).slice(0, SESSION_HANDLES_LIMIT);
-}
-
-function normalizeQueuedSessionHandleSelections(
- value: unknown,
- text: string,
- fallbackMentions: SessionHandle[],
-): SessionHandleSelection[] {
- if (!Array.isArray(value)) {
- return tokenSelectionsForText(text, fallbackMentions, "@");
- }
- return value.flatMap((item) => {
- if (!item || typeof item !== "object") return [];
- const candidate = item as Partial;
- const mention = normalizeQueuedSessionHandles([candidate.mention])[0];
- const start = candidate.start;
- const end = candidate.end;
- if (
- !mention
- || !Number.isInteger(start)
- || !Number.isInteger(end)
- || typeof start !== "number"
- || typeof end !== "number"
- || start < 0
- || end <= start
- || end > text.length
- ) return [];
- const selection = { mention, start, end };
- return selectionMatchesText(text, selection, "@") ? [selection] : [];
- }).slice(0, SESSION_HANDLE_SELECTIONS_LIMIT);
-}
-
function normalizeQueuedPrompt(item: unknown, index: number): QueuedPrompt | null {
if (!item || typeof item !== "object") return null;
const record = item as Partial;
@@ -846,20 +479,6 @@ function normalizeQueuedPrompt(item: unknown, index: number): QueuedPrompt | nul
? record.quotedContext.trim().slice(0, QUEUED_PROMPT_MAX_CHARS)
: "";
const sessionMentions = normalizeQueuedSessionMentions(record.sessionMentions);
- const sessionMentionSelections = normalizeQueuedSessionMentionSelections(
- record.sessionMentionSelections,
- text,
- sessionMentions,
- );
- const selectedSessionMentions = uniqueMentions(sessionMentionSelections);
- const sessionHandles = normalizeQueuedSessionHandles(record.sessionHandles);
- const sessionHandleSelections = normalizeQueuedSessionHandleSelections(
- record.sessionHandleSelections,
- text,
- sessionHandles,
- );
- const selectedSessionHandles = uniqueMentions(sessionHandleSelections, SESSION_HANDLES_LIMIT);
- const atMentionNamespaces = normalizeAtMentionNamespaces(record.atMentionNamespaces, text);
if (!text && images.length === 0) return null;
const id = typeof record.id === "string" && record.id.trim()
? record.id
@@ -869,16 +488,7 @@ function normalizeQueuedPrompt(item: unknown, index: number): QueuedPrompt | nul
text,
...(images.length > 0 ? { images } : {}),
...(quotedContext ? { quotedContext } : {}),
- ...(selectedSessionMentions.length > 0
- ? {
- sessionMentions: selectedSessionMentions,
- sessionMentionSelections,
- }
- : {}),
- ...(selectedSessionHandles.length > 0
- ? { sessionHandles: selectedSessionHandles, sessionHandleSelections }
- : {}),
- ...(Object.keys(atMentionNamespaces).length > 0 ? { atMentionNamespaces } : {}),
+ ...(sessionMentions.length > 0 ? { sessionMentions } : {}),
};
}
@@ -897,63 +507,6 @@ function readQueuedPrompts(storageKey: string): QueuedPrompt[] {
}
}
-function serializeQueuedPrompts(prompts: QueuedPrompt[]): string {
- return JSON.stringify(
- prompts.slice(0, QUEUED_PROMPTS_LIMIT).map((prompt) => {
- const sessionSelections = (prompt.sessionMentionSelections ?? []).filter((selection) => (
- selectionMatchesText(prompt.text, selection, "#")
- )).slice(0, SESSION_MENTION_SELECTIONS_LIMIT);
- const sessionMentions = uniqueMentions(sessionSelections);
- const handleSelections = (prompt.sessionHandleSelections ?? []).filter((selection) => (
- selectionMatchesText(prompt.text, selection, "@")
- )).slice(0, SESSION_HANDLE_SELECTIONS_LIMIT);
- const sessionHandles = uniqueMentions(handleSelections, SESSION_HANDLES_LIMIT);
- const atMentionNamespaces = normalizeAtMentionNamespaces(
- prompt.atMentionNamespaces,
- prompt.text,
- );
- return {
- id: prompt.id,
- text: prompt.text.slice(0, QUEUED_PROMPT_MAX_CHARS),
- ...(prompt.images?.length
- ? { images: prompt.images.slice(0, MAX_ATTACHMENTS_PER_MESSAGE) }
- : {}),
- ...(prompt.quotedContext ? { quotedContext: prompt.quotedContext } : {}),
- ...(sessionMentions.length > 0
- ? { sessionMentions, sessionMentionSelections: sessionSelections }
- : {}),
- ...(sessionHandles.length > 0
- ? { sessionHandles, sessionHandleSelections: handleSelections }
- : {}),
- ...(Object.keys(atMentionNamespaces).length > 0 ? { atMentionNamespaces } : {}),
- };
- }),
- );
-}
-
-function readQueuedPromptsWithLegacyMigration(
- storageKey: string,
- legacyStorageKey: string | null,
-): QueuedPrompt[] {
- if (typeof window === "undefined") return [];
- try {
- if (window.localStorage.getItem(storageKey) !== null) {
- return readQueuedPrompts(storageKey);
- }
- if (!legacyStorageKey || window.localStorage.getItem(legacyStorageKey) === null) {
- return [];
- }
- const prompts = readQueuedPrompts(legacyStorageKey);
- if (prompts.length > 0) {
- window.localStorage.setItem(storageKey, serializeQueuedPrompts(prompts));
- }
- window.localStorage.removeItem(legacyStorageKey);
- return prompts;
- } catch {
- return [];
- }
-}
-
function storeQueuedPrompts(storageKey: string, prompts: QueuedPrompt[]): void {
if (typeof window === "undefined") return;
try {
@@ -963,7 +516,17 @@ function storeQueuedPrompts(storageKey: string, prompts: QueuedPrompt[]): void {
}
window.localStorage.setItem(
storageKey,
- serializeQueuedPrompts(prompts),
+ JSON.stringify(
+ prompts.slice(0, QUEUED_PROMPTS_LIMIT).map((prompt) => ({
+ id: prompt.id,
+ text: prompt.text.slice(0, QUEUED_PROMPT_MAX_CHARS),
+ ...(prompt.images?.length ? { images: prompt.images.slice(0, MAX_ATTACHMENTS_PER_MESSAGE) } : {}),
+ ...(prompt.quotedContext ? { quotedContext: prompt.quotedContext } : {}),
+ ...(prompt.sessionMentions?.length
+ ? { sessionMentions: prompt.sessionMentions.slice(0, SESSION_MENTIONS_LIMIT) }
+ : {}),
+ })),
+ ),
);
} catch {
// localStorage persistence is a convenience; the in-memory queue still works.
@@ -1337,7 +900,6 @@ export function ThreadComposer({
cliApps = [],
mcpPresets = [],
sessions = [],
- handleSessions = [],
skills = [],
onStop,
surfaceRef,
@@ -1360,15 +922,7 @@ export function ThreadComposer({
}: ThreadComposerProps) {
const { t } = useTranslation();
const [value, setValue] = useState("");
- const [selectedSessionMentionSelections, setSelectedSessionMentionSelections] = useState<
- SessionMentionSelection[]
- >([]);
- const [selectedSessionHandleSelections, setSelectedSessionHandleSelections] = useState<
- SessionHandleSelection[]
- >([]);
- const [selectedAtMentionNamespaces, setSelectedAtMentionNamespaces] = useState<
- AtMentionNamespaces
- >({});
+ const [selectedSessionMentions, setSelectedSessionMentions] = useState([]);
const [sessionDragPreview, setSessionDragPreview] = useState<{
mention: SessionMention;
start: number;
@@ -1385,13 +939,8 @@ export function ThreadComposer({
const [cursorPosition, setCursorPosition] = useState(0);
const [recentSlashCommands, setRecentSlashCommands] = useState(() => readSlashRecents());
const [queuedPrompts, setQueuedPrompts] = useState([]);
- const paletteId = useId();
- const slashPaletteId = `${paletteId}-slash-listbox`;
- const mentionPaletteId = `${paletteId}-mention-listbox`;
const hasTouchPrimaryPointer = useMediaQuery("(hover: none) and (pointer: coarse)");
const textareaRef = useRef(null);
- const inputSelectionRef = useRef({ start: 0, end: 0 });
- const pendingInputEditRef = useRef(null);
const formRef = useRef(null);
const fileInputRef = useRef(null);
const chipRefs = useRef(new Map());
@@ -1411,13 +960,6 @@ export function ThreadComposer({
() => queuedPromptsStorageKey(pendingQueueKey),
[pendingQueueKey],
);
- const legacyQueuedPromptStorageKey = useMemo(
- () => queuedPromptsStorageKey(
- pendingQueueKey,
- LEGACY_QUEUED_PROMPTS_STORAGE_PREFIX,
- ),
- [pendingQueueKey],
- );
const projectPickerAvailable =
isHero
&& !!workspaceDefaultScope
@@ -1428,15 +970,8 @@ export function ThreadComposer({
useEffect(() => {
secondEnterPromptIdRef.current = null;
skipQueuedPromptPersistRef.current = true;
- setQueuedPrompts(
- queuedPromptStorageKey
- ? readQueuedPromptsWithLegacyMigration(
- queuedPromptStorageKey,
- legacyQueuedPromptStorageKey,
- )
- : [],
- );
- }, [legacyQueuedPromptStorageKey, pendingQueueKey, queuedPromptStorageKey]);
+ setQueuedPrompts(queuedPromptStorageKey ? readQueuedPrompts(queuedPromptStorageKey) : []);
+ }, [pendingQueueKey, queuedPromptStorageKey]);
useEffect(() => {
if (!queuedPromptStorageKey) return;
@@ -1711,90 +1246,13 @@ export function ThreadComposer({
};
}, [cliAppMenuDismissed, cursorPosition, interactionDisabled, value]);
- const sessionReferenceQuery = useMemo(() => {
- if (interactionDisabled || cliAppMenuDismissed) return null;
- const caret = Math.min(Math.max(cursorPosition, 0), value.length);
- const beforeCaret = value.slice(0, caret);
- const match = /(?:^|\s)#([\p{L}\p{N}_-]*)$/iu.exec(beforeCaret);
- if (!match) return null;
- const query = match[1].toLowerCase();
- return { query, start: caret - query.length - 1, end: caret };
- }, [cliAppMenuDismissed, cursorPosition, interactionDisabled, value]);
-
const availableSessionMentions = useMemo(
() => sessionMentionOptions(sessions),
[sessions],
);
- const availableSessionHandles = useMemo(
- () => sessionHandleOptions(handleSessions),
- [handleSessions],
- );
- const validSelectedSessionMentionSelections = useMemo(
- () => validateSessionMentionSelections(
- value,
- selectedSessionMentionSelections,
- availableSessionMentions,
- ),
- [availableSessionMentions, selectedSessionMentionSelections, value],
- );
- const activeSessionMentions = useMemo(
- () => uniqueMentions(validSelectedSessionMentionSelections),
- [validSelectedSessionMentionSelections],
- );
- const validSelectedSessionHandleSelections = useMemo(
- () => validateSessionHandleSelections(
- value,
- selectedSessionHandleSelections,
- availableSessionHandles,
- ),
- [availableSessionHandles, selectedSessionHandleSelections, value],
- );
- const activeSessionHandles = useMemo(
- () => uniqueMentions(validSelectedSessionHandleSelections),
- [validSelectedSessionHandleSelections],
- );
- const validAtMentionNamespaces = useMemo(
- () => normalizeAtMentionNamespaces(selectedAtMentionNamespaces, value),
- [selectedAtMentionNamespaces, value],
- );
- const ownedSessionHandles = useMemo(
- () => activeSessionHandles.filter((handle) => (
- (atMentionNamespace(validAtMentionNamespaces, handle.name) ?? "handle") === "handle"
- )),
- [activeSessionHandles, validAtMentionNamespaces],
- );
- const effectiveCliApps = useMemo(
- () => cliApps.filter((app) => {
- const owner = atMentionNamespace(validAtMentionNamespaces, app.name);
- return owner === undefined || owner === "cli";
- }),
- [cliApps, validAtMentionNamespaces],
- );
- const effectiveMcpPresets = useMemo(
- () => mcpPresets.filter((preset) => {
- const owner = atMentionNamespace(validAtMentionNamespaces, preset.name);
- return owner === undefined || owner === "mcp";
- }),
- [mcpPresets, validAtMentionNamespaces],
- );
const mentionSegments = useMemo(
- () => splitComposerTokenSegments(
- value,
- effectiveCliApps,
- effectiveMcpPresets,
- ownedSessionHandles,
- validSelectedSessionHandleSelections,
- activeSessionMentions,
- ),
- [
- activeSessionMentions,
- effectiveCliApps,
- effectiveMcpPresets,
- ownedSessionHandles,
- validSelectedSessionHandleSelections,
- validSelectedSessionMentionSelections,
- value,
- ],
+ () => splitCapabilityMentionSegments(value, cliApps, mcpPresets, selectedSessionMentions),
+ [cliApps, mcpPresets, selectedSessionMentions, value],
);
const sessionDragInsertion = sessionDragPreview
? mentionInsertion(
@@ -1802,150 +1260,49 @@ export function ThreadComposer({
sessionDragPreview.mention.name,
sessionDragPreview.start,
sessionDragPreview.end,
- "#",
)
: null;
const displayMentionSegments = sessionDragInsertion && sessionDragPreview
- ? (() => {
- const previewSelections = [
- ...reconcileTokenSelections(
- value,
- sessionDragInsertion.value,
- validSelectedSessionMentionSelections,
- "#",
- { start: sessionDragPreview.start, end: sessionDragPreview.end },
- ).filter((selection) => (
- selection.mention.session_key !== sessionDragPreview.mention.session_key
- && selection.mention.name.toLowerCase()
- !== sessionDragPreview.mention.name.toLowerCase()
- )),
- {
- mention: sessionDragPreview.mention,
- start: sessionDragInsertion.tokenStart,
- end: sessionDragInsertion.tokenEnd,
- },
- ];
- const previewSessionSelections = reconcileTokenSelections(
- value,
- sessionDragInsertion.value,
- validSelectedSessionHandleSelections,
- "@",
- { start: sessionDragPreview.start, end: sessionDragPreview.end },
- );
- return splitComposerTokenSegments(
- sessionDragInsertion.value,
- effectiveCliApps,
- effectiveMcpPresets,
- uniqueMentions(previewSessionSelections).filter((handle) => (
- (atMentionNamespace(validAtMentionNamespaces, handle.name) ?? "handle") === "handle"
- )),
- previewSessionSelections,
- previewSelections.map((selection) => selection.mention),
- );
- })()
+ ? splitCapabilityMentionSegments(
+ sessionDragInsertion.value,
+ cliApps,
+ mcpPresets,
+ [...selectedSessionMentions, sessionDragPreview.mention],
+ )
: mentionSegments;
- useEffect(() => {
- if (
- validSelectedSessionMentionSelections.length
- === selectedSessionMentionSelections.length
- && validSelectedSessionMentionSelections.every((selection, index) => {
- const current = selectedSessionMentionSelections[index];
- return current?.mention.name === selection.mention.name
- && current.mention.session_key === selection.mention.session_key
- && current.mention.title === selection.mention.title
- && current.start === selection.start
- && current.end === selection.end;
- })
- ) return;
- setSelectedSessionMentionSelections(validSelectedSessionMentionSelections);
- }, [selectedSessionMentionSelections, validSelectedSessionMentionSelections]);
- useEffect(() => {
- if (
- validSelectedSessionHandleSelections.length === selectedSessionHandleSelections.length
- && validSelectedSessionHandleSelections.every((selection, index) => {
- const current = selectedSessionHandleSelections[index];
- return current?.mention.id === selection.mention.id
- && current.mention.name === selection.mention.name
- && current.mention.session_key === selection.mention.session_key
- && current.mention.color_slot === selection.mention.color_slot
- && current.start === selection.start
- && current.end === selection.end;
- })
- ) return;
- setSelectedSessionHandleSelections(validSelectedSessionHandleSelections);
- }, [selectedSessionHandleSelections, validSelectedSessionHandleSelections]);
- const applyComposerTextEdit = useCallback(
- (
- nextValue: string,
- replacedRange: TextEditRange | null = null,
- nextSelection: TextEditRange = { start: nextValue.length, end: nextValue.length },
- ) => {
- setSelectedSessionMentionSelections(reconcileTokenSelections(
- value,
- nextValue,
- validSelectedSessionMentionSelections,
- "#",
- replacedRange,
- ));
- setSelectedSessionHandleSelections(reconcileTokenSelections(
- value,
- nextValue,
- validSelectedSessionHandleSelections,
- "@",
- replacedRange,
- ));
- setSelectedAtMentionNamespaces((current) => normalizeAtMentionNamespaces(
- current,
- nextValue,
- ));
- setValue(nextValue);
- inputSelectionRef.current = nextSelection;
- pendingInputEditRef.current = null;
- },
- [validSelectedSessionHandleSelections, validSelectedSessionMentionSelections, value],
- );
- const resetComposerText = useCallback(() => {
- setValue("");
- setSelectedSessionMentionSelections([]);
- setSelectedSessionHandleSelections([]);
- setSelectedAtMentionNamespaces({});
- inputSelectionRef.current = { start: 0, end: 0 };
- pendingInputEditRef.current = null;
- }, []);
+ const activeSessionMentions = useMemo(() => {
+ const seen = new Set();
+ return mentionSegments.flatMap((segment) => {
+ if (segment.kind !== "session" || seen.has(segment.mention.session_key)) return [];
+ seen.add(segment.mention.session_key);
+ return [segment.mention];
+ }).slice(0, SESSION_MENTIONS_LIMIT);
+ }, [mentionSegments]);
const filteredMentionCandidates = useMemo(() => {
- if (sessionReferenceQuery) {
- return availableSessionMentions
- .filter((mention) => (
- activeSessionMentions.length < SESSION_MENTIONS_LIMIT
- || activeSessionMentions.some((selected) => selected.session_key === mention.session_key)
- ))
- .filter((mention) => [mention.name, mention.title]
- .join(" ").toLowerCase().includes(sessionReferenceQuery.query))
- .slice(0, 8)
- .map((mention) => ({
- kind: "session" as const,
- name: mention.name,
- displayName: mention.title || mention.name,
- mention,
- }));
- }
if (!cliAppMention) return [];
- const handleCandidates: MentionCandidate[] = availableSessionHandles
+ const sessionCandidates: MentionCandidate[] = availableSessionMentions
.filter((mention) => (
- activeSessionHandles.length < SESSION_MENTIONS_LIMIT
- || activeSessionHandles.some(
+ activeSessionMentions.length < SESSION_MENTIONS_LIMIT
+ || activeSessionMentions.some(
(selected) => selected.session_key === mention.session_key,
)
))
- .filter((mention) => mention.name.toLowerCase().includes(cliAppMention.query))
+ .filter((mention) => [
+ mention.name,
+ mention.title,
+ ].join(" ").toLowerCase().includes(cliAppMention.query))
.map((mention) => ({
- kind: "handle",
+ kind: "session",
name: mention.name,
- displayName: `@${mention.name}`,
- handle: mention,
+ displayName: mention.title || mention.name,
+ mention,
}));
+ const sessionNames = new Set(
+ availableSessionMentions.map((mention) => mention.name.toLowerCase()),
+ );
const cliCandidates: MentionCandidate[] = cliApps
.filter((app) => app.installed)
+ .filter((app) => !sessionNames.has(app.name.toLowerCase()))
.filter((app) => {
const haystack = [
app.name,
@@ -1966,6 +1323,7 @@ export function ThreadComposer({
}));
const mcpCandidates: MentionCandidate[] = mcpPresets
.filter((preset) => preset.installed && preset.configured)
+ .filter((preset) => !sessionNames.has(preset.name.toLowerCase()))
.filter((preset) => {
const haystack = [
preset.name,
@@ -1985,7 +1343,7 @@ export function ThreadComposer({
initials: mcpPresetInitials(preset),
}));
const groups = [
- { candidates: handleCandidates, reserved: 4 },
+ { candidates: sessionCandidates, reserved: 4 },
{ candidates: cliCandidates, reserved: 2 },
{ candidates: mcpCandidates, reserved: 2 },
];
@@ -2001,16 +1359,7 @@ export function ThreadComposer({
remaining -= extra;
}
return groups.flatMap(({ candidates }, index) => candidates.slice(0, counts[index]));
- }, [
- activeSessionHandles,
- activeSessionMentions,
- availableSessionHandles,
- availableSessionMentions,
- cliAppMention,
- cliApps,
- mcpPresets,
- sessionReferenceQuery,
- ]);
+ }, [activeSessionMentions, availableSessionMentions, cliAppMention, cliApps, mcpPresets]);
const showCliAppMenu = filteredMentionCandidates.length > 0;
const showAnyPalette = showSlashMenu || showCliAppMenu;
@@ -2044,7 +1393,7 @@ export function ThreadComposer({
useEffect(() => {
setSelectedCliAppIndex(0);
- }, [cliAppMention?.query, sessionReferenceQuery?.query]);
+ }, [cliAppMention?.query]);
useEffect(() => {
if (selectedCommandIndex >= filteredSlashCommands.length) {
@@ -2129,7 +1478,8 @@ export function ThreadComposer({
if (previousPendingQueueKeyRef.current === pendingQueueKey) return;
previousPendingQueueKeyRef.current = pendingQueueKey;
secondEnterPromptIdRef.current = null;
- resetComposerText();
+ setValue("");
+ setSelectedSessionMentions([]);
setInlineError(null);
setSlashMenuDismissed(false);
setCliAppMenuDismissed(false);
@@ -2141,25 +1491,22 @@ export function ThreadComposer({
el.style.height = "auto";
el.style.height = `${Math.min(el.scrollHeight, 260)}px`;
});
- }, [clear, pendingQueueKey, resetComposerText]);
+ }, [clear, pendingQueueKey]);
const appendTranscription = useCallback((text: string) => {
const transcript = text.trim();
if (!transcript) return;
secondEnterPromptIdRef.current = null;
- const separator = value.trim() && !/[\s\n]$/.test(value) ? " " : "";
- const next = value.trim() ? `${value}${separator}${transcript}` : transcript;
- const nextCursor = next.length;
- applyComposerTextEdit(
- next,
- { start: value.length, end: value.length },
- { start: nextCursor, end: nextCursor },
- );
+ setValue((current) => {
+ if (!current.trim()) return transcript;
+ const separator = /[\s\n]$/.test(current) ? "" : " ";
+ return `${current}${separator}${transcript}`;
+ });
setSlashMenuDismissed(false);
setCliAppMenuDismissed(false);
setInlineError(null);
resizeTextarea();
- }, [applyComposerTextEdit, resizeTextarea, value]);
+ }, [resizeTextarea]);
const clearVoiceErrorTimers = useCallback(() => {
if (voiceErrorFadeTimerRef.current !== null) clearTimeout(voiceErrorFadeTimerRef.current);
@@ -2232,7 +1579,7 @@ export function ThreadComposer({
(command: SlashPaletteCommand) => {
if (command.command === "/stop" && isStreaming && onStop) {
onStop();
- resetComposerText();
+ setValue("");
setSlashMenuDismissed(true);
setCliAppMenuDismissed(false);
setInlineError(null);
@@ -2252,11 +1599,7 @@ export function ThreadComposer({
const inserted = `${command.command}${suffix.startsWith(" ") ? "" : " "}`;
const next = `${value.slice(0, skillQuery.start)}${inserted}${suffix}`;
const nextCursor = skillQuery.start + inserted.length;
- applyComposerTextEdit(
- next,
- { start: skillQuery.start, end: skillQuery.end },
- { start: nextCursor, end: nextCursor },
- );
+ setValue(next);
setCursorPosition(nextCursor);
requestAnimationFrame(() => {
const el = textareaRef.current;
@@ -2265,99 +1608,34 @@ export function ThreadComposer({
el.setSelectionRange(nextCursor, nextCursor);
});
} else {
- const next = command.argHint ? `${command.command} ` : command.command;
- applyComposerTextEdit(
- next,
- { start: 0, end: value.length },
- { start: next.length, end: next.length },
- );
+ setValue(command.argHint ? `${command.command} ` : command.command);
}
setSlashMenuDismissed(true);
setCliAppMenuDismissed(false);
setInlineError(null);
resizeTextarea();
},
- [
- applyComposerTextEdit,
- isStreaming,
- onStop,
- recentSlashCommands,
- resetComposerText,
- resizeTextarea,
- skillQuery,
- value,
- ],
+ [isStreaming, onStop, recentSlashCommands, resizeTextarea, skillQuery, value],
);
const insertMentionCandidate = useCallback(
(candidate: MentionCandidate, start: number, end: number) => {
- const insertion = mentionInsertion(
- value,
- candidate.name,
- start,
- end,
- candidate.kind === "session" ? "#" : "@",
- );
- const reconciledReferenceSelections = reconcileTokenSelections(
- value,
- insertion.value,
- validSelectedSessionMentionSelections,
- "#",
- { start, end },
- );
- const reconciledHandleSelections = reconcileTokenSelections(
- value,
- insertion.value,
- validSelectedSessionHandleSelections,
- "@",
- { start, end },
- );
if (candidate.kind === "session") {
const alreadySelected = activeSessionMentions.some(
(mention) => mention.session_key === candidate.mention.session_key,
);
if (!alreadySelected && activeSessionMentions.length >= SESSION_MENTIONS_LIMIT) return;
- setSelectedSessionMentionSelections([
- ...reconciledReferenceSelections,
- {
- mention: candidate.mention,
- start: insertion.tokenStart,
- end: insertion.tokenEnd,
- },
- ]);
- setSelectedSessionHandleSelections(reconciledHandleSelections);
- } else if (candidate.kind === "handle") {
- const alreadySelected = activeSessionHandles.some(
- (mention) => mention.session_key === candidate.handle.session_key,
- );
- if (!alreadySelected && activeSessionHandles.length >= SESSION_MENTIONS_LIMIT) return;
- setSelectedSessionHandleSelections([
- ...reconciledHandleSelections.filter((selection) => (
- selection.mention.name.toLowerCase() !== candidate.name.toLowerCase()
- )),
- {
- mention: candidate.handle,
- start: insertion.tokenStart,
- end: insertion.tokenEnd,
- },
- ]);
- setSelectedSessionMentionSelections(reconciledReferenceSelections);
- } else {
- setSelectedSessionMentionSelections(reconciledReferenceSelections);
- setSelectedSessionHandleSelections(reconciledHandleSelections.filter((selection) => (
- selection.mention.name.toLowerCase() !== candidate.name.toLowerCase()
- )));
- }
- if (candidate.kind !== "session") {
const name = candidate.name.toLowerCase();
- setSelectedAtMentionNamespaces((current) => ({
- ...normalizeAtMentionNamespaces(current, insertion.value),
- [name]: candidate.kind,
- }));
+ setSelectedSessionMentions([
+ ...activeSessionMentions.filter((mention) => (
+ mention.name.toLowerCase() !== name
+ && mention.session_key !== candidate.mention.session_key
+ )),
+ candidate.mention,
+ ]);
}
+ const insertion = mentionInsertion(value, candidate.name, start, end);
setValue(insertion.value);
- inputSelectionRef.current = { start: insertion.cursor, end: insertion.cursor };
- pendingInputEditRef.current = null;
setCursorPosition(insertion.cursor);
setCliAppMenuDismissed(true);
setSlashMenuDismissed(false);
@@ -2370,23 +1648,15 @@ export function ThreadComposer({
el.setSelectionRange(insertion.cursor, insertion.cursor);
});
},
- [
- activeSessionMentions,
- activeSessionHandles,
- resizeTextarea,
- validSelectedSessionHandleSelections,
- validSelectedSessionMentionSelections,
- value,
- ],
+ [activeSessionMentions, resizeTextarea, value],
);
const chooseMentionCandidate = useCallback(
(candidate: MentionCandidate) => {
- const query = candidate.kind === "session" ? sessionReferenceQuery : cliAppMention;
- if (!query) return;
- insertMentionCandidate(candidate, query.start, query.end);
+ if (!cliAppMention) return;
+ insertMentionCandidate(candidate, cliAppMention.start, cliAppMention.end);
},
- [cliAppMention, insertMentionCandidate, sessionReferenceQuery],
+ [cliAppMention, insertMentionCandidate],
);
const handleSessionDrop = useCallback((event: React.DragEvent) => {
@@ -2465,13 +1735,14 @@ export function ThreadComposer({
}, [sessionDragPreview]);
const clearComposerText = useCallback((restoreFocus = true) => {
- resetComposerText();
+ setValue("");
+ setSelectedSessionMentions([]);
setInlineError(null);
setSlashMenuDismissed(false);
setCliAppMenuDismissed(false);
setCursorPosition(0);
resizeTextarea(restoreFocus);
- }, [resetComposerText, resizeTextarea]);
+ }, [resizeTextarea]);
const queueGuidancePrompt = useCallback(() => {
const text = value.trim();
@@ -2481,18 +1752,6 @@ export function ThreadComposer({
return;
}
const queuedImages = readyImagesToQueuedImages(readyImages);
- const sessionMentionSelections = selectionsForTrimmedText(
- value,
- validSelectedSessionMentionSelections,
- "#",
- );
- const sessionMentions = uniqueMentions(sessionMentionSelections);
- const sessionHandleSelections = selectionsForTrimmedText(
- value,
- validSelectedSessionHandleSelections,
- "@",
- );
- const sessionHandles = uniqueMentions(sessionHandleSelections, SESSION_HANDLES_LIMIT);
queuedPromptCounterRef.current += 1;
const id = `queued-prompt-${Date.now()}-${queuedPromptCounterRef.current}`;
secondEnterPromptIdRef.current = id;
@@ -2503,14 +1762,8 @@ export function ThreadComposer({
text,
...(queuedImages.length > 0 ? { images: queuedImages } : {}),
...(normalizedQuotedContext ? { quotedContext: normalizedQuotedContext } : {}),
- ...(sessionMentions.length > 0
- ? { sessionMentions, sessionMentionSelections }
- : {}),
- ...(sessionHandles.length > 0
- ? { sessionHandles, sessionHandleSelections }
- : {}),
- ...(Object.keys(validAtMentionNamespaces).length > 0
- ? { atMentionNamespaces: validAtMentionNamespaces }
+ ...(activeSessionMentions.length > 0
+ ? { sessionMentions: activeSessionMentions }
: {}),
},
]);
@@ -2518,6 +1771,7 @@ export function ThreadComposer({
clearComposerText();
onQuotedContextChange?.(null);
}, [
+ activeSessionMentions,
canQueueGuidance,
clear,
clearComposerText,
@@ -2526,9 +1780,6 @@ export function ThreadComposer({
onQuotedContextChange,
readyImages,
textTooLargeMessage,
- validSelectedSessionHandleSelections,
- validSelectedSessionMentionSelections,
- validAtMentionNamespaces,
value,
]);
@@ -2541,28 +1792,8 @@ export function ThreadComposer({
const editQueuedPrompt = useCallback((prompt: QueuedPrompt) => {
secondEnterPromptIdRef.current = null;
setQueuedPrompts((items) => items.filter((item) => item.id !== prompt.id));
- const restoredSelections = validateSessionMentionSelections(
- prompt.text,
- prompt.sessionMentionSelections
- ?? tokenSelectionsForText(prompt.text, prompt.sessionMentions ?? [], "#"),
- availableSessionMentions,
- );
- const restoredSessionSelections = validateSessionHandleSelections(
- prompt.text,
- prompt.sessionHandleSelections
- ?? tokenSelectionsForText(prompt.text, prompt.sessionHandles ?? [], "@"),
- availableSessionHandles,
- );
- const restoredNamespaces = normalizeAtMentionNamespaces(
- prompt.atMentionNamespaces,
- prompt.text,
- );
setValue(prompt.text);
- setSelectedSessionMentionSelections(restoredSelections);
- setSelectedSessionHandleSelections(restoredSessionSelections);
- setSelectedAtMentionNamespaces(restoredNamespaces);
- inputSelectionRef.current = { start: prompt.text.length, end: prompt.text.length };
- pendingInputEditRef.current = null;
+ setSelectedSessionMentions(prompt.sessionMentions ?? []);
setInlineError(null);
setSlashMenuDismissed(false);
setCliAppMenuDismissed(false);
@@ -2580,14 +1811,7 @@ export function ThreadComposer({
el.focus();
el.setSelectionRange(prompt.text.length, prompt.text.length);
});
- }, [
- availableSessionHandles,
- availableSessionMentions,
- clear,
- onQuotedContextChange,
- resizeTextarea,
- restoreReadyImages,
- ]);
+ }, [clear, onQuotedContextChange, resizeTextarea, restoreReadyImages]);
const moveQueuedPrompt = useCallback((dragId: string, targetId: string) => {
if (dragId === targetId) return;
@@ -2603,90 +1827,22 @@ export function ThreadComposer({
});
}, []);
- const queuedSessionMentions = useCallback(
- (prompt: QueuedPrompt): SessionMention[] => uniqueMentions(
- validateSessionMentionSelections(
- prompt.text,
- prompt.sessionMentionSelections
- ?? tokenSelectionsForText(prompt.text, prompt.sessionMentions ?? [], "#"),
- availableSessionMentions,
- ),
- ),
- [availableSessionMentions],
- );
- const queuedSessionHandles = useCallback(
- (prompt: QueuedPrompt): SessionHandle[] => uniqueMentions(
- validateSessionHandleSelections(
- prompt.text,
- prompt.sessionHandleSelections
- ?? tokenSelectionsForText(prompt.text, prompt.sessionHandles ?? [], "@"),
- availableSessionHandles,
- ),
- SESSION_HANDLES_LIMIT,
- ),
- [availableSessionHandles],
- );
- const queuedCapabilityMentions = useCallback((prompt: QueuedPrompt) => {
- const owners = normalizeAtMentionNamespaces(prompt.atMentionNamespaces, prompt.text);
- const effectiveCli = cliApps.filter((app) => {
- const name = app.name.toLowerCase();
- const owner = atMentionNamespace(owners, name);
- return owner === undefined || owner === "cli";
- });
- const effectiveMcp = mcpPresets.filter((preset) => {
- const name = preset.name.toLowerCase();
- const owner = atMentionNamespace(owners, name);
- return owner === undefined || owner === "mcp";
- });
- const cliMentions = new Map();
- const mcpMentions = new Map();
- for (const segment of splitCapabilityMentionSegments(
- prompt.text,
- effectiveCli,
- effectiveMcp,
- )) {
- if (segment.kind === "cli") {
- cliMentions.set(segment.app.name.toLowerCase(), cliAppMentionPayload(segment.app));
- } else if (segment.kind === "mcp") {
- mcpMentions.set(
- segment.preset.name.toLowerCase(),
- mcpPresetMentionPayload(segment.preset),
- );
- }
- }
- return {
- cliApps: [...cliMentions.values()],
- mcpPresets: [...mcpMentions.values()],
- };
- }, [cliApps, mcpPresets]);
-
const sendQueuedPrompt = useCallback(
(prompt: QueuedPrompt) => {
secondEnterPromptIdRef.current = null;
const text = prompt.text.trim();
const queuedImages = queuedImagesToSendImages(prompt.images);
- const sessionMentions = queuedSessionMentions(prompt);
- const sessionHandles = queuedSessionHandles(prompt);
- const capabilities = queuedCapabilityMentions(prompt);
setQueuedPrompts((items) => items.filter((item) => item.id !== prompt.id));
if (text || queuedImages?.length) {
const options: SendOptions | undefined = (
prompt.quotedContext
- || sessionMentions.length
- || sessionHandles.length
- || capabilities.cliApps.length
- || capabilities.mcpPresets.length
+ || prompt.sessionMentions?.length
|| isStreaming
)
? {
...(prompt.quotedContext ? { quotedContext: prompt.quotedContext } : {}),
- ...(sessionMentions.length
- ? { sessionMentions }
- : {}),
- ...(sessionHandles.length ? { sessionHandles } : {}),
- ...(capabilities.cliApps.length ? { cliApps: capabilities.cliApps } : {}),
- ...(capabilities.mcpPresets.length
- ? { mcpPresets: capabilities.mcpPresets }
+ ...(prompt.sessionMentions?.length
+ ? { sessionMentions: prompt.sessionMentions }
: {}),
...(isStreaming ? { continueActiveTurn: true } : {}),
}
@@ -2695,13 +1851,7 @@ export function ThreadComposer({
}
requestAnimationFrame(() => textareaRef.current?.focus());
},
- [
- isStreaming,
- onSend,
- queuedCapabilityMentions,
- queuedSessionHandles,
- queuedSessionMentions,
- ],
+ [isStreaming, onSend],
);
const sendNextQueuedPrompt = useCallback(() => {
@@ -2713,25 +1863,13 @@ export function ThreadComposer({
}
setQueuedPrompts((items) => items.filter((item) => item.id !== nextPrompt.id));
const queuedImages = queuedImagesToSendImages(nextPrompt.images);
- const sessionMentions = queuedSessionMentions(nextPrompt);
- const sessionHandles = queuedSessionHandles(nextPrompt);
- const capabilities = queuedCapabilityMentions(nextPrompt);
const options: SendOptions | undefined = (
- nextPrompt.quotedContext
- || sessionMentions.length
- || sessionHandles.length
- || capabilities.cliApps.length
- || capabilities.mcpPresets.length
+ nextPrompt.quotedContext || nextPrompt.sessionMentions?.length
)
? {
...(nextPrompt.quotedContext ? { quotedContext: nextPrompt.quotedContext } : {}),
- ...(sessionMentions.length
- ? { sessionMentions }
- : {}),
- ...(sessionHandles.length ? { sessionHandles } : {}),
- ...(capabilities.cliApps.length ? { cliApps: capabilities.cliApps } : {}),
- ...(capabilities.mcpPresets.length
- ? { mcpPresets: capabilities.mcpPresets }
+ ...(nextPrompt.sessionMentions?.length
+ ? { sessionMentions: nextPrompt.sessionMentions }
: {}),
}
: undefined;
@@ -2740,13 +1878,7 @@ export function ThreadComposer({
else if (options) onSend(nextPrompt.text.trim(), undefined, options);
else onSend(nextPrompt.text.trim());
requestAnimationFrame(() => textareaRef.current?.focus());
- }, [
- onSend,
- queuedCapabilityMentions,
- queuedSessionHandles,
- queuedPrompts,
- queuedSessionMentions,
- ]);
+ }, [onSend, queuedPrompts]);
useEffect(() => {
const wasStreaming = wasStreamingRef.current;
@@ -2800,7 +1932,6 @@ export function ThreadComposer({
attachedCliApps.length > 0
|| attachedMcpPresets.length > 0
|| activeSessionMentions.length > 0
- || ownedSessionHandles.length > 0
|| normalizedQuotedContext
? {
...(attachedCliApps.length > 0 ? { cliApps: attachedCliApps } : {}),
@@ -2808,9 +1939,6 @@ export function ThreadComposer({
...(activeSessionMentions.length > 0
? { sessionMentions: activeSessionMentions }
: {}),
- ...(ownedSessionHandles.length > 0
- ? { sessionHandles: ownedSessionHandles }
- : {}),
...(normalizedQuotedContext ? { quotedContext: normalizedQuotedContext } : {}),
}
: undefined;
@@ -2818,8 +1946,7 @@ export function ThreadComposer({
payload === undefined
&& attachedCliApps.length === 0
&& attachedMcpPresets.length === 0
- && activeSessionMentions.length === 0
- && ownedSessionHandles.length === 0;
+ && activeSessionMentions.length === 0;
const slashLifecycle = hasPlainTextCommandPayload
? slashCommandLifecycle(content, slashCommands)
: null;
@@ -2888,7 +2015,6 @@ export function ThreadComposer({
onStop,
onQuotedContextChange,
normalizedQuotedContext,
- ownedSessionHandles,
readyImages,
slashCommands,
textTooLargeMessage,
@@ -2896,7 +2022,6 @@ export function ThreadComposer({
]);
const onKeyDown = (e: ReactKeyboardEvent) => {
- if (e.nativeEvent.isComposing) return;
if (showCliAppMenu) {
if (e.key === "ArrowDown") {
e.preventDefault();
@@ -2945,7 +2070,7 @@ export function ThreadComposer({
return;
}
}
- if (e.key === "Enter" && !e.shiftKey) {
+ if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
e.preventDefault();
if (canQueueGuidance) {
if (!e.repeat) queueGuidancePrompt();
@@ -3075,7 +2200,6 @@ export function ThreadComposer({
>
{showSlashMenu ? (
{
secondEnterPromptIdRef.current = null;
- const nextValue = e.target.value;
- const nextStart = e.target.selectionStart ?? nextValue.length;
- const nextEnd = e.target.selectionEnd ?? nextStart;
- applyComposerTextEdit(
- nextValue,
- pendingInputEditRef.current ?? inputSelectionRef.current,
- { start: nextStart, end: nextEnd },
- );
+ setValue(e.target.value);
setSlashMenuDismissed(false);
setCliAppMenuDismissed(false);
- setCursorPosition(nextStart);
- }}
- onBeforeInput={(e) => {
- pendingInputEditRef.current = {
- start: e.currentTarget.selectionStart ?? 0,
- end: e.currentTarget.selectionEnd ?? e.currentTarget.selectionStart ?? 0,
- };
+ setCursorPosition(e.target.selectionStart ?? e.target.value.length);
}}
onBlur={() => {
secondEnterPromptIdRef.current = null;
}}
onInput={onInput}
onKeyDown={onKeyDown}
- onKeyUp={(e) => {
- const start = e.currentTarget.selectionStart ?? e.currentTarget.value.length;
- const end = e.currentTarget.selectionEnd ?? start;
- inputSelectionRef.current = { start, end };
- setCursorPosition(start);
- }}
- onSelect={(e) => {
- const start = e.currentTarget.selectionStart ?? e.currentTarget.value.length;
- const end = e.currentTarget.selectionEnd ?? start;
- inputSelectionRef.current = { start, end };
- setCursorPosition(start);
- }}
- onClick={(e) => {
- const start = e.currentTarget.selectionStart ?? e.currentTarget.value.length;
- const end = e.currentTarget.selectionEnd ?? start;
- inputSelectionRef.current = { start, end };
- setCursorPosition(start);
- }}
- onPaste={(e) => {
- pendingInputEditRef.current = {
- start: e.currentTarget.selectionStart ?? 0,
- end: e.currentTarget.selectionEnd ?? e.currentTarget.selectionStart ?? 0,
- };
- onPaste(e);
- if (e.defaultPrevented) pendingInputEditRef.current = null;
- }}
+ onKeyUp={(e) => setCursorPosition(e.currentTarget.selectionStart ?? e.currentTarget.value.length)}
+ onSelect={(e) => setCursorPosition(e.currentTarget.selectionStart ?? e.currentTarget.value.length)}
+ onClick={(e) => setCursorPosition(e.currentTarget.selectionStart ?? e.currentTarget.value.length)}
+ onPaste={onPaste}
rows={1}
placeholder={sessionDragPreview ? "" : resolvedPlaceholder}
disabled={interactionDisabled}
- role="combobox"
- aria-autocomplete="list"
- aria-expanded={showAnyPalette}
- aria-controls={
- showCliAppMenu
- ? mentionPaletteId
- : showSlashMenu
- ? slashPaletteId
- : undefined
- }
- aria-activedescendant={
- showCliAppMenu
- ? `${mentionPaletteId}-option-${selectedCliAppIndex}`
- : showSlashMenu
- ? `${slashPaletteId}-option-${selectedCommandIndex}`
- : undefined
- }
aria-label={inputAriaLabel ?? t("thread.composer.inputAria")}
className={cn(
inputTextClasses,
@@ -3671,7 +2742,7 @@ function ComposerCliMentionOverlay({
className,
ghostRange,
}: {
- segments: ComposerTokenSegment[];
+ segments: CapabilityMentionSegment[];
isHero: boolean;
className: string;
ghostRange?: { start: number; end: number } | null;
@@ -3698,19 +2769,11 @@ function ComposerCliMentionOverlay({
data-testid={isGhost ? "composer-session-drag-preview" : undefined}
className={cn(isGhost && "opacity-45 transition-opacity duration-100")}
>
- {segment.kind === "session" ? (
-
- ) : (
-
- )}
+
);
})}
@@ -3718,7 +2781,6 @@ function ComposerCliMentionOverlay({
);
}
interface SlashCommandPaletteProps {
- id: string;
commands: SlashPaletteCommand[];
selectedIndex: number;
layout: SlashPaletteLayout;
@@ -3728,7 +2790,6 @@ interface SlashCommandPaletteProps {
}
interface CliAppMentionPaletteProps {
- id: string;
candidates: MentionCandidate[];
selectedIndex: number;
layout: SlashPaletteLayout;
@@ -3755,7 +2816,6 @@ function useSelectedOptionScroll(selectedIndex: number) {
}
function CliAppMentionPalette({
- id,
candidates,
selectedIndex,
layout,
@@ -3769,16 +2829,14 @@ function CliAppMentionPalette({
layout.maxHeight - SLASH_PALETTE_CHROME_PX,
);
const listRef = useSelectedOptionScroll(selectedIndex);
- const groupedCandidates = (["handle", "cli", "mcp", "session"] as const)
+ const groupedCandidates = (["session", "cli", "mcp"] as const)
.map((kind) => ({
kind,
- label: kind === "handle"
+ label: kind === "session"
? t("thread.composer.mentions.sessionGroup")
- : kind === "session"
- ? t("thread.composer.mentions.sessionGroup")
- : kind === "cli"
- ? t("thread.composer.mentions.cliGroup")
- : t("thread.composer.mentions.mcpGroup"),
+ : kind === "cli"
+ ? t("thread.composer.mentions.cliGroup")
+ : t("thread.composer.mentions.mcpGroup"),
items: candidates
.map((candidate, index) => ({ candidate, index }))
.filter(({ candidate }) => candidate.kind === kind),
@@ -3786,7 +2844,6 @@ function CliAppMentionPalette({
.filter((group) => group.items.length > 0);
return (
onHover(index)}
onMouseDown={(e) => {
e.preventDefault();
@@ -3849,23 +2895,15 @@ function CliAppMentionPalette({
)}
>
- {candidate.kind === "handle" ? (
-
+
+
+ {candidate.displayName}
+
+
@{name}
- ) : (
-
-
- {candidate.displayName}
-
-
- {sigil}{name}
-
-
- )}
- {candidate.kind === "cli" || candidate.kind === "mcp" ? (
+
+ {candidate.kind !== "session" ? (
logoFallbackUrls(rawLogoUrl), [rawLogoUrl]);
const { logoUrl, onLogoError, onLogoLoad } = useLogoFallback(logoUrls);
- if (candidate.kind === "handle" || candidate.kind === "session") {
+ if (candidate.kind === "session") {
return (
- {candidate.kind === "handle"
- ?
- : }
+
);
}
@@ -3948,7 +2982,6 @@ function MentionCandidateLogo({
}
function SlashCommandPalette({
- id,
commands,
selectedIndex,
layout,
@@ -3964,7 +2997,6 @@ function SlashCommandPalette({
const listRef = useSelectedOptionScroll(selectedIndex);
return (
onHover(index)}
diff --git a/webui/src/components/thread/ThreadHeader.tsx b/webui/src/components/thread/ThreadHeader.tsx
index 53c39b56f..b4ca504be 100644
--- a/webui/src/components/thread/ThreadHeader.tsx
+++ b/webui/src/components/thread/ThreadHeader.tsx
@@ -3,7 +3,7 @@ import { type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
-import { SessionHandleHighlight } from "@/components/CliAppMentionText";
+import { SessionHandleLabel } from "@/components/SessionHandleLabel";
import {
Tooltip,
TooltipContent,
@@ -85,12 +85,11 @@ export function ThreadHeader({
) : null}
{handle ? (
-
+
@{handle.name}
-
+
) : null}
diff --git a/webui/src/components/thread/ThreadMessages.tsx b/webui/src/components/thread/ThreadMessages.tsx
index 82a189226..fcd516086 100644
--- a/webui/src/components/thread/ThreadMessages.tsx
+++ b/webui/src/components/thread/ThreadMessages.tsx
@@ -4,13 +4,7 @@ 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,
- SessionHandle,
- SlashCommand,
- UIMessage,
-} from "@/lib/types";
+import type { CliAppInfo, McpPresetInfo, SlashCommand, UIMessage } from "@/lib/types";
interface ThreadMessagesProps {
messages: UIMessage[];
@@ -24,7 +18,6 @@ interface ThreadMessagesProps {
cliApps?: CliAppInfo[];
mcpPresets?: McpPresetInfo[];
slashCommands?: SlashCommand[];
- sessionDirectory?: SessionHandle[];
forkBoundaryMessageCount?: number | null;
onOpenFilePreview?: (path: string) => void;
onForkFromMessage?: (beforeUserIndex: number) => void;
@@ -69,7 +62,6 @@ export function ThreadMessages({
cliApps = [],
mcpPresets = [],
slashCommands = [],
- sessionDirectory = [],
forkBoundaryMessageCount = null,
onOpenFilePreview,
onForkFromMessage,
@@ -167,7 +159,6 @@ export function ThreadMessages({
cliApps={cliApps}
mcpPresets={mcpPresets}
slashCommands={slashCommands}
- sessionDirectory={sessionDirectory}
onOpenFilePreview={onOpenFilePreview}
onForkFromMessage={onForkFromMessage}
/>
@@ -249,7 +240,6 @@ interface ThreadDisplayUnitProps {
cliApps: CliAppInfo[];
mcpPresets: McpPresetInfo[];
slashCommands: SlashCommand[];
- sessionDirectory: SessionHandle[];
onOpenFilePreview?: (path: string) => void;
onForkFromMessage?: (beforeUserIndex: number) => void;
}
@@ -268,7 +258,6 @@ const ThreadDisplayUnit = memo(function ThreadDisplayUnit({
cliApps,
mcpPresets,
slashCommands,
- sessionDirectory,
onOpenFilePreview,
onForkFromMessage,
}: ThreadDisplayUnitProps) {
@@ -307,7 +296,6 @@ const ThreadDisplayUnit = memo(function ThreadDisplayUnit({
cliApps={cliApps}
mcpPresets={mcpPresets}
slashCommands={slashCommands}
- sessionDirectory={sessionDirectory}
onOpenFilePreview={onOpenFilePreview}
onForkFromHere={forkIndex !== undefined ? onForkFromHere : undefined}
/>
@@ -336,7 +324,6 @@ 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 e98f0ae13..7a3d3a769 100644
--- a/webui/src/components/thread/ThreadShell.tsx
+++ b/webui/src/components/thread/ThreadShell.tsx
@@ -5,7 +5,7 @@ import { useTranslation } from "react-i18next";
import { FilePreviewAvailabilityProvider } from "@/components/FilePreviewAvailabilityContext";
import { FilePreviewPanel } from "@/components/FilePreviewPanel";
-import { SessionHandleHighlight } from "@/components/CliAppMentionText";
+import { SessionHandleLabel } from "@/components/SessionHandleLabel";
import { PromptNavigator } from "@/components/thread/PromptNavigator";
import { SessionInfoPopover } from "@/components/thread/SessionInfoPopover";
import { ThreadComposer } from "@/components/thread/ThreadComposer";
@@ -37,7 +37,6 @@ import type { CanonicalRunSnapshot, StreamError } from "@/lib/nanobot-client";
import { inferProviderFromModelName, providerDisplayLabel } from "@/lib/provider-brand";
import type {
ChatSummary,
- SessionHandle,
SettingsPayload,
SlashCommand,
SkillSummary,
@@ -639,28 +638,10 @@ export function ThreadShell({
const { t } = useTranslation();
const chatId = session?.chatId ?? null;
const historyKey = temporary ? null : session?.key ?? null;
- const referenceSessions = useMemo(
- () => sessions.filter((candidate) => (
- candidate.key !== historyKey
- && (
- workspaceScope?.access_mode !== "restricted"
- || candidate.workspaceScope?.project_path === workspaceScope.project_path
- )
- )),
- [historyKey, sessions, workspaceScope],
+ const mentionSessions = useMemo(
+ () => sessions.filter((candidate) => candidate.key !== historyKey),
+ [historyKey, sessions],
);
- 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,
@@ -1330,14 +1311,7 @@ export function ThreadShell({
setPendingFirstTargetChatId(newId);
return true;
},
- [
- booting,
- client,
- localModelPreset,
- onCreateChat,
- withWorkspaceScope,
- workspaceScope,
- ],
+ [booting, client, localModelPreset, onCreateChat, withWorkspaceScope, workspaceScope],
);
const handleThreadSend = useCallback(
@@ -1490,8 +1464,7 @@ export function ThreadShell({
slashCommands={availableSlashCommands}
cliApps={cliApps}
mcpPresets={mcpPresets}
- sessions={referenceSessions}
- handleSessions={handleSessions}
+ sessions={mentionSessions}
skills={skills}
onStop={stop}
onTranscribeAudio={transcribeAudio}
@@ -1538,8 +1511,7 @@ export function ThreadShell({
slashCommands={availableSlashCommands}
cliApps={cliApps}
mcpPresets={mcpPresets}
- sessions={referenceSessions}
- handleSessions={handleSessions}
+ sessions={mentionSessions}
skills={skills}
surfaceRef={composerSurfaceRef}
onTranscribeAudio={transcribeAudio}
@@ -1609,18 +1581,15 @@ export function ThreadShell({
{hideHeaderTitle && !temporary && session?.handle ? (
-
+
@{session.handle.name}
-
+
) : null}
@@ -1643,7 +1612,6 @@ export function ThreadShell({
showScrollToBottomButton={!!session}
cliApps={cliApps}
mcpPresets={mcpPresets}
- sessionDirectory={sessionDirectory}
slashCommands={availableSlashCommands}
forkBoundaryMessageCount={forkBoundaryMessageCount}
hasMoreBefore={hasMoreBefore}
diff --git a/webui/src/components/thread/ThreadViewport.tsx b/webui/src/components/thread/ThreadViewport.tsx
index 0ee035a5d..90a85842e 100644
--- a/webui/src/components/thread/ThreadViewport.tsx
+++ b/webui/src/components/thread/ThreadViewport.tsx
@@ -26,13 +26,7 @@ import {
promptTop,
} from "@/components/thread/promptNavigation";
import { cn } from "@/lib/utils";
-import type {
- CliAppInfo,
- McpPresetInfo,
- SessionHandle,
- SlashCommand,
- UIMessage,
-} from "@/lib/types";
+import type { CliAppInfo, McpPresetInfo, SlashCommand, UIMessage } from "@/lib/types";
export interface ThreadViewportHandle {
jumpToUserPrompt: (promptId: string) => void;
@@ -56,7 +50,6 @@ interface ThreadViewportProps {
cliApps?: CliAppInfo[];
mcpPresets?: McpPresetInfo[];
slashCommands?: SlashCommand[];
- sessionDirectory?: SessionHandle[];
forkBoundaryMessageCount?: number | null;
hasMoreBefore?: boolean;
loadingOlder?: boolean;
@@ -76,7 +69,6 @@ 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;
@@ -112,6 +104,11 @@ 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(
@@ -119,11 +116,6 @@ 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<
@@ -193,7 +185,6 @@ 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":
@@ -307,8 +281,6 @@ 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":
@@ -329,15 +301,10 @@ function activityDetail(items: GenericToolRunItem[], family: ToolFamily, name: s
}
}
-function activityAside(
- items: GenericToolRunItem[],
- family: ToolFamily,
- name: string,
-): string {
+function activityAside(items: GenericToolRunItem[], family: ToolFamily): 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 b1b08bd48..9a65e4343 100644
--- a/webui/src/globals.css
+++ b/webui/src/globals.css
@@ -33,14 +33,8 @@
--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;
+ --session-handle-lightness: 0.5;
+ --session-handle-chroma: 0.12;
--temporary-control-active: #ef8e30;
--temporary-accent: 24 95% 53%;
--temporary-foreground: 17 88% 32%;
@@ -89,14 +83,8 @@
--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;
+ --session-handle-lightness: 0.75;
+ --session-handle-chroma: 0.11;
--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 8b0b8d76f..0ee306f26 100644
--- a/webui/src/hooks/useNanobotStream.ts
+++ b/webui/src/hooks/useNanobotStream.ts
@@ -33,7 +33,6 @@ import type {
OutboundCliAppMention,
OutboundMcpPresetMention,
OutboundMedia,
- SessionHandle,
SessionMention,
GoalStateWsPayload,
MessageDeliveryStatus,
@@ -170,7 +169,6 @@ export interface SendOptions {
cliApps?: OutboundCliAppMention[];
mcpPresets?: OutboundMcpPresetMention[];
sessionMentions?: SessionMention[];
- sessionHandles?: SessionHandle[];
quotedContext?: string;
workspaceScope?: WorkspaceScopePayload | null;
sideChannel?: boolean;
@@ -190,7 +188,6 @@ 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");
@@ -221,31 +218,27 @@ function transitionTurnDelivery(
return changed ? next : messages;
}
-function appendLiveSessionMessage(
+function appendProjectedSessionInput(
messages: UIMessage[],
- event: Extract,
+ event: Extract,
): UIMessage[] {
- const messageId = event.session_message?.message_id?.trim();
- if (!messageId || event.session_message.direction !== "incoming") return messages;
+ const sessionMessage = event.provenance?.session_message;
+ const messageId = sessionMessage?.message_id?.trim();
+ if (!sessionMessage || !messageId) 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,
+ createdAt: typeof event.created_at_ms === "number"
+ && Number.isFinite(event.created_at_ms)
+ ? event.created_at_ms
+ : Date.now(),
+ sessionMessage,
...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),
- ];
+ return [...messages, row];
}
export function useNanobotStream(
@@ -675,18 +668,6 @@ 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.
@@ -752,6 +733,13 @@ export function useNanobotStream(
}
if (ev.event === "message_accepted") return;
if (ev.event === "user_message") {
+ if (ev.provenance?.session_message) {
+ flushPendingStreamEvents({ closeAnswerSegment: true });
+ clearActivitySegment();
+ setIsStreaming(true);
+ setMessages((prev) => appendProjectedSessionInput(prev, ev));
+ return;
+ }
setMessages((prev) => {
if (ev.turn_id && prev.some((message) => (
message.role === "user" && message.turnId === ev.turn_id
@@ -766,7 +754,10 @@ export function useNanobotStream(
turnPhase: "user",
turnSeq: 0,
deliveryStatus: "accepted",
- createdAt: Date.now(),
+ createdAt: typeof ev.created_at_ms === "number"
+ && Number.isFinite(ev.created_at_ms)
+ ? ev.created_at_ms
+ : Date.now(),
...(ev.media_urls?.length ? { media: ev.media_urls } : {}),
...(ev.cli_apps?.length ? { cliApps: ev.cli_apps } : {}),
...(ev.mcp_presets?.length ? { mcpPresets: ev.mcp_presets } : {}),
@@ -844,20 +835,12 @@ 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()));
@@ -1188,9 +1171,6 @@ 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 d295968c8..128a5d04c 100644
--- a/webui/src/i18n/locales/en/common.json
+++ b/webui/src/i18n/locales/en/common.json
@@ -1180,6 +1180,7 @@
"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",
@@ -1323,7 +1324,9 @@
"cliDescription": "Use @{{name}} as a local CLI app",
"mcpDescription": "Use @{{name}} as an MCP server",
"cliTitle": "CLI app: {{name}}",
- "mcpTitle": "MCP server: {{name}}"
+ "mcpTitle": "MCP server: {{name}}",
+ "sessionBadge": "Nanobot conversation",
+ "sessionDescription": "Reference @{{name}} as a previous chat"
},
"encoding": "Encoding…",
"remove": "Remove attachment",
diff --git a/webui/src/i18n/locales/es/common.json b/webui/src/i18n/locales/es/common.json
index da747b3cd..268d0344a 100644
--- a/webui/src/i18n/locales/es/common.json
+++ b/webui/src/i18n/locales/es/common.json
@@ -1167,6 +1167,7 @@
"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",
@@ -1326,7 +1327,9 @@
"cliDescription": "Usar @{{name}} como aplicación CLI local",
"mcpDescription": "Usar @{{name}} como servidor MCP",
"cliTitle": "Aplicación CLI: {{name}}",
- "mcpTitle": "Servidor MCP: {{name}}"
+ "mcpTitle": "Servidor MCP: {{name}}",
+ "sessionBadge": "Conversación de Nanobot",
+ "sessionDescription": "Referenciar @{{name}} como chat anterior"
},
"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 472225a08..30225cc60 100644
--- a/webui/src/i18n/locales/fr/common.json
+++ b/webui/src/i18n/locales/fr/common.json
@@ -1166,6 +1166,7 @@
"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",
@@ -1325,7 +1326,9 @@
"cliDescription": "Utiliser @{{name}} comme application CLI locale",
"mcpDescription": "Utiliser @{{name}} comme serveur MCP",
"cliTitle": "Application CLI : {{name}}",
- "mcpTitle": "Serveur MCP : {{name}}"
+ "mcpTitle": "Serveur MCP : {{name}}",
+ "sessionBadge": "Conversation Nanobot",
+ "sessionDescription": "Référencer @{{name}} comme discussion précédente"
},
"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 b96dc7a89..fe6636610 100644
--- a/webui/src/i18n/locales/id/common.json
+++ b/webui/src/i18n/locales/id/common.json
@@ -1166,6 +1166,7 @@
"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",
@@ -1325,7 +1326,9 @@
"cliDescription": "Gunakan @{{name}} sebagai aplikasi CLI lokal",
"mcpDescription": "Gunakan @{{name}} sebagai server MCP",
"cliTitle": "Aplikasi CLI: {{name}}",
- "mcpTitle": "Server MCP: {{name}}"
+ "mcpTitle": "Server MCP: {{name}}",
+ "sessionBadge": "Percakapan Nanobot",
+ "sessionDescription": "Referensikan @{{name}} sebagai chat sebelumnya"
},
"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 153c56884..a28c659cb 100644
--- a/webui/src/i18n/locales/ja/common.json
+++ b/webui/src/i18n/locales/ja/common.json
@@ -1166,6 +1166,7 @@
"placeholderStreaming": "モデルが応答しています…",
"inputAria": "メッセージ入力欄",
"sendHint": "Enter で送信 · Shift+Enter で改行",
+ "runRuntimeTitle": "実行中 · {{elapsed}}",
"goalStateStrip": "目標 · {{label}}",
"goalStateFallback": "目標",
"goalStateExpandAria": "目標の全文を表示",
@@ -1325,7 +1326,9 @@
"cliDescription": "@{{name}} をローカル CLI アプリとして使用",
"mcpDescription": "@{{name}} を MCP サーバーとして使用",
"cliTitle": "CLI アプリ: {{name}}",
- "mcpTitle": "MCP サーバー: {{name}}"
+ "mcpTitle": "MCP サーバー: {{name}}",
+ "sessionBadge": "Nanobot の会話",
+ "sessionDescription": "@{{name}} を過去のチャットとして参照"
},
"workspace": {
"accessAria": "ワークスペースのアクセスモード",
diff --git a/webui/src/i18n/locales/ko/common.json b/webui/src/i18n/locales/ko/common.json
index 65819920c..f3bbd31ad 100644
--- a/webui/src/i18n/locales/ko/common.json
+++ b/webui/src/i18n/locales/ko/common.json
@@ -1166,6 +1166,7 @@
"placeholderStreaming": "모델이 응답 중입니다…",
"inputAria": "메시지 입력",
"sendHint": "Enter로 전송 · Shift+Enter로 줄바꿈",
+ "runRuntimeTitle": "실행 중 · {{elapsed}}",
"goalStateStrip": "목표 · {{label}}",
"goalStateFallback": "목표",
"goalStateExpandAria": "전체 목표 보기",
@@ -1325,7 +1326,9 @@
"cliDescription": "@{{name}}을 로컬 CLI 앱으로 사용",
"mcpDescription": "@{{name}}을 MCP 서버로 사용",
"cliTitle": "CLI 앱: {{name}}",
- "mcpTitle": "MCP 서버: {{name}}"
+ "mcpTitle": "MCP 서버: {{name}}",
+ "sessionBadge": "Nanobot 대화",
+ "sessionDescription": "@{{name}}을 이전 채팅으로 참조"
},
"workspace": {
"accessAria": "작업공간 접근 모드",
diff --git a/webui/src/i18n/locales/pt-BR/common.json b/webui/src/i18n/locales/pt-BR/common.json
index 4bcca10e6..f4a2d7307 100644
--- a/webui/src/i18n/locales/pt-BR/common.json
+++ b/webui/src/i18n/locales/pt-BR/common.json
@@ -1180,6 +1180,7 @@
"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",
@@ -1323,7 +1324,9 @@
"cliDescription": "Usar @{{name}} como aplicativo CLI local",
"mcpDescription": "Usar @{{name}} como servidor MCP",
"cliTitle": "Aplicativo CLI: {{name}}",
- "mcpTitle": "Servidor MCP: {{name}}"
+ "mcpTitle": "Servidor MCP: {{name}}",
+ "sessionBadge": "Conversa do Nanobot",
+ "sessionDescription": "Referenciar @{{name}} como chat anterior"
},
"encoding": "Codificando…",
"remove": "Remover anexo",
diff --git a/webui/src/i18n/locales/vi/common.json b/webui/src/i18n/locales/vi/common.json
index 95412f257..50b2eafb7 100644
--- a/webui/src/i18n/locales/vi/common.json
+++ b/webui/src/i18n/locales/vi/common.json
@@ -1166,6 +1166,7 @@
"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",
@@ -1325,7 +1326,9 @@
"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}}"
+ "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"
},
"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 96647c018..4e334ee95 100644
--- a/webui/src/i18n/locales/zh-CN/common.json
+++ b/webui/src/i18n/locales/zh-CN/common.json
@@ -1180,6 +1180,7 @@
"placeholderStreaming": "模型正在回复…",
"inputAria": "消息输入框",
"sendHint": "Enter 发送 · Shift+Enter 换行",
+ "runRuntimeTitle": "运行中 · {{elapsed}}",
"goalStateStrip": "目标 · {{label}}",
"goalStateFallback": "目标",
"goalStateExpandAria": "查看完整目标",
@@ -1322,7 +1323,9 @@
"cliDescription": "使用 @{{name}} 调用本地 CLI",
"mcpDescription": "使用 @{{name}} 调用 MCP 服务",
"cliTitle": "CLI 应用:{{name}}",
- "mcpTitle": "MCP 服务:{{name}}"
+ "mcpTitle": "MCP 服务:{{name}}",
+ "sessionBadge": "Nanobot 对话",
+ "sessionDescription": "引用历史会话 @{{name}}"
},
"encoding": "处理中…",
"remove": "移除附件",
diff --git a/webui/src/i18n/locales/zh-TW/common.json b/webui/src/i18n/locales/zh-TW/common.json
index 7b9b2412a..7d39631de 100644
--- a/webui/src/i18n/locales/zh-TW/common.json
+++ b/webui/src/i18n/locales/zh-TW/common.json
@@ -1166,6 +1166,7 @@
"placeholderStreaming": "模型正在回覆…",
"inputAria": "訊息輸入框",
"sendHint": "Enter 送出 · Shift+Enter 換行",
+ "runRuntimeTitle": "執行中 · {{elapsed}}",
"goalStateStrip": "目標 · {{label}}",
"goalStateFallback": "目標",
"goalStateExpandAria": "檢視完整目標",
@@ -1325,7 +1326,9 @@
"cliDescription": "將 @{{name}} 作為本機 CLI 應用程式使用",
"mcpDescription": "將 @{{name}} 作為 MCP 伺服器使用",
"cliTitle": "CLI 應用程式:{{name}}",
- "mcpTitle": "MCP 伺服器:{{name}}"
+ "mcpTitle": "MCP 伺服器:{{name}}",
+ "sessionBadge": "Nanobot 對話",
+ "sessionDescription": "引用先前的對話 @{{name}}"
},
"workspace": {
"accessAria": "工作區存取模式",
diff --git a/webui/src/lib/api.ts b/webui/src/lib/api.ts
index 982d4c891..62a754d2a 100644
--- a/webui/src/lib/api.ts
+++ b/webui/src/lib/api.ts
@@ -23,7 +23,7 @@ import type {
ProviderOAuthLoginResult,
ProviderSettingsUpdate,
SessionDeleteResult,
- SessionListHandle,
+ SessionHandle,
SessionAutomationsPayload,
SettingsPayload,
SettingsUpdate,
@@ -167,20 +167,17 @@ 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 {
+function normalizeSessionHandle(value: unknown): SessionHandle | null {
if (!value || typeof value !== "object") return null;
- const handle = value as Partial;
+ 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 };
+ return { id, name };
}
export async function listSessions(
@@ -196,7 +193,7 @@ export async function listSessions(
model_preset?: string | null;
run_started_at?: number | null;
workspace_scope?: WorkspaceScopePayload | null;
- handle?: SessionListHandle | null;
+ handle?: SessionHandle | null;
};
const body = await request<{ sessions: Row[] }>(
`${base}/api/sessions`,
@@ -205,8 +202,7 @@ export async function listSessions(
API_READ_TIMEOUT_MS,
);
return body.sessions.map((s) => {
- const rawSession = normalizeSessionListHandle(s.handle);
- const handle = rawSession ? { ...rawSession, session_key: s.key } : null;
+ const handle = normalizeSessionHandle(s.handle);
return {
key: s.key,
...splitKey(s.key),
diff --git a/webui/src/lib/nanobot-client.ts b/webui/src/lib/nanobot-client.ts
index 888f30c26..75e221681 100644
--- a/webui/src/lib/nanobot-client.ts
+++ b/webui/src/lib/nanobot-client.ts
@@ -5,7 +5,6 @@ import type {
OutboundCliAppMention,
OutboundMcpPresetMention,
OutboundMedia,
- SessionHandle,
SessionMention,
SidebarStatePayload,
GoalStateWsPayload,
@@ -196,7 +195,7 @@ export class NanobotClient {
private knownChats = new Set();
/** Temporary chats are connection-owned and intentionally not reattached. */
private temporaryChatIds = new Set();
- /** Per-chat run projection, started optimistically and reconciled by lifecycle events. */
+ /** Wall-clock run strip: updated from ``goal_status`` even with no ``onChat`` subscriber. */
private runStartedAtByChatId = new Map();
/** Per-turn clocks let a rejected newer turn fall back without borrowing its timer. */
private runStartedAtByTurnKey = new Map();
@@ -538,14 +537,6 @@ 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);
@@ -725,7 +716,7 @@ export class NanobotClient {
}
}
- private recordRunStatus(chatId: string, ev: InboundEvent): void {
+ private recordGoalStatusForRunStrip(chatId: string, ev: InboundEvent): void {
if (ev.event === "turn_end") {
this.recordRunCompletion(chatId, ev.turn_id);
return;
@@ -976,7 +967,6 @@ export class NanobotClient {
cliApps?: OutboundCliAppMention[];
mcpPresets?: OutboundMcpPresetMention[];
sessionMentions?: SessionMention[];
- sessionHandles?: SessionHandle[];
quotedContext?: string;
workspaceScope?: WorkspaceScopePayload | null;
turnId?: string;
@@ -996,9 +986,6 @@ 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 } : {}),
@@ -1017,10 +1004,7 @@ export class NanobotClient {
}
if (options?.turnId && !isSystemCommandTurnId(options.turnId)) {
const startsNewRun = options.startsNewRun !== false;
- if (startsNewRun) {
- this.advanceRunGeneration(chatId, options.turnId);
- this.startRunLocally(chatId, options.turnId);
- }
+ if (startsNewRun) this.advanceRunGeneration(chatId, options.turnId);
this.trackPendingMessageSend(chatId, options.turnId, startsNewRun);
}
this.queueSend(frame);
@@ -1256,7 +1240,7 @@ export class NanobotClient {
if (chatId) {
if (this.isCanonicalCompletedTurnEvent(chatId, parsed)) return;
const supersededRunCompletion = this.isSupersededRunCompletion(chatId, parsed);
- this.recordRunStatus(chatId, parsed);
+ this.recordGoalStatusForRunStrip(chatId, parsed);
if (supersededRunCompletion) return;
this.recordGoalStateSnapshot(chatId, parsed);
this.dispatch(chatId, parsed);
diff --git a/webui/src/lib/session-handle.ts b/webui/src/lib/session-handle.ts
new file mode 100644
index 000000000..1190eaf2e
--- /dev/null
+++ b/webui/src/lib/session-handle.ts
@@ -0,0 +1,9 @@
+export function sessionHandleColor(handleId: string): string {
+ let hash = 2166136261;
+ for (const char of handleId) {
+ hash ^= char.codePointAt(0) ?? 0;
+ hash = Math.imul(hash, 16777619);
+ }
+ const hue = (hash >>> 0) % 360;
+ return `oklch(var(--session-handle-lightness) var(--session-handle-chroma) ${hue})`;
+}
diff --git a/webui/src/lib/types.ts b/webui/src/lib/types.ts
index f6b83449c..0ca9ce4af 100644
--- a/webui/src/lib/types.ts
+++ b/webui/src/lib/types.ts
@@ -66,8 +66,6 @@ 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. */
@@ -114,29 +112,24 @@ export interface UIMcpPresetAttachment {
}
export interface SessionMention {
- /** Text token inserted in the composer, without the leading #. */
+ /** Stable public identity. Older transcript rows may not include it. */
+ id?: string;
+ /** 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 {
+/** Stable public handle returned by the session-list endpoint. */
+export interface SessionHandle {
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;
+ session: SessionHandle;
}
export interface SessionAutomationJob {
@@ -1249,10 +1242,12 @@ export type InboundEvent =
active_turn_id?: string;
starts_turn: boolean;
started_at?: number;
+ created_at_ms?: number;
media_urls?: UIMediaAttachment[];
cli_apps?: UICliAppAttachment[];
mcp_presets?: UIMcpPresetAttachment[];
session_mentions?: SessionMention[];
+ provenance?: { session_message?: UISessionMessage };
}
| ({
event: "message";
@@ -1272,13 +1267,6 @@ 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;
@@ -1473,7 +1461,6 @@ 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 d37fe4507..0afeee0da 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 title-free handle handles", async () => {
+ it("maps generated session titles from the sessions list", async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({
@@ -1062,9 +1062,8 @@ describe("webui API helpers", () => {
model_preset: "fast",
run_started_at: 1_700_000_000,
handle: {
- id: "handle_1234567890abcdef1234567890abcdef",
- name: "webui-review",
- color_slot: 5,
+ id: "handle_0123456789abcdef0123456789abcdef",
+ name: "mira-0123456789",
},
},
],
@@ -1079,38 +1078,13 @@ describe("webui API helpers", () => {
modelPreset: "fast",
runStartedAt: 1_700_000_000,
handle: {
- id: "handle_1234567890abcdef1234567890abcdef",
- name: "webui-review",
- color_slot: 5,
- session_key: "websocket:chat-1",
+ id: "handle_0123456789abcdef0123456789abcdef",
+ name: "mira-0123456789",
},
},
]);
});
- 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 9d8fccd76..501691f07 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("combobox", { name: "Message input" }), {
+ fireEvent.change(screen.getByRole("textbox", { 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("combobox", {
+ const paneInput = within(activeComposer).getByRole("textbox", {
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 730cf3741..10ece9c1b 100644
--- a/webui/src/tests/chat-list.test.tsx
+++ b/webui/src/tests/chat-list.test.tsx
@@ -66,104 +66,6 @@ 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");
- const activeTrack = activeButton.querySelector("[data-sidebar-selection-track]");
- expect(activeTrack)
+ expect(activeButton.querySelector("[data-sidebar-selection-track]"))
.toHaveClass("origin-left", "scale-x-100", "transition-transform");
- expect(activeTrack?.getAttribute("style")).toContain("currentcolor");
+ expect(activeButton.querySelector("[data-sidebar-selection-track]"))
+ .toHaveStyle({ backgroundColor: "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"],
@@ -41,60 +40,6 @@ 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 38c95e058..6252025a8 100644
--- a/webui/src/tests/markdown-text-renderer.test.tsx
+++ b/webui/src/tests/markdown-text-renderer.test.tsx
@@ -28,53 +28,6 @@ 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 6d9f5939c..ac03c000d 100644
--- a/webui/src/tests/message-bubble.test.tsx
+++ b/webui/src/tests/message-bubble.test.tsx
@@ -2,7 +2,6 @@ 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,
@@ -114,6 +113,28 @@ describe("MessageBubble", () => {
expect(screen.queryByRole("button", { name: "Fork" })).not.toBeInTheDocument();
});
+ it("renders cross-session input with its public handle", () => {
+ const message: UIMessage = {
+ id: "session-message:message-1",
+ role: "user",
+ content: "Please review this.",
+ createdAt: 1_700_000_000_123,
+ sessionMessage: {
+ message_id: "message-1",
+ session: {
+ id: "handle_0123456789abcdef0123456789abcdef",
+ name: "mira-0123456789",
+ },
+ },
+ };
+
+ const { container } = render();
+
+ expect(container.querySelector("[data-session-message]")).toBeInTheDocument();
+ expect(screen.getByText("@mira-0123456789")).toBeInTheDocument();
+ expect(screen.getByText("Please review this.")).toBeInTheDocument();
+ });
+
it("outlines temporary-chat user messages with a short dashed border", () => {
const message: UIMessage = {
id: "u-temporary",
@@ -594,11 +615,11 @@ describe("MessageBubble", () => {
expect(screen.getByTestId("message-mcp-mention-logo-browserbase")).toBeInTheDocument();
});
- it("renders new # session references as links", () => {
+ it("renders persisted session mentions inside sent user messages", () => {
const message: UIMessage = {
id: "u-session",
role: "user",
- content: "Use #收费设计",
+ content: "Use @收费设计 as context",
createdAt: Date.now(),
sessionMentions: [{
name: "收费设计",
@@ -609,113 +630,13 @@ describe("MessageBubble", () => {
render();
- const token = screen.getByTestId("message-session-reference-收费设计");
- expect(token).toHaveTextContent("#收费设计");
+ const token = screen.getByTestId("message-session-mention-收费设计");
+ expect(token).toHaveTextContent("@收费设计");
expect(token).toHaveAttribute("title", "Session: 收费设计");
expect(token.closest("a")).toHaveAttribute("href", "#/chat/websocket%3Apricing");
- });
-
- 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(
- ,
+ expect(token.closest("a")?.getAttribute("style")).toContain(
+ "text-decoration-color: var(--inline-token-highlight)",
);
-
- 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 4b42ce62f..0112d73fb 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 canonical run status without an onChat subscriber", () => {
+ it("records goal_status run strip without an onChat subscriber", () => {
const client = new NanobotClient({
url: "ws://test",
reconnect: false,
@@ -527,50 +527,7 @@ describe("NanobotClient", () => {
expect(client.getRunStartedAt("chat-strip")).toBeNull();
});
- 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", () => {
+ it("clears the local run strip immediately when a stop is requested", () => {
const client = new NanobotClient({
url: "ws://test",
reconnect: false,
@@ -595,7 +552,7 @@ describe("NanobotClient", () => {
expect(handler).toHaveBeenLastCalledWith("chat-stop", null);
});
- it("clears stale run status when reconnecting after a dropped socket", async () => {
+ it("clears stale run strip when reconnecting after a dropped socket", async () => {
const client = new NanobotClient({
url: "ws://test",
reconnect: true,
@@ -621,7 +578,7 @@ describe("NanobotClient", () => {
expect(FakeSocket.instances.length).toBeGreaterThan(1);
});
- it("clears run status when a turn_end arrives without idle", () => {
+ it("clears run strip when a turn_end arrives without idle", () => {
const client = new NanobotClient({
url: "ws://test",
reconnect: false,
@@ -771,7 +728,6 @@ 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", () => {
@@ -2106,7 +2062,7 @@ describe("NanobotClient", () => {
);
});
- it("keeps session references and handle mentions separate on the wire", () => {
+ it("includes session mentions in outbound messages", () => {
const client = new NanobotClient({
url: "ws://test",
reconnect: false,
@@ -2115,35 +2071,23 @@ describe("NanobotClient", () => {
client.connect();
lastSocket().fakeOpen();
- client.sendMessage("chat-current", "Use #pricing and ask @mira", undefined, {
+ client.sendMessage("chat-current", "Use @pricing", 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 and ask @mira",
+ content: "Use @pricing",
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 15af878ca..7451e75b3 100644
--- a/webui/src/tests/thread-composer.test.tsx
+++ b/webui/src/tests/thread-composer.test.tsx
@@ -127,14 +127,15 @@ const MCP_PRESETS: McpPresetInfo[] = [
},
];
-function session(
- chatId: string,
- title: string,
- preview = "",
- mentionName = title,
-): ChatSummary {
+function session(chatId: string, title: string, preview = ""): ChatSummary {
+ const key = `websocket:${chatId}`;
+ const handleId = Array.from(chatId)
+ .map((character) => character.codePointAt(0)?.toString(16).padStart(4, "0") ?? "0000")
+ .join("")
+ .padEnd(32, "0")
+ .slice(0, 32);
return {
- key: `websocket:${chatId}`,
+ key,
channel: "websocket",
chatId,
createdAt: null,
@@ -142,10 +143,8 @@ function session(
title,
preview,
handle: {
- id: `handle_${chatId}`,
- name: mentionName,
- color_slot: 2,
- session_key: `websocket:${chatId}`,
+ id: `handle_${handleId}`,
+ name: title,
},
};
}
@@ -1733,32 +1732,32 @@ describe("ThreadComposer", () => {
const input = screen.getByLabelText("Message input");
fireEvent.change(input, {
- target: { value: "普通文字 #收费设计", selectionStart: 10 },
+ target: { value: "普通文字 @收费设计", selectionStart: 10 },
});
- expect(screen.queryByTestId("composer-session-reference-收费设计")).not.toBeInTheDocument();
+ expect(screen.queryByTestId("composer-session-mention-收费设计")).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-reference-收费设计");
- expect(mention).toHaveTextContent("#收费设计");
+ expect(input).toHaveValue("参考 @收费设计 ");
+ const mention = screen.getByTestId("composer-session-mention-收费设计");
+ 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: [{
+ id: session("pricing", "收费设计").handle?.id,
name: "收费设计",
session_key: "websocket:pricing",
title: "收费设计",
@@ -1766,198 +1765,6 @@ 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(
@@ -1986,7 +1793,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();
@@ -1996,14 +1803,15 @@ 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-reference-收费设计"))
- .toHaveTextContent("#收费设计");
+ expect(screen.getByTestId("composer-session-mention-收费设计"))
+ .toHaveTextContent("@收费设计");
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
- expect(onSend).toHaveBeenCalledWith("Compare #收费设计 notes", undefined, {
+ expect(onSend).toHaveBeenCalledWith("Compare @收费设计 notes", undefined, {
sessionMentions: [{
+ id: session("pricing", "收费设计").handle?.id,
name: "收费设计",
session_key: "websocket:pricing",
title: "收费设计",
@@ -2033,154 +1841,6 @@ describe("ThreadComposer", () => {
expect(screen.queryByTestId("composer-session-drag-preview")).not.toBeInTheDocument();
});
- 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(
- ,
- );
-
- const input = screen.getByLabelText("Message input");
- fireEvent.change(input, { target: { value: "@", selectionStart: 1 } });
-
- const palette = screen.getByRole("listbox", { name: "Mentions" });
- expect(within(palette).getAllByRole("group").map((group) => (
- group.getAttribute("aria-label")
- ))).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$/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", () => {
const onSend = vi.fn();
render(
@@ -2196,21 +1856,11 @@ 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}`;
- input.setSelectionRange(input.value.length, input.value.length);
- fireEvent.select(input);
+ const value = `${input.value}${input.value ? " " : ""}@Topic${index}`;
fireEvent.change(input, { target: { value, selectionStart: value.length } });
fireEvent.keyDown(input, { key: "Tab" });
}
- 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);
+ const replacement = `${input.value.replace("@Topic0 ", "")} @Topic8`;
fireEvent.change(input, {
target: { value: replacement, selectionStart: replacement.length },
});
@@ -2224,7 +1874,7 @@ describe("ThreadComposer", () => {
))).toEqual(expect.arrayContaining(["websocket:topic-8"]));
});
- it("keeps a selected handle mention when queuing guidance for the active turn", () => {
+ it("keeps a selected session stable across refreshes and queued guidance", () => {
const onSend = vi.fn();
const target = session("z-target", "Plan", "Original plan");
const { rerender } = render(
@@ -2233,7 +1883,7 @@ describe("ThreadComposer", () => {
onStop={vi.fn()}
isStreaming
placeholder="Type your message..."
- handleSessions={[target]}
+ sessions={[target]}
/>,
);
@@ -2247,26 +1897,23 @@ describe("ThreadComposer", () => {
onStop={vi.fn()}
isStreaming
placeholder="Type your message..."
- handleSessions={[
+ sessions={[
{ ...target, title: "Renamed plan" },
- session("a-new", "Another title", target.preview, "Other"),
+ session("a-new", "Plan", target.preview),
]}
/>,
);
- 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();
+ expect(screen.getByTestId("composer-session-mention-Plan")).toHaveTextContent("@Plan");
fireEvent.keyDown(input, { key: "Enter" });
+ fireEvent.click(screen.getByRole("button", { name: "Guide" }));
expect(onSend).toHaveBeenCalledWith("@Plan", undefined, {
- sessionHandles: [{
- id: "handle_z-target",
+ sessionMentions: [{
+ id: session("z-target", "Plan").handle?.id,
name: "Plan",
session_key: "websocket:z-target",
- color_slot: 2,
+ title: "Plan",
}],
continueActiveTurn: true,
});
@@ -2325,49 +1972,6 @@ 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(
@@ -3469,7 +3041,7 @@ describe("ThreadComposer", () => {
expect(await screen.findByText("do not persist this")).toBeInTheDocument();
expect(
window.localStorage.getItem(
- "nanobot.webui.composerQueuedGuidance.v2:temporary-private",
+ "nanobot.webui.composerQueuedGuidance.v1:temporary-private",
),
).toBeNull();
@@ -3489,7 +3061,7 @@ describe("ThreadComposer", () => {
});
expect(
window.localStorage.getItem(
- "nanobot.webui.composerQueuedGuidance.v2:temporary-private",
+ "nanobot.webui.composerQueuedGuidance.v1:temporary-private",
),
).toBeNull();
});
diff --git a/webui/src/tests/thread-shell.test.tsx b/webui/src/tests/thread-shell.test.tsx
index 983d349e9..41a712426 100644
--- a/webui/src/tests/thread-shell.test.tsx
+++ b/webui/src/tests/thread-shell.test.tsx
@@ -21,7 +21,6 @@ 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();
@@ -109,13 +108,6 @@ 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);
@@ -425,164 +417,6 @@ 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();
@@ -953,7 +787,7 @@ describe("ThreadShell", () => {
fireEvent.click(badge);
expect(onOpenModelSettings).toHaveBeenCalledTimes(1);
- fireEvent.change(screen.getByRole("combobox", { name: "Message input" }), {
+ fireEvent.change(screen.getByRole("textbox", { name: "Message input" }), {
target: { value: "hello" },
});
fireEvent.click(screen.getByRole("button", { name: "Configure model" }));
@@ -1109,39 +943,6 @@ 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(
@@ -2251,7 +2052,7 @@ describe("ThreadShell", () => {
);
await waitFor(() => expect(historyCalls).toBe(1));
- const input = screen.getByRole("combobox", { name: "Message input" });
+ const input = screen.getByRole("textbox", { 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());
@@ -2488,7 +2289,7 @@ describe("ThreadShell", () => {
act(() => client._emitSessionUpdate("chat-version-a"));
await waitFor(() => expect(chatACalls).toBe(2));
- fireEvent.change(screen.getByRole("combobox", { name: "Message input" }), {
+ fireEvent.change(screen.getByRole("textbox", { name: "Message input" }), {
target: { value: "new question" },
});
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
@@ -2589,7 +2390,7 @@ describe("ThreadShell", () => {
turn_id: newTurnId,
});
});
- const input = screen.getByRole("combobox", { name: "Message input" });
+ const input = screen.getByRole("textbox", { name: "Message input" });
fireEvent.change(input, { target: { value: "queued for the new run" } });
fireEvent.keyDown(input, { key: "Enter" });
expect(client.sendMessage).not.toHaveBeenCalled();
@@ -2888,7 +2689,7 @@ describe("ThreadShell", () => {
turn_id: turnId,
});
});
- const input = screen.getByRole("combobox", { name: "Message input" });
+ const input = screen.getByRole("textbox", { name: "Message input" });
fireEvent.change(input, { target: { value: "queued guidance" } });
fireEvent.keyDown(input, { key: "Enter" });
expect(client.sendMessage).not.toHaveBeenCalled();
@@ -2991,7 +2792,7 @@ describe("ThreadShell", () => {
});
});
await waitFor(() => expect(screen.getByText("partial answer")).toBeInTheDocument());
- const input = screen.getByRole("combobox", { name: "Message input" });
+ const input = screen.getByRole("textbox", { name: "Message input" });
fireEvent.change(input, { target: { value: "queued guidance" } });
fireEvent.keyDown(input, { key: "Enter" });
expect(screen.getByText("queued guidance")).toBeInTheDocument();
@@ -3087,7 +2888,7 @@ describe("ThreadShell", () => {
});
await waitFor(() => expect(screen.getByText("Continuing the search.")).toBeInTheDocument());
- const input = screen.getByRole("combobox", { name: "Message input" });
+ const input = screen.getByRole("textbox", { name: "Message input" });
fireEvent.change(input, { target: { value: "How is it going?" } });
fireEvent.keyDown(input, { key: "Enter" });
fireEvent.keyDown(input, { key: "Enter" });
@@ -4129,56 +3930,50 @@ describe("ThreadShell", () => {
);
});
- 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",
- },
- };
+ it("offers sessions across projects 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,
+ handle: {
+ id: "handle_11111111111111111111111111111111",
+ name: "same-1111111111",
+ },
+ };
+ const otherProject = {
+ ...session("other-project"),
+ title: "Other project",
+ workspaceScope: {
+ project_path: "/projects/other",
+ access_mode: "restricted" as const,
+ },
+ handle: {
+ id: "handle_22222222222222222222222222222222",
+ name: "other-2222222222",
+ },
+ };
- 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.getByRole("option", { name: /Other project/i })).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 3381a98f5..e5bdbe05c 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 unmanaged reasoning disclosure anchored for pointer and keyboard toggles", () => {
+ it("keeps 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 3a9972dfc..26d71768b 100644
--- a/webui/src/tests/useNanobotStream.test.tsx
+++ b/webui/src/tests/useNanobotStream.test.tsx
@@ -38,8 +38,6 @@ const SEMANTIC_MESSAGE_FIELDS = [
"cliApps",
"mcpPresets",
"sessionMentions",
- "sessionHandles",
- "handle",
"reasoning",
"latencyMs",
"source",
@@ -72,7 +70,6 @@ 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();
@@ -116,13 +113,6 @@ 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;
@@ -164,11 +154,6 @@ 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);
},
@@ -197,101 +182,6 @@ 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");
@@ -2029,6 +1919,44 @@ describe("useNanobotStream", () => {
expect(result.current.runStartedAt).toBe(1_700_000_000);
});
+ it("projects a cross-session input with its public handle exactly once", () => {
+ const fake = fakeClient();
+ const { result } = renderHook(
+ () => useNanobotStream("chat-target", EMPTY_MESSAGES),
+ { wrapper: wrap(fake.client) },
+ );
+ const event: InboundEvent = {
+ event: "user_message",
+ chat_id: "chat-target",
+ text: "Please review this.",
+ created_at_ms: 1_700_000_000_123,
+ starts_turn: false,
+ provenance: {
+ session_message: {
+ message_id: "message-1",
+ session: {
+ id: "handle_0123456789abcdef0123456789abcdef",
+ name: "mira-0123456789",
+ },
+ },
+ },
+ };
+
+ act(() => {
+ fake.emit("chat-target", event);
+ fake.emit("chat-target", event);
+ });
+
+ expect(result.current.messages).toEqual([expect.objectContaining({
+ id: "session-message:message-1",
+ role: "user",
+ content: "Please review this.",
+ createdAt: 1_700_000_000_123,
+ sessionMessage: event.provenance?.session_message,
+ })]);
+ expect(result.current.isStreaming).toBe(true);
+ });
+
it("marks only the optimistic turn named by a correlated rejection as failed", () => {
const fake = fakeClient();
const { result } = renderHook(
@@ -2975,28 +2903,6 @@ 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(