mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-09-01 16:51:53 +03:00
feat(webui): add lightweight session messaging via mentions
This commit is contained in:
+15
-2
@@ -2331,6 +2331,18 @@ function Shell({
|
||||
.map((key) => byKey.get(key))
|
||||
.filter((session): session is ChatSummary => session !== undefined);
|
||||
}, [activeTabKey, activeTabState, orderedWorkbenchTabsByKey, sessions]);
|
||||
const collaborationSessions = useMemo(() => {
|
||||
const nearby = workbenchPaneSessions.length > 0
|
||||
? workbenchPaneSessions
|
||||
: activeSession
|
||||
? [activeSession]
|
||||
: [];
|
||||
const nearbyKeys = new Set(nearby.map((session) => session.key));
|
||||
return [
|
||||
...nearby,
|
||||
...sessions.filter((session) => !nearbyKeys.has(session.key)),
|
||||
];
|
||||
}, [activeSession, sessions, workbenchPaneSessions]);
|
||||
const paneChromeEnabled = Boolean(
|
||||
activeKey && activeSession && !temporaryChatActive && activeTabState,
|
||||
);
|
||||
@@ -2379,6 +2391,7 @@ function Shell({
|
||||
key: session.key,
|
||||
chatId: session.chatId,
|
||||
title: titleForSession(session),
|
||||
handle: session.handle,
|
||||
}));
|
||||
return [presentation.rowKey, {
|
||||
tabKey: orderedTab.tabKey,
|
||||
@@ -2746,7 +2759,7 @@ function Shell({
|
||||
return (
|
||||
<ThreadShell
|
||||
session={activeSession}
|
||||
sessions={sessions}
|
||||
sessions={collaborationSessions}
|
||||
title={headerTitle}
|
||||
temporary={temporaryChatRequested}
|
||||
temporaryChatIds={temporaryChatIds}
|
||||
@@ -2791,7 +2804,7 @@ function Shell({
|
||||
return (
|
||||
<ThreadShell
|
||||
session={paneSession}
|
||||
sessions={sessions}
|
||||
sessions={collaborationSessions}
|
||||
title={pane.title}
|
||||
onToggleSidebar={toggleSidebar}
|
||||
onNewChat={onNewChat}
|
||||
|
||||
@@ -50,6 +50,7 @@ import {
|
||||
} from "@/components/ui/tooltip";
|
||||
import { MAX_WORKBENCH_PANES } from "@/components/workbench/workbench-model";
|
||||
import { SIDEBAR_SELECTION_ITEM_CLASS } from "@/components/SidebarSelectionHighlight";
|
||||
import { SessionHandleHighlight, sessionHandleColor } from "@/components/CliAppMentionText";
|
||||
import { deriveTitle, relativeTime, visibleSessionPreview } from "@/lib/format";
|
||||
import {
|
||||
COLLAPSED_CHATS_VISIBLE_COUNT,
|
||||
@@ -103,8 +104,10 @@ function SidebarItemTooltip({
|
||||
|
||||
function SidebarSelectionTrack({
|
||||
active,
|
||||
handle,
|
||||
}: {
|
||||
active: boolean;
|
||||
handle: ChatSummary["handle"];
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
@@ -112,14 +115,31 @@ function SidebarSelectionTrack({
|
||||
data-active={active ? "true" : "false"}
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"pointer-events-none absolute inset-x-0 bottom-0 h-0.5 origin-left rounded-full bg-current",
|
||||
"pointer-events-none absolute inset-x-0 bottom-0 h-0.5 origin-left rounded-full",
|
||||
"transition-transform duration-200 ease-out motion-reduce:transition-none",
|
||||
active ? "scale-x-100" : "scale-x-0",
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: handle ? sessionHandleColor(handle.color_slot) : "currentColor",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarSessionHandle({ handle }: { handle: ChatSummary["handle"] }) {
|
||||
if (!handle) return null;
|
||||
return (
|
||||
<span
|
||||
data-sidebar-handle-handle
|
||||
className="flex max-w-20 shrink-0 items-center overflow-hidden whitespace-nowrap text-[11px] font-medium leading-5"
|
||||
>
|
||||
<SessionHandleHighlight handle={handle}>
|
||||
@{handle.name}
|
||||
</SessionHandleHighlight>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function readCollapsedPaneGroups(): Set<string> {
|
||||
try {
|
||||
const value = JSON.parse(window.localStorage.getItem(
|
||||
@@ -160,6 +180,7 @@ export interface SidebarPaneGroup {
|
||||
key: string;
|
||||
chatId: string;
|
||||
title: string;
|
||||
handle?: ChatSummary["handle"];
|
||||
}>;
|
||||
}
|
||||
|
||||
@@ -965,27 +986,29 @@ export const ChatList = memo(function ChatList({
|
||||
partial={tabPartiallySelected}
|
||||
/>
|
||||
) : null}
|
||||
<span className="min-w-0 flex-1 overflow-hidden">
|
||||
{projectMode ? (
|
||||
<span className="relative flex w-full min-w-0 items-baseline gap-2">
|
||||
<span className="min-w-0 flex-1 truncate font-medium leading-5">
|
||||
{title}
|
||||
</span>
|
||||
{isPinned ? <PinnedChatIndicator /> : null}
|
||||
<span className="min-w-0 flex-1 overflow-hidden">
|
||||
{projectMode ? (
|
||||
<span className="relative flex w-full min-w-0 items-baseline gap-2">
|
||||
<SidebarSessionHandle handle={s.handle} />
|
||||
<span className="min-w-0 flex-1 truncate font-medium leading-5">
|
||||
{title}
|
||||
</span>
|
||||
{isPinned ? <PinnedChatIndicator /> : null}
|
||||
{timestamp ? (
|
||||
<span className="shrink-0 text-[11.5px] font-medium text-muted-foreground/58">
|
||||
{timestamp}
|
||||
</span>
|
||||
) : null}
|
||||
<SidebarSelectionTrack active={topicActive} />
|
||||
<SidebarSelectionTrack active={topicActive} handle={s.handle} />
|
||||
</span>
|
||||
) : (
|
||||
<span className="relative flex w-full min-w-0 items-center gap-1.5">
|
||||
<SidebarSessionHandle handle={s.handle} />
|
||||
<span className="min-w-0 flex-1 truncate font-medium leading-5">
|
||||
{title}
|
||||
</span>
|
||||
{isPinned ? <PinnedChatIndicator /> : null}
|
||||
<SidebarSelectionTrack active={topicActive} />
|
||||
<SidebarSelectionTrack active={topicActive} handle={s.handle} />
|
||||
</span>
|
||||
)}
|
||||
{showPreview ? (
|
||||
@@ -1405,7 +1428,9 @@ function ActivePaneRows({
|
||||
&& "bg-sidebar-accent/55 text-sidebar-accent-foreground",
|
||||
)}
|
||||
>
|
||||
<SidebarItemTooltip label={pane.title}>
|
||||
<SidebarItemTooltip
|
||||
label={pane.handle ? `@${pane.handle.name} · ${pane.title}` : pane.title}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
@@ -1437,9 +1462,10 @@ function ActivePaneRows({
|
||||
<SelectionIndicator checked={selected} partial={false} />
|
||||
) : null}
|
||||
<span className="relative flex min-w-0 flex-1 items-center gap-2 overflow-hidden">
|
||||
<SidebarSessionHandle handle={pane.handle} />
|
||||
<span className="min-w-0 flex-1 truncate">{pane.title}</span>
|
||||
{isPinned ? <PinnedChatIndicator /> : null}
|
||||
<SidebarSelectionTrack active={active} />
|
||||
<SidebarSelectionTrack active={active} handle={pane.handle} />
|
||||
</span>
|
||||
</button>
|
||||
</SidebarItemTooltip>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo } from "react";
|
||||
import { useMemo, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import {
|
||||
@@ -7,7 +7,12 @@ import {
|
||||
} from "@/components/InlineTokenHighlight";
|
||||
import { useLogoFallback } from "@/hooks/useLogoFallback";
|
||||
import { logoFallbackUrls } from "@/lib/provider-brand";
|
||||
import type { CliAppInfo, McpPresetInfo, SessionMention } from "@/lib/types";
|
||||
import type {
|
||||
CliAppInfo,
|
||||
McpPresetInfo,
|
||||
SessionHandle,
|
||||
SessionMention,
|
||||
} from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type CliAppMentionSegment =
|
||||
@@ -17,8 +22,56 @@ type CliAppMentionSegment =
|
||||
export type CapabilityMentionSegment =
|
||||
| CliAppMentionSegment
|
||||
| { kind: "mcp"; text: string; preset: McpPresetInfo }
|
||||
| { kind: "handle"; text: string; handle: SessionHandle };
|
||||
|
||||
export type SessionReferenceSegment =
|
||||
| { kind: "text"; text: string }
|
||||
| { kind: "session"; text: string; mention: SessionMention };
|
||||
|
||||
export interface TokenSelection<T> {
|
||||
mention: T;
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
export type SessionHandleSelection = TokenSelection<SessionHandle>;
|
||||
export type SessionMentionSelection = TokenSelection<SessionMention>;
|
||||
|
||||
const SESSION_HANDLE_COLOR_COUNT = 8;
|
||||
|
||||
export function sessionHandleColor(colorSlot: number): string {
|
||||
const slot = Number.isFinite(colorSlot)
|
||||
? Math.abs(Math.trunc(colorSlot)) % SESSION_HANDLE_COLOR_COUNT
|
||||
: 0;
|
||||
return `var(--session-handle-${slot})`;
|
||||
}
|
||||
|
||||
export function SessionHandleHighlight({
|
||||
handle,
|
||||
children,
|
||||
className,
|
||||
testId,
|
||||
}: {
|
||||
handle: Pick<SessionHandle, "color_slot" | "name">;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
testId?: string;
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className="inline border-b-2"
|
||||
style={{ borderBottomColor: sessionHandleColor(handle.color_slot) }}
|
||||
>
|
||||
<InlineTokenHighlight
|
||||
testId={testId}
|
||||
className={cn("text-foreground", className)}
|
||||
>
|
||||
{children}
|
||||
</InlineTokenHighlight>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function cliAppInitials(app: CliAppInfo): string {
|
||||
const value = app.display_name || app.name;
|
||||
return (
|
||||
@@ -30,6 +83,7 @@ export function cliAppInitials(app: CliAppInfo): string {
|
||||
.join("") || app.name.slice(0, 2).toUpperCase()
|
||||
);
|
||||
}
|
||||
|
||||
export function mcpPresetInitials(preset: Pick<McpPresetInfo, "name" | "display_name">): string {
|
||||
const value = preset.display_name || preset.name;
|
||||
return (
|
||||
@@ -41,13 +95,15 @@ export function mcpPresetInitials(preset: Pick<McpPresetInfo, "name" | "display_
|
||||
.join("") || preset.name.slice(0, 2).toUpperCase()
|
||||
);
|
||||
}
|
||||
|
||||
export function splitCapabilityMentionSegments(
|
||||
value: string,
|
||||
cliApps: CliAppInfo[],
|
||||
mcpPresets: McpPresetInfo[] = [],
|
||||
sessionMentions: SessionMention[] = [],
|
||||
sessionHandles: SessionHandle[] = [],
|
||||
handleSelections?: SessionHandleSelection[],
|
||||
): CapabilityMentionSegment[] {
|
||||
if (!value || (cliApps.length === 0 && mcpPresets.length === 0 && sessionMentions.length === 0)) {
|
||||
if (!value || (cliApps.length === 0 && mcpPresets.length === 0 && sessionHandles.length === 0)) {
|
||||
return value ? [{ kind: "text", text: value }] : [];
|
||||
}
|
||||
const cliAppsByName = new Map(
|
||||
@@ -60,10 +116,13 @@ export function splitCapabilityMentionSegments(
|
||||
.filter((preset) => preset.installed && preset.configured)
|
||||
.map((preset) => [preset.name.toLowerCase(), preset]),
|
||||
);
|
||||
const sessionsByName = new Map(
|
||||
sessionMentions.map((mention) => [mention.name.toLowerCase(), mention]),
|
||||
const handlesByName = new Map(
|
||||
sessionHandles.map((handle) => [handle.name.toLowerCase(), handle]),
|
||||
);
|
||||
if (cliAppsByName.size === 0 && mcpPresetsByName.size === 0 && sessionsByName.size === 0) {
|
||||
const selectedSessionNames = new Set(
|
||||
(handleSelections ?? []).map((selection) => selection.mention.name.toLowerCase()),
|
||||
);
|
||||
if (cliAppsByName.size === 0 && mcpPresetsByName.size === 0 && handlesByName.size === 0) {
|
||||
return [{ kind: "text", text: value }];
|
||||
}
|
||||
|
||||
@@ -75,13 +134,15 @@ export function splitCapabilityMentionSegments(
|
||||
const prefix = match[1] ?? "";
|
||||
const name = match[2] ?? "";
|
||||
const key = name.toLowerCase();
|
||||
const app = cliAppsByName.get(key);
|
||||
const preset = app ? null : mcpPresetsByName.get(key);
|
||||
const session = app || preset ? null : sessionsByName.get(key);
|
||||
if (!app && !preset && !session) continue;
|
||||
|
||||
const mentionStart = match.index + prefix.length;
|
||||
const mentionEnd = mentionStart + name.length + 1;
|
||||
const handle = handleSelections
|
||||
? selectedSessionNames.has(key) ? handlesByName.get(key) : undefined
|
||||
: handlesByName.get(key);
|
||||
const app = handle ? null : cliAppsByName.get(key);
|
||||
const preset = handle || app ? null : mcpPresetsByName.get(key);
|
||||
if (!app && !preset && !handle) continue;
|
||||
|
||||
if (mentionStart > cursor) {
|
||||
segments.push({ kind: "text", text: value.slice(cursor, mentionStart) });
|
||||
}
|
||||
@@ -89,18 +150,51 @@ export function splitCapabilityMentionSegments(
|
||||
segments.push({ kind: "cli", text: value.slice(mentionStart, mentionEnd), app });
|
||||
} else if (preset) {
|
||||
segments.push({ kind: "mcp", text: value.slice(mentionStart, mentionEnd), preset });
|
||||
} else if (session) {
|
||||
segments.push({
|
||||
kind: "session",
|
||||
text: value.slice(mentionStart, mentionEnd),
|
||||
mention: session,
|
||||
});
|
||||
} else if (handle) {
|
||||
segments.push({ kind: "handle", text: value.slice(mentionStart, mentionEnd), handle });
|
||||
}
|
||||
cursor = mentionEnd;
|
||||
}
|
||||
if (cursor < value.length) {
|
||||
segments.push({ kind: "text", text: value.slice(cursor) });
|
||||
if (cursor < value.length) segments.push({ kind: "text", text: value.slice(cursor) });
|
||||
return segments.length ? segments : [{ kind: "text", text: value }];
|
||||
}
|
||||
|
||||
export function splitSessionReferenceSegments(
|
||||
value: string,
|
||||
sessionMentions: SessionMention[] = [],
|
||||
sessionSelections?: SessionMentionSelection[],
|
||||
allowLegacyAt = false,
|
||||
): SessionReferenceSegment[] {
|
||||
if (!value || sessionMentions.length === 0) return value ? [{ kind: "text", text: value }] : [];
|
||||
const sessionsByName = new Map(
|
||||
sessionMentions.map((mention) => [mention.name.toLowerCase(), mention]),
|
||||
);
|
||||
const selectedSessionByStart = new Map(
|
||||
(sessionSelections ?? []).map((selection) => [selection.start, selection]),
|
||||
);
|
||||
const segments: SessionReferenceSegment[] = [];
|
||||
const referenceRe = allowLegacyAt
|
||||
? /(^|[\s([{])([#@])([\p{L}\p{N}_-]+)(?=$|[^\p{L}\p{N}_-])/giu
|
||||
: /(^|[\s([{])(#)([\p{L}\p{N}_-]+)(?=$|[^\p{L}\p{N}_-])/giu;
|
||||
let cursor = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = referenceRe.exec(value)) !== null) {
|
||||
const prefix = match[1] ?? "";
|
||||
const name = match[3] ?? "";
|
||||
const start = match.index + prefix.length;
|
||||
const end = start + name.length + 1;
|
||||
const selected = selectedSessionByStart.get(start);
|
||||
const mention = sessionSelections
|
||||
? selected?.end === end && selected.mention.name.toLowerCase() === name.toLowerCase()
|
||||
? selected.mention
|
||||
: undefined
|
||||
: sessionsByName.get(name.toLowerCase());
|
||||
if (!mention) continue;
|
||||
if (start > cursor) segments.push({ kind: "text", text: value.slice(cursor, start) });
|
||||
segments.push({ kind: "session", text: value.slice(start, end), mention });
|
||||
cursor = end;
|
||||
}
|
||||
if (cursor < value.length) segments.push({ kind: "text", text: value.slice(cursor) });
|
||||
return segments.length ? segments : [{ kind: "text", text: value }];
|
||||
}
|
||||
|
||||
@@ -133,10 +227,42 @@ export function CapabilityMentionToken({
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <SessionMentionToken mention={segment.mention} label={segment.text} variant={variant} />;
|
||||
return <SessionHandleToken handle={segment.handle} label={segment.text} variant={variant} />;
|
||||
}
|
||||
|
||||
export function SessionMentionToken({
|
||||
export function SessionHandleToken({
|
||||
handle,
|
||||
label,
|
||||
variant,
|
||||
}: {
|
||||
handle: SessionHandle;
|
||||
label: string;
|
||||
variant: "composer" | "message";
|
||||
}) {
|
||||
const testIdPrefix = variant === "composer" ? "composer" : "message";
|
||||
const color = sessionHandleColor(handle.color_slot);
|
||||
const token = (
|
||||
<SessionHandleHighlight
|
||||
handle={handle}
|
||||
testId={`${testIdPrefix}-handle-mention-${handle.name}`}
|
||||
className={variant === "composer" ? "font-normal" : undefined}
|
||||
>
|
||||
{label}
|
||||
</SessionHandleHighlight>
|
||||
);
|
||||
if (variant === "composer" || !handle.session_key) return token;
|
||||
return (
|
||||
<a
|
||||
href={`#/chat/${encodeURIComponent(handle.session_key)}`}
|
||||
className="rounded-sm underline-offset-2 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/60"
|
||||
style={{ textDecorationColor: color }}
|
||||
>
|
||||
{token}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
export function SessionReferenceToken({
|
||||
mention,
|
||||
label,
|
||||
variant,
|
||||
@@ -148,7 +274,7 @@ export function SessionMentionToken({
|
||||
const testIdPrefix = variant === "composer" ? "composer" : "message";
|
||||
const token = (
|
||||
<InlineTokenHighlight
|
||||
testId={`${testIdPrefix}-session-mention-${mention.name}`}
|
||||
testId={`${testIdPrefix}-session-reference-${mention.name}`}
|
||||
title={`Session: ${mention.title || mention.name}`}
|
||||
color={INLINE_TOKEN_HIGHLIGHT_COLOR}
|
||||
className={variant === "composer" ? "font-normal" : undefined}
|
||||
|
||||
@@ -13,7 +13,7 @@ export function InlineTokenHighlight({
|
||||
}: {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
color: string;
|
||||
color?: string;
|
||||
testId?: string;
|
||||
title?: string;
|
||||
}) {
|
||||
@@ -25,7 +25,7 @@ export function InlineTokenHighlight({
|
||||
"relative inline font-[550] transition-colors duration-150",
|
||||
className,
|
||||
)}
|
||||
style={{ color }}
|
||||
style={color ? { color } : undefined}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { SessionHandle } from "@/lib/types";
|
||||
|
||||
interface MarkdownTextProps {
|
||||
children: string;
|
||||
@@ -15,6 +16,7 @@ interface MarkdownTextProps {
|
||||
streaming?: boolean;
|
||||
preserveStreamingLayout?: boolean;
|
||||
onOpenFilePreview?: (path: string) => void;
|
||||
sessionHandles?: SessionHandle[];
|
||||
}
|
||||
|
||||
const loadMarkdownRenderer = () => import("@/components/MarkdownTextRenderer");
|
||||
@@ -26,12 +28,14 @@ const MemoizedMarkdownRenderer = memo(function MemoizedMarkdownRenderer({
|
||||
highlightCode,
|
||||
streaming,
|
||||
onOpenFilePreview,
|
||||
sessionHandles,
|
||||
}: {
|
||||
source: string;
|
||||
className?: string;
|
||||
highlightCode: boolean;
|
||||
streaming: boolean;
|
||||
onOpenFilePreview?: (path: string) => void;
|
||||
sessionHandles?: SessionHandle[];
|
||||
}) {
|
||||
return (
|
||||
<LazyMarkdownRenderer
|
||||
@@ -39,6 +43,7 @@ const MemoizedMarkdownRenderer = memo(function MemoizedMarkdownRenderer({
|
||||
highlightCode={highlightCode}
|
||||
streaming={streaming}
|
||||
onOpenFilePreview={onOpenFilePreview}
|
||||
sessionHandles={sessionHandles}
|
||||
>
|
||||
{source}
|
||||
</LazyMarkdownRenderer>
|
||||
@@ -77,6 +82,7 @@ export function MarkdownText({
|
||||
streaming = false,
|
||||
preserveStreamingLayout = false,
|
||||
onOpenFilePreview,
|
||||
sessionHandles,
|
||||
}: MarkdownTextProps) {
|
||||
const renderedSource = children;
|
||||
const renderPhase = streaming ? "streaming" : "complete";
|
||||
@@ -108,6 +114,7 @@ export function MarkdownText({
|
||||
highlightCode={highlightCode}
|
||||
streaming={renderWithStreamingLayout}
|
||||
onOpenFilePreview={onOpenFilePreview}
|
||||
sessionHandles={sessionHandles}
|
||||
/>
|
||||
</Suspense>
|
||||
</MarkdownRendererBoundary>
|
||||
|
||||
@@ -16,6 +16,7 @@ import { Streamdown, type Components, type StreamdownProps } from "streamdown";
|
||||
|
||||
import { AttachmentTile } from "@/components/AttachmentTile";
|
||||
import { CodeBlock } from "@/components/CodeBlock";
|
||||
import { SessionHandleHighlight, sessionHandleColor } from "@/components/CliAppMentionText";
|
||||
import {
|
||||
INLINE_TOKEN_HIGHLIGHT_COLOR,
|
||||
InlineTokenHighlight,
|
||||
@@ -34,6 +35,7 @@ import { inferMediaKind } from "@/lib/media";
|
||||
import { browserSafeFaviconUrls } from "@/lib/provider-brand";
|
||||
import { remarkTexMath } from "@/lib/remark-tex-math";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { SessionHandle } from "@/lib/types";
|
||||
|
||||
import "katex/dist/katex.min.css";
|
||||
import "streamdown/styles.css";
|
||||
@@ -44,11 +46,13 @@ interface MarkdownTextRendererProps {
|
||||
highlightCode?: boolean;
|
||||
streaming?: boolean;
|
||||
onOpenFilePreview?: (path: string) => void;
|
||||
sessionHandles?: SessionHandle[];
|
||||
}
|
||||
|
||||
type MarkdownAstNode = {
|
||||
type: string;
|
||||
value?: string;
|
||||
url?: string;
|
||||
children?: MarkdownAstNode[];
|
||||
data?: {
|
||||
hName?: string;
|
||||
@@ -277,7 +281,108 @@ function remarkCjkStrongBoundaries() {
|
||||
};
|
||||
}
|
||||
|
||||
const remarkPlugins: NonNullable<StreamdownProps["remarkPlugins"]> = [
|
||||
const SESSION_HANDLE_PATTERN = /@([\p{L}\p{N}_-]+)/gu;
|
||||
const SESSION_HANDLE_SKIP_NODES = new Set([
|
||||
"code",
|
||||
"html",
|
||||
"inlineCode",
|
||||
"inlineMath",
|
||||
"link",
|
||||
"linkReference",
|
||||
"math",
|
||||
]);
|
||||
const VOID_HTML_TAGS = new Set([
|
||||
"area",
|
||||
"base",
|
||||
"br",
|
||||
"col",
|
||||
"embed",
|
||||
"hr",
|
||||
"img",
|
||||
"input",
|
||||
"link",
|
||||
"meta",
|
||||
"param",
|
||||
"source",
|
||||
"track",
|
||||
"wbr",
|
||||
]);
|
||||
const RAW_HTML_TAG_PATTERN = /<\s*(\/?)\s*([a-z][\w:-]*)(?:\s[^<>]*?)?(\/?)\s*>/giu;
|
||||
|
||||
function normalizeSessionHandle(value: string): string {
|
||||
return value.normalize("NFKC").toLocaleLowerCase();
|
||||
}
|
||||
|
||||
function sessionHandleNodes(
|
||||
value: string,
|
||||
handlesByName: ReadonlyMap<string, SessionHandle>,
|
||||
): MarkdownAstNode[] | null {
|
||||
const replacement: MarkdownAstNode[] = [];
|
||||
let cursor = 0;
|
||||
for (const match of value.matchAll(SESSION_HANDLE_PATTERN)) {
|
||||
const start = match.index;
|
||||
const previous = start > 0 ? value[start - 1] : "";
|
||||
if (previous && /[\p{L}\p{N}_@-]/u.test(previous)) continue;
|
||||
const handle = handlesByName.get(normalizeSessionHandle(match[1]));
|
||||
if (!handle) continue;
|
||||
if (start > cursor) replacement.push(safeText(value.slice(cursor, start)));
|
||||
replacement.push({
|
||||
type: "link",
|
||||
url: `#session-handle/${encodeURIComponent(handle.session_key)}`,
|
||||
children: [safeText(match[0])],
|
||||
});
|
||||
cursor = start + match[0].length;
|
||||
}
|
||||
if (cursor === 0) return null;
|
||||
if (cursor < value.length) replacement.push(safeText(value.slice(cursor)));
|
||||
return replacement;
|
||||
}
|
||||
|
||||
function rawHtmlNestingDelta(value: string | undefined): number {
|
||||
if (!value) return 0;
|
||||
let delta = 0;
|
||||
for (const match of value.matchAll(RAW_HTML_TAG_PATTERN)) {
|
||||
const closing = match[1] === "/";
|
||||
const tagName = match[2].toLowerCase();
|
||||
const selfClosing = match[3] === "/" || VOID_HTML_TAGS.has(tagName);
|
||||
if (closing) delta -= 1;
|
||||
else if (!selfClosing) delta += 1;
|
||||
}
|
||||
return delta;
|
||||
}
|
||||
|
||||
function transformKnownSessionHandles(
|
||||
node: MarkdownAstNode,
|
||||
handlesByName: ReadonlyMap<string, SessionHandle>,
|
||||
): void {
|
||||
if (
|
||||
!node.children
|
||||
|| SESSION_HANDLE_SKIP_NODES.has(node.type)
|
||||
|| node.type.startsWith("nanobotSafeHtml")
|
||||
) return;
|
||||
let rawHtmlDepth = 0;
|
||||
node.children = node.children.flatMap((child) => {
|
||||
if (child.type === "html") {
|
||||
rawHtmlDepth = Math.max(0, rawHtmlDepth + rawHtmlNestingDelta(child.value));
|
||||
return [child];
|
||||
}
|
||||
if (rawHtmlDepth > 0) return [child];
|
||||
if (child.type !== "text" || !child.value?.includes("@")) {
|
||||
transformKnownSessionHandles(child, handlesByName);
|
||||
return [child];
|
||||
}
|
||||
return sessionHandleNodes(child.value, handlesByName) ?? [child];
|
||||
});
|
||||
}
|
||||
|
||||
function remarkKnownSessionHandles({ handles }: { handles: SessionHandle[] }) {
|
||||
const handlesByName = new Map(
|
||||
handles.map((handle) => [normalizeSessionHandle(handle.name), handle]),
|
||||
);
|
||||
return (tree: MarkdownAstNode) => transformKnownSessionHandles(tree, handlesByName);
|
||||
}
|
||||
|
||||
const baseRemarkPlugins: NonNullable<StreamdownProps["remarkPlugins"]> = [
|
||||
remarkBreaks,
|
||||
remarkGfm,
|
||||
[remarkMath, { singleDollarTextMath: false }],
|
||||
@@ -517,8 +622,22 @@ export default function MarkdownTextRenderer({
|
||||
highlightCode = true,
|
||||
streaming = false,
|
||||
onOpenFilePreview,
|
||||
sessionHandles = [],
|
||||
}: MarkdownTextRendererProps) {
|
||||
const { t } = useTranslation();
|
||||
const handlesBySessionKey = useMemo(
|
||||
() => new Map(sessionHandles.map((handle) => [handle.session_key, handle])),
|
||||
[sessionHandles],
|
||||
);
|
||||
const remarkPlugins = useMemo(
|
||||
() => sessionHandles.length > 0
|
||||
? [
|
||||
...baseRemarkPlugins,
|
||||
[remarkKnownSessionHandles, { handles: sessionHandles }],
|
||||
] as NonNullable<StreamdownProps["remarkPlugins"]>
|
||||
: baseRemarkPlugins,
|
||||
[sessionHandles],
|
||||
);
|
||||
const components = useMemo<Components>(
|
||||
() => ({
|
||||
code({ className: cls, children: kids, node: _node, ...props }) {
|
||||
@@ -612,6 +731,30 @@ export default function MarkdownTextRenderer({
|
||||
if (href === "streamdown:incomplete-link") {
|
||||
return <>{markdownChildren}</>;
|
||||
}
|
||||
if (href.startsWith("#session-handle/")) {
|
||||
let handle: SessionHandle | undefined;
|
||||
try {
|
||||
handle = handlesBySessionKey.get(decodeURIComponent(href.slice("#session-handle/".length)));
|
||||
} catch {
|
||||
handle = undefined;
|
||||
}
|
||||
if (!handle) return <>{markdownChildren}</>;
|
||||
const color = sessionHandleColor(handle.color_slot);
|
||||
return (
|
||||
<a
|
||||
href={`#/chat/${encodeURIComponent(handle.session_key)}`}
|
||||
className="rounded-sm no-underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/60"
|
||||
style={{ textDecorationColor: color }}
|
||||
>
|
||||
<SessionHandleHighlight
|
||||
handle={handle}
|
||||
testId={`message-handle-mention-${handle.name}`}
|
||||
>
|
||||
{markdownChildren}
|
||||
</SessionHandleHighlight>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
const sessionHref = sessionReferenceHref(href);
|
||||
if (sessionHref) {
|
||||
return (
|
||||
@@ -790,7 +933,7 @@ export default function MarkdownTextRenderer({
|
||||
);
|
||||
},
|
||||
}),
|
||||
[highlightCode, onOpenFilePreview, t],
|
||||
[highlightCode, onOpenFilePreview, handlesBySessionKey, t],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { AttachmentTile } from "@/components/AttachmentTile";
|
||||
import { sessionHandleColor } from "@/components/CliAppMentionText";
|
||||
import { ImageLightbox } from "@/components/ImageLightbox";
|
||||
import { MarkdownText } from "@/components/MarkdownText";
|
||||
import { SlashCommandText } from "@/components/SlashCommandText";
|
||||
@@ -48,6 +49,7 @@ import type {
|
||||
UIMessage,
|
||||
MessageDeliveryErrorKind,
|
||||
MessageDeliveryStatus,
|
||||
SessionHandle,
|
||||
} from "@/lib/types";
|
||||
|
||||
interface MessageBubbleProps {
|
||||
@@ -61,6 +63,7 @@ interface MessageBubbleProps {
|
||||
cliApps?: CliAppInfo[];
|
||||
mcpPresets?: McpPresetInfo[];
|
||||
slashCommands?: SlashCommand[];
|
||||
sessionDirectory?: SessionHandle[];
|
||||
onOpenFilePreview?: (path: string) => void;
|
||||
onForkFromHere?: () => void;
|
||||
}
|
||||
@@ -259,6 +262,77 @@ function UserDeliveryStatus({
|
||||
);
|
||||
}
|
||||
|
||||
function IncomingSessionMessage({
|
||||
message,
|
||||
showCopyAction,
|
||||
sessionDirectory,
|
||||
onOpenFilePreview,
|
||||
}: {
|
||||
message: UIMessage;
|
||||
showCopyAction: boolean;
|
||||
sessionDirectory: SessionHandle[];
|
||||
onOpenFilePreview?: (path: string) => void;
|
||||
}) {
|
||||
const handle = message.sessionMessage!.session;
|
||||
const activeSession = sessionDirectory.find((candidate) => candidate.id === handle.id);
|
||||
const color = sessionHandleColor(handle.color_slot);
|
||||
const createdAtLabel = formatMessageEndTime(message.createdAt);
|
||||
const handleName = `@${handle.name}`;
|
||||
const name = <span className="font-medium text-foreground">{handleName}</span>;
|
||||
|
||||
return (
|
||||
<div
|
||||
data-handle-message="incoming"
|
||||
className="group w-full text-[15px]"
|
||||
style={{ lineHeight: "var(--cjk-line-height)" }}
|
||||
>
|
||||
<div
|
||||
data-handle-message-body
|
||||
className="min-w-0 rounded-es-[16px] border-s-2 bg-background pb-1 ps-2.5"
|
||||
style={{ borderInlineStartColor: color }}
|
||||
>
|
||||
<div className="mb-1.5 flex items-center text-[12px] text-muted-foreground">
|
||||
{activeSession?.session_key ? (
|
||||
<a
|
||||
href={`#/chat/${encodeURIComponent(activeSession.session_key)}`}
|
||||
className="rounded-sm underline-offset-2 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/60"
|
||||
>
|
||||
{name}
|
||||
</a>
|
||||
) : name}
|
||||
</div>
|
||||
<div data-assistant-selectable="true" className="min-w-0">
|
||||
<MarkdownText
|
||||
preserveStreamingLayout
|
||||
onOpenFilePreview={onOpenFilePreview}
|
||||
sessionHandles={sessionDirectory}
|
||||
>
|
||||
{message.content}
|
||||
</MarkdownText>
|
||||
</div>
|
||||
</div>
|
||||
{createdAtLabel || showCopyAction ? (
|
||||
<TooltipProvider delayDuration={220} skipDelayDuration={80}>
|
||||
<div
|
||||
data-handle-footer
|
||||
className="mt-1 flex min-h-8 items-center gap-1.5 text-muted-foreground"
|
||||
>
|
||||
{showCopyAction ? <MessageCopyButton content={message.content} /> : null}
|
||||
{createdAtLabel ? (
|
||||
<MessageTimestamp
|
||||
timestamp={message.createdAt}
|
||||
tooltipLabel={fmtDateTime(message.createdAt)}
|
||||
>
|
||||
{createdAtLabel}
|
||||
</MessageTimestamp>
|
||||
) : null}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Render user turns as compact bubbles and assistant turns as document-like prose. */
|
||||
export function MessageBubble({
|
||||
message,
|
||||
@@ -268,6 +342,7 @@ export function MessageBubble({
|
||||
cliApps = [],
|
||||
mcpPresets = [],
|
||||
slashCommands = [],
|
||||
sessionDirectory = [],
|
||||
onOpenFilePreview,
|
||||
onForkFromHere,
|
||||
}: MessageBubbleProps) {
|
||||
@@ -285,6 +360,17 @@ export function MessageBubble({
|
||||
return <TraceGroup message={message} />;
|
||||
}
|
||||
|
||||
if (message.role === "user" && message.sessionMessage?.direction === "incoming") {
|
||||
return (
|
||||
<IncomingSessionMessage
|
||||
message={message}
|
||||
showCopyAction={showCopyAction}
|
||||
sessionDirectory={sessionDirectory}
|
||||
onOpenFilePreview={onOpenFilePreview}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (message.role === "user") {
|
||||
const images = message.images ?? [];
|
||||
const media = message.media ?? [];
|
||||
@@ -308,6 +394,9 @@ export function MessageBubble({
|
||||
cliApps={mentionCliApps}
|
||||
mcpPresets={mentionMcpPresets}
|
||||
sessionMentions={message.sessionMentions}
|
||||
sessionHandles={message.sessionHandles}
|
||||
attachedCliApps={message.cliApps}
|
||||
attachedMcpPresets={message.mcpPresets}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
@@ -316,6 +405,9 @@ export function MessageBubble({
|
||||
cliApps={mentionCliApps}
|
||||
mcpPresets={mentionMcpPresets}
|
||||
sessionMentions={message.sessionMentions}
|
||||
sessionHandles={message.sessionHandles}
|
||||
attachedCliApps={message.cliApps}
|
||||
attachedMcpPresets={message.mcpPresets}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
@@ -433,6 +525,7 @@ export function MessageBubble({
|
||||
streaming={!!message.isStreaming}
|
||||
preserveStreamingLayout
|
||||
onOpenFilePreview={onOpenFilePreview}
|
||||
sessionHandles={sessionDirectory}
|
||||
>
|
||||
{message.content}
|
||||
</MarkdownText>
|
||||
|
||||
@@ -3,14 +3,24 @@ 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, SessionMention } from "@/lib/types";
|
||||
import type {
|
||||
CliAppInfo,
|
||||
McpPresetInfo,
|
||||
SessionHandle,
|
||||
SessionMention,
|
||||
UICliAppAttachment,
|
||||
UIMcpPresetAttachment,
|
||||
} from "@/lib/types";
|
||||
|
||||
type SkillReferenceSegment =
|
||||
| { kind: "text"; text: string }
|
||||
@@ -18,6 +28,7 @@ type SkillReferenceSegment =
|
||||
|
||||
type UserMessageSegment =
|
||||
| CapabilityMentionSegment
|
||||
| SessionReferenceSegment
|
||||
| { kind: "skill"; text: string; name: string };
|
||||
|
||||
function splitSkillReferenceSegments(value: string): SkillReferenceSegment[] {
|
||||
@@ -49,18 +60,75 @@ function splitUserMessageSegments(
|
||||
cliApps: CliAppInfo[],
|
||||
mcpPresets: McpPresetInfo[],
|
||||
sessionMentions: SessionMention[],
|
||||
sessionHandles: SessionHandle[],
|
||||
attachedCliApps: UICliAppAttachment[],
|
||||
attachedMcpPresets: UIMcpPresetAttachment[],
|
||||
): UserMessageSegment[] {
|
||||
const segments: UserMessageSegment[] = [];
|
||||
for (const segment of splitCapabilityMentionSegments(
|
||||
value,
|
||||
cliApps,
|
||||
mcpPresets,
|
||||
sessionMentions,
|
||||
)) {
|
||||
if (segment.kind === "text") {
|
||||
segments.push(...splitSkillReferenceSegments(segment.text));
|
||||
} else {
|
||||
segments.push(segment);
|
||||
const structuredAtNamespaces = new Map<string, "handle" | "cli" | "mcp">();
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
return segments;
|
||||
@@ -71,14 +139,28 @@ 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);
|
||||
const segments = splitUserMessageSegments(
|
||||
text,
|
||||
cliApps,
|
||||
mcpPresets,
|
||||
sessionMentions,
|
||||
sessionHandles,
|
||||
attachedCliApps,
|
||||
attachedMcpPresets,
|
||||
);
|
||||
return (
|
||||
<>
|
||||
{segments.map((segment, index) => {
|
||||
@@ -95,6 +177,14 @@ export function UserMessageText({
|
||||
{segment.name}
|
||||
</InlineTokenHighlight>
|
||||
);
|
||||
if (segment.kind === "session") return (
|
||||
<SessionReferenceToken
|
||||
key={`session-${segment.mention.session_key}-${index}`}
|
||||
mention={segment.mention}
|
||||
label={segment.text}
|
||||
variant="message"
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<CapabilityMentionToken
|
||||
key={`${segment.kind}-${index}`}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,7 @@ import { type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SessionHandleHighlight } from "@/components/CliAppMentionText";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -10,9 +11,11 @@ import {
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { SessionHandle } from "@/lib/types";
|
||||
|
||||
interface ThreadHeaderProps {
|
||||
title: string;
|
||||
handle?: SessionHandle | null;
|
||||
onToggleSidebar: () => void;
|
||||
theme: "light" | "dark";
|
||||
onToggleTheme: () => void;
|
||||
@@ -32,6 +35,7 @@ interface ThreadHeaderProps {
|
||||
|
||||
export function ThreadHeader({
|
||||
title,
|
||||
handle = null,
|
||||
onToggleSidebar,
|
||||
theme,
|
||||
onToggleTheme,
|
||||
@@ -79,6 +83,16 @@ export function ThreadHeader({
|
||||
<span className="max-w-[min(60vw,32rem)] truncate">{title}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{handle ? (
|
||||
<span
|
||||
data-testid="thread-handle-handle"
|
||||
className="flex shrink-0 items-center rounded-md px-1.5 py-1 text-[12px] font-medium"
|
||||
>
|
||||
<SessionHandleHighlight handle={handle}>
|
||||
@{handle.name}
|
||||
</SessionHandleHighlight>
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="ml-auto flex shrink-0 items-center gap-1">
|
||||
|
||||
@@ -4,7 +4,13 @@ import { MessageBubble } from "@/components/MessageBubble";
|
||||
import { AgentActivityCluster } from "@/components/thread/AgentActivityCluster";
|
||||
import { AssistantSelectionAction } from "@/components/thread/AssistantSelectionAction";
|
||||
import { normalizeActivityTimeline, type TurnUnit } from "@/lib/activity-timeline";
|
||||
import type { CliAppInfo, McpPresetInfo, SlashCommand, UIMessage } from "@/lib/types";
|
||||
import type {
|
||||
CliAppInfo,
|
||||
McpPresetInfo,
|
||||
SessionHandle,
|
||||
SlashCommand,
|
||||
UIMessage,
|
||||
} from "@/lib/types";
|
||||
|
||||
interface ThreadMessagesProps {
|
||||
messages: UIMessage[];
|
||||
@@ -18,6 +24,7 @@ interface ThreadMessagesProps {
|
||||
cliApps?: CliAppInfo[];
|
||||
mcpPresets?: McpPresetInfo[];
|
||||
slashCommands?: SlashCommand[];
|
||||
sessionDirectory?: SessionHandle[];
|
||||
forkBoundaryMessageCount?: number | null;
|
||||
onOpenFilePreview?: (path: string) => void;
|
||||
onForkFromMessage?: (beforeUserIndex: number) => void;
|
||||
@@ -62,6 +69,7 @@ export function ThreadMessages({
|
||||
cliApps = [],
|
||||
mcpPresets = [],
|
||||
slashCommands = [],
|
||||
sessionDirectory = [],
|
||||
forkBoundaryMessageCount = null,
|
||||
onOpenFilePreview,
|
||||
onForkFromMessage,
|
||||
@@ -159,6 +167,7 @@ export function ThreadMessages({
|
||||
cliApps={cliApps}
|
||||
mcpPresets={mcpPresets}
|
||||
slashCommands={slashCommands}
|
||||
sessionDirectory={sessionDirectory}
|
||||
onOpenFilePreview={onOpenFilePreview}
|
||||
onForkFromMessage={onForkFromMessage}
|
||||
/>
|
||||
@@ -240,6 +249,7 @@ interface ThreadDisplayUnitProps {
|
||||
cliApps: CliAppInfo[];
|
||||
mcpPresets: McpPresetInfo[];
|
||||
slashCommands: SlashCommand[];
|
||||
sessionDirectory: SessionHandle[];
|
||||
onOpenFilePreview?: (path: string) => void;
|
||||
onForkFromMessage?: (beforeUserIndex: number) => void;
|
||||
}
|
||||
@@ -258,6 +268,7 @@ const ThreadDisplayUnit = memo(function ThreadDisplayUnit({
|
||||
cliApps,
|
||||
mcpPresets,
|
||||
slashCommands,
|
||||
sessionDirectory,
|
||||
onOpenFilePreview,
|
||||
onForkFromMessage,
|
||||
}: ThreadDisplayUnitProps) {
|
||||
@@ -296,6 +307,7 @@ const ThreadDisplayUnit = memo(function ThreadDisplayUnit({
|
||||
cliApps={cliApps}
|
||||
mcpPresets={mcpPresets}
|
||||
slashCommands={slashCommands}
|
||||
sessionDirectory={sessionDirectory}
|
||||
onOpenFilePreview={onOpenFilePreview}
|
||||
onForkFromHere={forkIndex !== undefined ? onForkFromHere : undefined}
|
||||
/>
|
||||
@@ -324,6 +336,7 @@ function threadDisplayUnitPropsEqual(
|
||||
&& previous.cliApps === next.cliApps
|
||||
&& previous.mcpPresets === next.mcpPresets
|
||||
&& previous.slashCommands === next.slashCommands
|
||||
&& previous.sessionDirectory === next.sessionDirectory
|
||||
&& previous.onOpenFilePreview === next.onOpenFilePreview
|
||||
&& previous.onForkFromMessage === next.onForkFromMessage
|
||||
);
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useTranslation } from "react-i18next";
|
||||
|
||||
import { FilePreviewAvailabilityProvider } from "@/components/FilePreviewAvailabilityContext";
|
||||
import { FilePreviewPanel } from "@/components/FilePreviewPanel";
|
||||
import { SessionHandleHighlight } from "@/components/CliAppMentionText";
|
||||
import { PromptNavigator } from "@/components/thread/PromptNavigator";
|
||||
import { SessionInfoPopover } from "@/components/thread/SessionInfoPopover";
|
||||
import { ThreadComposer } from "@/components/thread/ThreadComposer";
|
||||
@@ -36,6 +37,7 @@ import type { CanonicalRunSnapshot, StreamError } from "@/lib/nanobot-client";
|
||||
import { inferProviderFromModelName, providerDisplayLabel } from "@/lib/provider-brand";
|
||||
import type {
|
||||
ChatSummary,
|
||||
SessionHandle,
|
||||
SettingsPayload,
|
||||
SlashCommand,
|
||||
SkillSummary,
|
||||
@@ -637,7 +639,7 @@ export function ThreadShell({
|
||||
const { t } = useTranslation();
|
||||
const chatId = session?.chatId ?? null;
|
||||
const historyKey = temporary ? null : session?.key ?? null;
|
||||
const mentionSessions = useMemo(
|
||||
const referenceSessions = useMemo(
|
||||
() => sessions.filter((candidate) => (
|
||||
candidate.key !== historyKey
|
||||
&& (
|
||||
@@ -647,6 +649,18 @@ export function ThreadShell({
|
||||
)),
|
||||
[historyKey, sessions, workspaceScope],
|
||||
);
|
||||
const handleSessions = useMemo(() => {
|
||||
if (temporary) return [];
|
||||
return sessions;
|
||||
}, [sessions, temporary]);
|
||||
const sessionDirectory = useMemo<SessionHandle[]>(() => {
|
||||
const handles = new Map<string, SessionHandle>();
|
||||
if (session?.handle) handles.set(session.handle.id, session.handle);
|
||||
for (const candidate of handleSessions) {
|
||||
if (candidate.handle) handles.set(candidate.handle.id, candidate.handle);
|
||||
}
|
||||
return [...handles.values()];
|
||||
}, [handleSessions, session?.handle]);
|
||||
const {
|
||||
messages: historical,
|
||||
loading,
|
||||
@@ -1316,7 +1330,14 @@ export function ThreadShell({
|
||||
setPendingFirstTargetChatId(newId);
|
||||
return true;
|
||||
},
|
||||
[booting, client, localModelPreset, onCreateChat, withWorkspaceScope, workspaceScope],
|
||||
[
|
||||
booting,
|
||||
client,
|
||||
localModelPreset,
|
||||
onCreateChat,
|
||||
withWorkspaceScope,
|
||||
workspaceScope,
|
||||
],
|
||||
);
|
||||
|
||||
const handleThreadSend = useCallback(
|
||||
@@ -1469,7 +1490,8 @@ export function ThreadShell({
|
||||
slashCommands={availableSlashCommands}
|
||||
cliApps={cliApps}
|
||||
mcpPresets={mcpPresets}
|
||||
sessions={mentionSessions}
|
||||
sessions={referenceSessions}
|
||||
handleSessions={handleSessions}
|
||||
skills={skills}
|
||||
onStop={stop}
|
||||
onTranscribeAudio={transcribeAudio}
|
||||
@@ -1516,7 +1538,8 @@ export function ThreadShell({
|
||||
slashCommands={availableSlashCommands}
|
||||
cliApps={cliApps}
|
||||
mcpPresets={mcpPresets}
|
||||
sessions={mentionSessions}
|
||||
sessions={referenceSessions}
|
||||
handleSessions={handleSessions}
|
||||
skills={skills}
|
||||
surfaceRef={composerSurfaceRef}
|
||||
onTranscribeAudio={transcribeAudio}
|
||||
@@ -1560,6 +1583,7 @@ export function ThreadShell({
|
||||
const threadHeader = !hideHeader ? (
|
||||
<ThreadHeader
|
||||
title={title}
|
||||
handle={temporary || hideHeaderTitle ? null : session?.handle}
|
||||
onToggleSidebar={onToggleSidebar}
|
||||
theme={theme}
|
||||
onToggleTheme={onToggleTheme}
|
||||
@@ -1583,6 +1607,23 @@ export function ThreadShell({
|
||||
return (
|
||||
<section ref={shellRef} className="relative flex min-h-0 flex-1 overflow-hidden">
|
||||
<div className="relative flex min-w-0 flex-1 flex-col overflow-hidden">
|
||||
{hideHeaderTitle && !temporary && session?.handle ? (
|
||||
<div
|
||||
data-testid="pane-handle-identity"
|
||||
data-active={headerActive ? "true" : "false"}
|
||||
aria-label={`Session @${session.handle.name}`}
|
||||
className="flex h-8 shrink-0 items-center px-3 text-[12px]"
|
||||
>
|
||||
<span
|
||||
data-pane-handle-handle
|
||||
className="shrink-0"
|
||||
>
|
||||
<SessionHandleHighlight handle={session.handle}>
|
||||
@{session.handle.name}
|
||||
</SessionHandleHighlight>
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
{headerPortalTarget === undefined ? threadHeader : null}
|
||||
<FilePreviewAvailabilityProvider
|
||||
resolve={historyKey ? resolveFilePreviewAvailability : undefined}
|
||||
@@ -1602,6 +1643,7 @@ export function ThreadShell({
|
||||
showScrollToBottomButton={!!session}
|
||||
cliApps={cliApps}
|
||||
mcpPresets={mcpPresets}
|
||||
sessionDirectory={sessionDirectory}
|
||||
slashCommands={availableSlashCommands}
|
||||
forkBoundaryMessageCount={forkBoundaryMessageCount}
|
||||
hasMoreBefore={hasMoreBefore}
|
||||
|
||||
@@ -26,7 +26,13 @@ import {
|
||||
promptTop,
|
||||
} from "@/components/thread/promptNavigation";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { CliAppInfo, McpPresetInfo, SlashCommand, UIMessage } from "@/lib/types";
|
||||
import type {
|
||||
CliAppInfo,
|
||||
McpPresetInfo,
|
||||
SessionHandle,
|
||||
SlashCommand,
|
||||
UIMessage,
|
||||
} from "@/lib/types";
|
||||
|
||||
export interface ThreadViewportHandle {
|
||||
jumpToUserPrompt: (promptId: string) => void;
|
||||
@@ -50,6 +56,7 @@ interface ThreadViewportProps {
|
||||
cliApps?: CliAppInfo[];
|
||||
mcpPresets?: McpPresetInfo[];
|
||||
slashCommands?: SlashCommand[];
|
||||
sessionDirectory?: SessionHandle[];
|
||||
forkBoundaryMessageCount?: number | null;
|
||||
hasMoreBefore?: boolean;
|
||||
loadingOlder?: boolean;
|
||||
@@ -69,6 +76,7 @@ const SOFT_KEYBOARD_MIN_INSET_PX = 80;
|
||||
const SESSION_HANDOFF_EXIT_DURATION_MS = 80;
|
||||
const SESSION_HANDOFF_ENTER_DURATION_MS = 140;
|
||||
const SESSION_HANDOFF_OPACITY = 0.82;
|
||||
const EMPTY_SESSION_DIRECTORY: SessionHandle[] = [];
|
||||
export const INITIAL_HISTORY_WINDOW = 160;
|
||||
export const HISTORY_WINDOW_INCREMENT = 120;
|
||||
|
||||
@@ -104,11 +112,6 @@ function isKeyboardEditableElement(element: Element | null): element is HTMLElem
|
||||
].includes(element.type);
|
||||
}
|
||||
|
||||
function isThreadDisclosureTarget(target: EventTarget | null): boolean {
|
||||
return target instanceof Element
|
||||
&& target.closest("[data-thread-disclosure]") !== null;
|
||||
}
|
||||
|
||||
function isKeyboardControl(element: Element | null): boolean {
|
||||
return element instanceof HTMLElement
|
||||
&& element.closest(
|
||||
@@ -116,6 +119,11 @@ function isKeyboardControl(element: Element | null): boolean {
|
||||
) !== null;
|
||||
}
|
||||
|
||||
function isThreadDisclosureTarget(target: EventTarget | null): boolean {
|
||||
return target instanceof Element
|
||||
&& target.closest("[data-thread-disclosure]") !== null;
|
||||
}
|
||||
|
||||
type ThreadScrollDirection = "backward" | "forward";
|
||||
|
||||
const KEYBOARD_SCROLL_DIRECTIONS: Readonly<
|
||||
@@ -185,6 +193,7 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
||||
cliApps = [],
|
||||
mcpPresets = [],
|
||||
slashCommands = [],
|
||||
sessionDirectory = EMPTY_SESSION_DIRECTORY,
|
||||
forkBoundaryMessageCount = null,
|
||||
hasMoreBefore = false,
|
||||
loadingOlder = false,
|
||||
@@ -762,6 +771,7 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
||||
cliApps={cliApps}
|
||||
mcpPresets={mcpPresets}
|
||||
slashCommands={slashCommands}
|
||||
sessionDirectory={sessionDirectory}
|
||||
forkBoundaryMessageCount={visibleForkBoundaryMessageCount}
|
||||
onOpenFilePreview={onOpenFilePreview}
|
||||
onForkFromMessage={onForkFromMessage}
|
||||
|
||||
@@ -15,6 +15,8 @@ export interface ToolField {
|
||||
| "key"
|
||||
| "label"
|
||||
| "name"
|
||||
| "to"
|
||||
| "expect_reply"
|
||||
| "channel"
|
||||
| "chat_id"
|
||||
| "session_id"
|
||||
@@ -119,7 +121,7 @@ export function describeGenericToolRun(items: GenericToolRunItem[]): GenericTool
|
||||
status,
|
||||
label: activityLabel(family, status, collected, name, items),
|
||||
detail: activityDetail(items, family, name),
|
||||
aside: activityAside(items, family),
|
||||
aside: activityAside(items, family, name),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -168,6 +170,7 @@ function safeFields(args: unknown): ToolField[] {
|
||||
"key",
|
||||
"label",
|
||||
"name",
|
||||
"to",
|
||||
"channel",
|
||||
"chat_id",
|
||||
"session_id",
|
||||
@@ -178,6 +181,17 @@ function safeFields(args: unknown): ToolField[] {
|
||||
fields.push({ key, value: value.trim() });
|
||||
}
|
||||
}
|
||||
const expectReply = record.expect_reply;
|
||||
if (typeof expectReply === "boolean") {
|
||||
fields.push({ key: "expect_reply", value: String(expectReply) });
|
||||
} else if (typeof expectReply === "string") {
|
||||
const normalized = expectReply.toLowerCase();
|
||||
if (["true", "1", "yes"].includes(normalized)) {
|
||||
fields.push({ key: "expect_reply", value: "true" });
|
||||
} else if (["false", "0", "no"].includes(normalized)) {
|
||||
fields.push({ key: "expect_reply", value: "false" });
|
||||
}
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
@@ -226,6 +240,18 @@ function activityLabel(
|
||||
return statusCopy(status, "Generating image", "Generated image", "Could not generate image");
|
||||
case "spawn":
|
||||
return statusCopy(status, "Delegating task", "Delegated task", "Could not delegate task");
|
||||
case "send_session_message":
|
||||
if (items.length > 1) {
|
||||
return statusCopy(
|
||||
status,
|
||||
"Sending messages",
|
||||
"Sent messages",
|
||||
"Could not send messages",
|
||||
);
|
||||
}
|
||||
return fieldValue(items[0]?.trace, "expect_reply") === "true"
|
||||
? statusCopy(status, "Asking", "Asked", "Could not reach")
|
||||
: statusCopy(status, "Sending to", "Sent to", "Could not reach");
|
||||
case "message":
|
||||
return statusCopy(status, "Sending message", "Sent message", "Could not send message");
|
||||
case "my":
|
||||
@@ -281,6 +307,8 @@ function activityDetail(items: GenericToolRunItem[], family: ToolFamily, name: s
|
||||
switch (name) {
|
||||
case "spawn":
|
||||
return safeText(fieldValue(trace, "label"));
|
||||
case "send_session_message":
|
||||
return safeText(fieldValue(trace, "to"));
|
||||
case "message":
|
||||
return safeText(fieldValue(trace, "channel"));
|
||||
case "my":
|
||||
@@ -301,10 +329,15 @@ function activityDetail(items: GenericToolRunItem[], family: ToolFamily, name: s
|
||||
}
|
||||
}
|
||||
|
||||
function activityAside(items: GenericToolRunItem[], family: ToolFamily): string {
|
||||
function activityAside(
|
||||
items: GenericToolRunItem[],
|
||||
family: ToolFamily,
|
||||
name: string,
|
||||
): string {
|
||||
const pathCount = uniqueValues(items, ["path", "file_path"]).length;
|
||||
if (pathCount > 1) return `${pathCount} files`;
|
||||
if (items.length <= 1) return "";
|
||||
if (name === "send_session_message") return `${items.length} messages`;
|
||||
if (family === "content-search" || family === "file-search" || family === "memory") {
|
||||
return `${items.length} searches`;
|
||||
}
|
||||
|
||||
@@ -33,6 +33,14 @@
|
||||
--input: 40 8% 90.5%;
|
||||
--ring: 0 0% 3.9%;
|
||||
--inline-token-highlight: #ef8e30;
|
||||
--session-handle-0: #b45f36;
|
||||
--session-handle-1: #9b6b16;
|
||||
--session-handle-2: #3f7a4f;
|
||||
--session-handle-3: #267b78;
|
||||
--session-handle-4: #3c6fa8;
|
||||
--session-handle-5: #655fb0;
|
||||
--session-handle-6: #98558f;
|
||||
--session-handle-7: #a54f62;
|
||||
--temporary-control-active: #ef8e30;
|
||||
--temporary-accent: 24 95% 53%;
|
||||
--temporary-foreground: 17 88% 32%;
|
||||
@@ -81,6 +89,14 @@
|
||||
--input: var(--border);
|
||||
--ring: 0 0% 83.1%;
|
||||
--inline-token-highlight: #ef8e30;
|
||||
--session-handle-0: #e58a62;
|
||||
--session-handle-1: #d2a44d;
|
||||
--session-handle-2: #73b985;
|
||||
--session-handle-3: #55b8b2;
|
||||
--session-handle-4: #72a5dc;
|
||||
--session-handle-5: #9a91e3;
|
||||
--session-handle-6: #cf83c5;
|
||||
--session-handle-7: #dc7e91;
|
||||
--temporary-control-active: #ef8e30;
|
||||
--temporary-accent: 24 95% 53%;
|
||||
--temporary-foreground: 32 98% 73%;
|
||||
|
||||
@@ -33,6 +33,7 @@ import type {
|
||||
OutboundCliAppMention,
|
||||
OutboundMcpPresetMention,
|
||||
OutboundMedia,
|
||||
SessionHandle,
|
||||
SessionMention,
|
||||
GoalStateWsPayload,
|
||||
MessageDeliveryStatus,
|
||||
@@ -169,6 +170,7 @@ export interface SendOptions {
|
||||
cliApps?: OutboundCliAppMention[];
|
||||
mcpPresets?: OutboundMcpPresetMention[];
|
||||
sessionMentions?: SessionMention[];
|
||||
sessionHandles?: SessionHandle[];
|
||||
quotedContext?: string;
|
||||
workspaceScope?: WorkspaceScopePayload | null;
|
||||
sideChannel?: boolean;
|
||||
@@ -188,6 +190,7 @@ function eventExtendsModelActivity(ev: InboundEvent): boolean {
|
||||
ev.event === "delta"
|
||||
|| ev.event === "reasoning_delta"
|
||||
|| ev.event === "file_edit"
|
||||
|| ev.event === "session_message"
|
||||
) return true;
|
||||
return ev.event === "message"
|
||||
&& (ev.kind === "tool_hint" || ev.kind === "progress" || ev.kind === "reasoning");
|
||||
@@ -218,6 +221,33 @@ function transitionTurnDelivery(
|
||||
return changed ? next : messages;
|
||||
}
|
||||
|
||||
function appendLiveSessionMessage(
|
||||
messages: UIMessage[],
|
||||
event: Extract<InboundEvent, { event: "session_message" }>,
|
||||
): UIMessage[] {
|
||||
const messageId = event.session_message?.message_id?.trim();
|
||||
if (!messageId || event.session_message.direction !== "incoming") return messages;
|
||||
if (messages.some((message) => message.sessionMessage?.message_id === messageId)) return messages;
|
||||
|
||||
const row: UIMessage = {
|
||||
id: `session-message:${messageId}`,
|
||||
role: "user",
|
||||
content: event.text,
|
||||
createdAt: Number.isFinite(event.created_at_ms) ? event.created_at_ms : Date.now(),
|
||||
sessionMessage: event.session_message,
|
||||
...turnFieldsFromEvent(event, "user"),
|
||||
};
|
||||
const sameTurnIndex = event.turn_id
|
||||
? messages.findIndex((message) => message.turnId === event.turn_id)
|
||||
: -1;
|
||||
if (sameTurnIndex < 0) return [...messages, row];
|
||||
return [
|
||||
...messages.slice(0, sameTurnIndex),
|
||||
row,
|
||||
...messages.slice(sameTurnIndex),
|
||||
];
|
||||
}
|
||||
|
||||
export function useNanobotStream(
|
||||
chatId: string | null,
|
||||
initialMessages: UIMessage[] = [],
|
||||
@@ -645,6 +675,18 @@ export function useNanobotStream(
|
||||
});
|
||||
}, [cancelStreamEndTimer, client]);
|
||||
|
||||
useEffect(() => {
|
||||
return client.onRunStatus((updatedChatId, startedAt) => {
|
||||
if (updatedChatId !== chatId) return;
|
||||
// Canonical HTTP reconciliation can settle a turn before its delayed
|
||||
// WebSocket completion frame reaches this mounted thread. The client
|
||||
// then fences that duplicate frame, so keep the pane-local timer in
|
||||
// sync with the client's authoritative per-chat run projection.
|
||||
setRunStartedAt(startedAt);
|
||||
if (startedAt !== null) setIsStreaming(true);
|
||||
});
|
||||
}, [chatId, client]);
|
||||
|
||||
// Reset local state when switching chats. Do not reset on every
|
||||
// ``initialMessages`` update: a brand-new chat can receive an empty/404
|
||||
// history response after the optimistic first message has already rendered.
|
||||
@@ -802,12 +844,20 @@ export function useNanobotStream(
|
||||
|
||||
const shouldCloseAnswerBeforeEvent =
|
||||
ev.event === "file_edit"
|
||||
|| ev.event === "session_message"
|
||||
|| (
|
||||
ev.event === "message"
|
||||
&& (ev.kind === "tool_hint" || ev.kind === "progress")
|
||||
);
|
||||
flushPendingStreamEvents({ closeAnswerSegment: shouldCloseAnswerBeforeEvent });
|
||||
|
||||
if (ev.event === "session_message") {
|
||||
clearActivitySegment();
|
||||
setIsStreaming(true);
|
||||
setMessages((prev) => appendLiveSessionMessage(prev, ev));
|
||||
return;
|
||||
}
|
||||
|
||||
if (ev.event === "reasoning_end") {
|
||||
if (suppressStreamUntilTurnEndRef.current) return;
|
||||
setMessages((prev) => closeReasoningStream(prev, Date.now()));
|
||||
@@ -1138,6 +1188,9 @@ export function useNanobotStream(
|
||||
...(options?.sessionMentions?.length
|
||||
? { sessionMentions: options.sessionMentions }
|
||||
: {}),
|
||||
...(options?.sessionHandles?.length
|
||||
? { sessionHandles: options.sessionHandles }
|
||||
: {}),
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
@@ -1180,7 +1180,6 @@
|
||||
"placeholderStreaming": "Model is responding…",
|
||||
"inputAria": "Message input",
|
||||
"sendHint": "Enter to send · Shift+Enter for newline",
|
||||
"runRuntimeTitle": "Running · {{elapsed}}",
|
||||
"goalStateStrip": "Goal · {{label}}",
|
||||
"goalStateFallback": "Goal",
|
||||
"goalStateExpandAria": "Show full goal",
|
||||
@@ -1324,9 +1323,7 @@
|
||||
"cliDescription": "Use @{{name}} as a local CLI app",
|
||||
"mcpDescription": "Use @{{name}} as an MCP server",
|
||||
"cliTitle": "CLI app: {{name}}",
|
||||
"mcpTitle": "MCP server: {{name}}",
|
||||
"sessionBadge": "Nanobot conversation",
|
||||
"sessionDescription": "Reference @{{name}} as a previous chat"
|
||||
"mcpTitle": "MCP server: {{name}}"
|
||||
},
|
||||
"encoding": "Encoding…",
|
||||
"remove": "Remove attachment",
|
||||
|
||||
@@ -1167,7 +1167,6 @@
|
||||
"placeholderStreaming": "El modelo está respondiendo…",
|
||||
"inputAria": "Entrada de mensaje",
|
||||
"sendHint": "Enter para enviar · Shift+Enter para nueva línea",
|
||||
"runRuntimeTitle": "En ejecución · {{elapsed}}",
|
||||
"goalStateStrip": "Objetivo · {{label}}",
|
||||
"goalStateFallback": "Objetivo",
|
||||
"goalStateExpandAria": "Ver objetivo completo",
|
||||
@@ -1327,9 +1326,7 @@
|
||||
"cliDescription": "Usar @{{name}} como aplicación CLI local",
|
||||
"mcpDescription": "Usar @{{name}} como servidor MCP",
|
||||
"cliTitle": "Aplicación CLI: {{name}}",
|
||||
"mcpTitle": "Servidor MCP: {{name}}",
|
||||
"sessionBadge": "Conversación de Nanobot",
|
||||
"sessionDescription": "Referenciar @{{name}} como chat anterior"
|
||||
"mcpTitle": "Servidor MCP: {{name}}"
|
||||
},
|
||||
"workspace": {
|
||||
"accessAria": "Modo de acceso al espacio de trabajo",
|
||||
|
||||
@@ -1166,7 +1166,6 @@
|
||||
"placeholderStreaming": "Le modèle est en train de répondre…",
|
||||
"inputAria": "Champ de message",
|
||||
"sendHint": "Entrée pour envoyer · Maj+Entrée pour un retour à la ligne",
|
||||
"runRuntimeTitle": "Exécution · {{elapsed}}",
|
||||
"goalStateStrip": "Objectif · {{label}}",
|
||||
"goalStateFallback": "Objectif",
|
||||
"goalStateExpandAria": "Afficher l’objectif complet",
|
||||
@@ -1326,9 +1325,7 @@
|
||||
"cliDescription": "Utiliser @{{name}} comme application CLI locale",
|
||||
"mcpDescription": "Utiliser @{{name}} comme serveur MCP",
|
||||
"cliTitle": "Application CLI : {{name}}",
|
||||
"mcpTitle": "Serveur MCP : {{name}}",
|
||||
"sessionBadge": "Conversation Nanobot",
|
||||
"sessionDescription": "Référencer @{{name}} comme discussion précédente"
|
||||
"mcpTitle": "Serveur MCP : {{name}}"
|
||||
},
|
||||
"workspace": {
|
||||
"accessAria": "Mode d’accès à l’espace de travail",
|
||||
|
||||
@@ -1166,7 +1166,6 @@
|
||||
"placeholderStreaming": "Model sedang merespons…",
|
||||
"inputAria": "Input pesan",
|
||||
"sendHint": "Enter untuk kirim · Shift+Enter untuk baris baru",
|
||||
"runRuntimeTitle": "Berjalan · {{elapsed}}",
|
||||
"goalStateStrip": "Tujuan · {{label}}",
|
||||
"goalStateFallback": "Tujuan",
|
||||
"goalStateExpandAria": "Lihat tujuan lengkap",
|
||||
@@ -1326,9 +1325,7 @@
|
||||
"cliDescription": "Gunakan @{{name}} sebagai aplikasi CLI lokal",
|
||||
"mcpDescription": "Gunakan @{{name}} sebagai server MCP",
|
||||
"cliTitle": "Aplikasi CLI: {{name}}",
|
||||
"mcpTitle": "Server MCP: {{name}}",
|
||||
"sessionBadge": "Percakapan Nanobot",
|
||||
"sessionDescription": "Referensikan @{{name}} sebagai chat sebelumnya"
|
||||
"mcpTitle": "Server MCP: {{name}}"
|
||||
},
|
||||
"workspace": {
|
||||
"accessAria": "Mode akses ruang kerja",
|
||||
|
||||
@@ -1166,7 +1166,6 @@
|
||||
"placeholderStreaming": "モデルが応答しています…",
|
||||
"inputAria": "メッセージ入力欄",
|
||||
"sendHint": "Enter で送信 · Shift+Enter で改行",
|
||||
"runRuntimeTitle": "実行中 · {{elapsed}}",
|
||||
"goalStateStrip": "目標 · {{label}}",
|
||||
"goalStateFallback": "目標",
|
||||
"goalStateExpandAria": "目標の全文を表示",
|
||||
@@ -1326,9 +1325,7 @@
|
||||
"cliDescription": "@{{name}} をローカル CLI アプリとして使用",
|
||||
"mcpDescription": "@{{name}} を MCP サーバーとして使用",
|
||||
"cliTitle": "CLI アプリ: {{name}}",
|
||||
"mcpTitle": "MCP サーバー: {{name}}",
|
||||
"sessionBadge": "Nanobot の会話",
|
||||
"sessionDescription": "@{{name}} を過去のチャットとして参照"
|
||||
"mcpTitle": "MCP サーバー: {{name}}"
|
||||
},
|
||||
"workspace": {
|
||||
"accessAria": "ワークスペースのアクセスモード",
|
||||
|
||||
@@ -1166,7 +1166,6 @@
|
||||
"placeholderStreaming": "모델이 응답 중입니다…",
|
||||
"inputAria": "메시지 입력",
|
||||
"sendHint": "Enter로 전송 · Shift+Enter로 줄바꿈",
|
||||
"runRuntimeTitle": "실행 중 · {{elapsed}}",
|
||||
"goalStateStrip": "목표 · {{label}}",
|
||||
"goalStateFallback": "목표",
|
||||
"goalStateExpandAria": "전체 목표 보기",
|
||||
@@ -1326,9 +1325,7 @@
|
||||
"cliDescription": "@{{name}}을 로컬 CLI 앱으로 사용",
|
||||
"mcpDescription": "@{{name}}을 MCP 서버로 사용",
|
||||
"cliTitle": "CLI 앱: {{name}}",
|
||||
"mcpTitle": "MCP 서버: {{name}}",
|
||||
"sessionBadge": "Nanobot 대화",
|
||||
"sessionDescription": "@{{name}}을 이전 채팅으로 참조"
|
||||
"mcpTitle": "MCP 서버: {{name}}"
|
||||
},
|
||||
"workspace": {
|
||||
"accessAria": "작업공간 접근 모드",
|
||||
|
||||
@@ -1180,7 +1180,6 @@
|
||||
"placeholderStreaming": "O modelo está respondendo…",
|
||||
"inputAria": "Campo de mensagem",
|
||||
"sendHint": "Enter para enviar · Shift+Enter para nova linha",
|
||||
"runRuntimeTitle": "Executando · {{elapsed}}",
|
||||
"goalStateStrip": "Objetivo · {{label}}",
|
||||
"goalStateFallback": "Objetivo",
|
||||
"goalStateExpandAria": "Mostrar objetivo completo",
|
||||
@@ -1324,9 +1323,7 @@
|
||||
"cliDescription": "Usar @{{name}} como aplicativo CLI local",
|
||||
"mcpDescription": "Usar @{{name}} como servidor MCP",
|
||||
"cliTitle": "Aplicativo CLI: {{name}}",
|
||||
"mcpTitle": "Servidor MCP: {{name}}",
|
||||
"sessionBadge": "Conversa do Nanobot",
|
||||
"sessionDescription": "Referenciar @{{name}} como chat anterior"
|
||||
"mcpTitle": "Servidor MCP: {{name}}"
|
||||
},
|
||||
"encoding": "Codificando…",
|
||||
"remove": "Remover anexo",
|
||||
|
||||
@@ -1166,7 +1166,6 @@
|
||||
"placeholderStreaming": "Mô hình đang trả lời…",
|
||||
"inputAria": "Ô nhập tin nhắn",
|
||||
"sendHint": "Enter để gửi · Shift+Enter để xuống dòng",
|
||||
"runRuntimeTitle": "Đang chạy · {{elapsed}}",
|
||||
"goalStateStrip": "Mục tiêu · {{label}}",
|
||||
"goalStateFallback": "Mục tiêu",
|
||||
"goalStateExpandAria": "Xem đầy đủ mục tiêu",
|
||||
@@ -1326,9 +1325,7 @@
|
||||
"cliDescription": "Dùng @{{name}} như ứng dụng CLI cục bộ",
|
||||
"mcpDescription": "Dùng @{{name}} như máy chủ MCP",
|
||||
"cliTitle": "Ứng dụng CLI: {{name}}",
|
||||
"mcpTitle": "Máy chủ MCP: {{name}}",
|
||||
"sessionBadge": "Cuộc trò chuyện Nanobot",
|
||||
"sessionDescription": "Tham chiếu @{{name}} như cuộc trò chuyện trước"
|
||||
"mcpTitle": "Máy chủ MCP: {{name}}"
|
||||
},
|
||||
"workspace": {
|
||||
"accessAria": "Chế độ truy cập không gian làm việc",
|
||||
|
||||
@@ -1180,7 +1180,6 @@
|
||||
"placeholderStreaming": "模型正在回复…",
|
||||
"inputAria": "消息输入框",
|
||||
"sendHint": "Enter 发送 · Shift+Enter 换行",
|
||||
"runRuntimeTitle": "运行中 · {{elapsed}}",
|
||||
"goalStateStrip": "目标 · {{label}}",
|
||||
"goalStateFallback": "目标",
|
||||
"goalStateExpandAria": "查看完整目标",
|
||||
@@ -1323,9 +1322,7 @@
|
||||
"cliDescription": "使用 @{{name}} 调用本地 CLI",
|
||||
"mcpDescription": "使用 @{{name}} 调用 MCP 服务",
|
||||
"cliTitle": "CLI 应用:{{name}}",
|
||||
"mcpTitle": "MCP 服务:{{name}}",
|
||||
"sessionBadge": "Nanobot 对话",
|
||||
"sessionDescription": "引用历史会话 @{{name}}"
|
||||
"mcpTitle": "MCP 服务:{{name}}"
|
||||
},
|
||||
"encoding": "处理中…",
|
||||
"remove": "移除附件",
|
||||
|
||||
@@ -1166,7 +1166,6 @@
|
||||
"placeholderStreaming": "模型正在回覆…",
|
||||
"inputAria": "訊息輸入框",
|
||||
"sendHint": "Enter 送出 · Shift+Enter 換行",
|
||||
"runRuntimeTitle": "執行中 · {{elapsed}}",
|
||||
"goalStateStrip": "目標 · {{label}}",
|
||||
"goalStateFallback": "目標",
|
||||
"goalStateExpandAria": "檢視完整目標",
|
||||
@@ -1326,9 +1325,7 @@
|
||||
"cliDescription": "將 @{{name}} 作為本機 CLI 應用程式使用",
|
||||
"mcpDescription": "將 @{{name}} 作為 MCP 伺服器使用",
|
||||
"cliTitle": "CLI 應用程式:{{name}}",
|
||||
"mcpTitle": "MCP 伺服器:{{name}}",
|
||||
"sessionBadge": "Nanobot 對話",
|
||||
"sessionDescription": "引用先前的對話 @{{name}}"
|
||||
"mcpTitle": "MCP 伺服器:{{name}}"
|
||||
},
|
||||
"workspace": {
|
||||
"accessAria": "工作區存取模式",
|
||||
|
||||
+34
-11
@@ -23,6 +23,7 @@ import type {
|
||||
ProviderOAuthLoginResult,
|
||||
ProviderSettingsUpdate,
|
||||
SessionDeleteResult,
|
||||
SessionListHandle,
|
||||
SessionAutomationsPayload,
|
||||
SettingsPayload,
|
||||
SettingsUpdate,
|
||||
@@ -166,6 +167,22 @@ function splitKey(key: string): { channel: string; chatId: string } {
|
||||
return { channel: key.slice(0, idx), chatId: key.slice(idx + 1) };
|
||||
}
|
||||
|
||||
function normalizeSessionListHandle(value: unknown): SessionListHandle | null {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const handle = value as Partial<SessionListHandle>;
|
||||
const id = typeof handle.id === "string" ? handle.id.trim() : "";
|
||||
const name = typeof handle.name === "string" ? handle.name.trim() : "";
|
||||
if (
|
||||
!/^handle_[a-f0-9]{32}$/i.test(id)
|
||||
|| !name
|
||||
|| !/^[\p{L}\p{N}_-]+$/u.test(name)
|
||||
|| !Number.isInteger(handle.color_slot)
|
||||
|| (handle.color_slot ?? -1) < 0
|
||||
|| (handle.color_slot ?? 8) >= 8
|
||||
) return null;
|
||||
return { id, name, color_slot: handle.color_slot as number };
|
||||
}
|
||||
|
||||
export async function listSessions(
|
||||
token: string,
|
||||
base: string = "",
|
||||
@@ -179,6 +196,7 @@ export async function listSessions(
|
||||
model_preset?: string | null;
|
||||
run_started_at?: number | null;
|
||||
workspace_scope?: WorkspaceScopePayload | null;
|
||||
handle?: SessionListHandle | null;
|
||||
};
|
||||
const body = await request<{ sessions: Row[] }>(
|
||||
`${base}/api/sessions`,
|
||||
@@ -186,17 +204,22 @@ export async function listSessions(
|
||||
undefined,
|
||||
API_READ_TIMEOUT_MS,
|
||||
);
|
||||
return body.sessions.map((s) => ({
|
||||
key: s.key,
|
||||
...splitKey(s.key),
|
||||
createdAt: s.created_at,
|
||||
updatedAt: s.updated_at,
|
||||
title: s.title ?? "",
|
||||
preview: s.preview ?? "",
|
||||
modelPreset: s.model_preset ?? null,
|
||||
runStartedAt: s.run_started_at ?? null,
|
||||
workspaceScope: s.workspace_scope ?? null,
|
||||
}));
|
||||
return body.sessions.map((s) => {
|
||||
const rawSession = normalizeSessionListHandle(s.handle);
|
||||
const handle = rawSession ? { ...rawSession, session_key: s.key } : null;
|
||||
return {
|
||||
key: s.key,
|
||||
...splitKey(s.key),
|
||||
createdAt: s.created_at,
|
||||
updatedAt: s.updated_at,
|
||||
title: s.title ?? "",
|
||||
preview: s.preview ?? "",
|
||||
modelPreset: s.model_preset ?? null,
|
||||
runStartedAt: s.run_started_at ?? null,
|
||||
workspaceScope: s.workspace_scope ?? null,
|
||||
handle,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Disk-backed WebUI display thread snapshot (separate from agent session). */
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
OutboundCliAppMention,
|
||||
OutboundMcpPresetMention,
|
||||
OutboundMedia,
|
||||
SessionHandle,
|
||||
SessionMention,
|
||||
SidebarStatePayload,
|
||||
GoalStateWsPayload,
|
||||
@@ -195,7 +196,7 @@ export class NanobotClient {
|
||||
private knownChats = new Set<string>();
|
||||
/** Temporary chats are connection-owned and intentionally not reattached. */
|
||||
private temporaryChatIds = new Set<string>();
|
||||
/** Wall-clock run strip: updated from ``goal_status`` even with no ``onChat`` subscriber. */
|
||||
/** Per-chat run projection, started optimistically and reconciled by lifecycle events. */
|
||||
private runStartedAtByChatId = new Map<string, number>();
|
||||
/** Per-turn clocks let a rejected newer turn fall back without borrowing its timer. */
|
||||
private runStartedAtByTurnKey = new Map<string, number>();
|
||||
@@ -537,6 +538,14 @@ export class NanobotClient {
|
||||
}
|
||||
}
|
||||
|
||||
private startRunLocally(chatId: string, turnId: string): void {
|
||||
const startedAt = Date.now() / 1000;
|
||||
this.runStartedAtByTurnKey.set(this.runSendKey(chatId, turnId), startedAt);
|
||||
const previous = this.runStartedAtByChatId.get(chatId);
|
||||
this.runStartedAtByChatId.set(chatId, startedAt);
|
||||
if (previous !== startedAt) this.emitRunStatus(chatId, startedAt);
|
||||
}
|
||||
|
||||
private settleRunTurn(chatId: string, turnId?: string): void {
|
||||
if (!turnId) return;
|
||||
this.clearPendingMessageSend(chatId, turnId);
|
||||
@@ -716,7 +725,7 @@ export class NanobotClient {
|
||||
}
|
||||
}
|
||||
|
||||
private recordGoalStatusForRunStrip(chatId: string, ev: InboundEvent): void {
|
||||
private recordRunStatus(chatId: string, ev: InboundEvent): void {
|
||||
if (ev.event === "turn_end") {
|
||||
this.recordRunCompletion(chatId, ev.turn_id);
|
||||
return;
|
||||
@@ -967,6 +976,7 @@ export class NanobotClient {
|
||||
cliApps?: OutboundCliAppMention[];
|
||||
mcpPresets?: OutboundMcpPresetMention[];
|
||||
sessionMentions?: SessionMention[];
|
||||
sessionHandles?: SessionHandle[];
|
||||
quotedContext?: string;
|
||||
workspaceScope?: WorkspaceScopePayload | null;
|
||||
turnId?: string;
|
||||
@@ -986,6 +996,9 @@ export class NanobotClient {
|
||||
...(options?.sessionMentions?.length
|
||||
? { session_mentions: options.sessionMentions }
|
||||
: {}),
|
||||
...(options?.sessionHandles?.length
|
||||
? { session_handles: options.sessionHandles }
|
||||
: {}),
|
||||
...(options?.quotedContext?.trim() ? { quoted_context: options.quotedContext.trim() } : {}),
|
||||
...(options?.workspaceScope ? { workspace_scope: options.workspaceScope } : {}),
|
||||
...(options?.turnId ? { turn_id: options.turnId } : {}),
|
||||
@@ -1004,7 +1017,10 @@ export class NanobotClient {
|
||||
}
|
||||
if (options?.turnId && !isSystemCommandTurnId(options.turnId)) {
|
||||
const startsNewRun = options.startsNewRun !== false;
|
||||
if (startsNewRun) this.advanceRunGeneration(chatId, options.turnId);
|
||||
if (startsNewRun) {
|
||||
this.advanceRunGeneration(chatId, options.turnId);
|
||||
this.startRunLocally(chatId, options.turnId);
|
||||
}
|
||||
this.trackPendingMessageSend(chatId, options.turnId, startsNewRun);
|
||||
}
|
||||
this.queueSend(frame);
|
||||
@@ -1240,7 +1256,7 @@ export class NanobotClient {
|
||||
if (chatId) {
|
||||
if (this.isCanonicalCompletedTurnEvent(chatId, parsed)) return;
|
||||
const supersededRunCompletion = this.isSupersededRunCompletion(chatId, parsed);
|
||||
this.recordGoalStatusForRunStrip(chatId, parsed);
|
||||
this.recordRunStatus(chatId, parsed);
|
||||
if (supersededRunCompletion) return;
|
||||
this.recordGoalStateSnapshot(chatId, parsed);
|
||||
this.dispatch(chatId, parsed);
|
||||
|
||||
+33
-1
@@ -66,6 +66,8 @@ export interface UIMessage {
|
||||
mcpPresets?: UIMcpPresetAttachment[];
|
||||
/** Persisted sessions explicitly referenced by this user turn. */
|
||||
sessionMentions?: SessionMention[];
|
||||
/** Active session handles structurally selected by this user turn. */
|
||||
sessionHandles?: SessionHandle[];
|
||||
/** Assistant turn: accumulated model reasoning / thinking text. Built up
|
||||
* incrementally from ``reasoning_delta`` frames; finalized when
|
||||
* ``reasoning_end`` arrives. */
|
||||
@@ -79,6 +81,8 @@ export interface UIMessage {
|
||||
completedAt?: number;
|
||||
/** Lightweight provenance for proactive assistant messages. */
|
||||
source?: UIMessageSource;
|
||||
/** Structured provenance for a message delivered by another session. */
|
||||
sessionMessage?: UISessionMessage;
|
||||
/** Stable protocol metadata for grouping all activity emitted by one user turn. */
|
||||
turnId?: string;
|
||||
turnPhase?: UITurnPhase;
|
||||
@@ -110,13 +114,31 @@ export interface UIMcpPresetAttachment {
|
||||
}
|
||||
|
||||
export interface SessionMention {
|
||||
/** Text token inserted in the composer, without the leading @. */
|
||||
/** Text token inserted in the composer, without the leading #. */
|
||||
name: string;
|
||||
/** Stable persisted-session identifier used by read_session. */
|
||||
session_key: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
/** Exact public handle DTO returned by the session-list endpoint. */
|
||||
export interface SessionListHandle {
|
||||
id: string;
|
||||
name: string;
|
||||
color_slot: number;
|
||||
}
|
||||
|
||||
/** Public session handle enriched with its UI navigation target. */
|
||||
export interface SessionHandle extends SessionListHandle {
|
||||
session_key: string;
|
||||
}
|
||||
|
||||
export interface UISessionMessage {
|
||||
direction: "incoming" | "outgoing";
|
||||
message_id: string;
|
||||
session: SessionListHandle;
|
||||
}
|
||||
|
||||
export interface SessionAutomationJob {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -337,6 +359,8 @@ export interface ChatSummary {
|
||||
/** Unix epoch seconds when this session currently has a turn in flight. */
|
||||
runStartedAt?: number | null;
|
||||
workspaceScope?: WorkspaceScopePayload | null;
|
||||
/** Stable, server-owned @handle for this session. */
|
||||
handle?: SessionHandle | null;
|
||||
}
|
||||
|
||||
export type WorkspaceAccessMode = "restricted" | "full";
|
||||
@@ -1248,6 +1272,13 @@ export type InboundEvent =
|
||||
/** Optional structured payload on progress frames (channel-specific). */
|
||||
agent_ui?: AgentUIBlob;
|
||||
} & InboundTurnMetadata)
|
||||
| ({
|
||||
event: "session_message";
|
||||
chat_id: string;
|
||||
text: string;
|
||||
created_at_ms: number;
|
||||
session_message: UISessionMessage;
|
||||
} & InboundTurnMetadata)
|
||||
| ({
|
||||
event: "file_edit";
|
||||
chat_id: string;
|
||||
@@ -1442,6 +1473,7 @@ export type Outbound =
|
||||
cli_apps?: OutboundCliAppMention[];
|
||||
mcp_presets?: OutboundMcpPresetMention[];
|
||||
session_mentions?: SessionMention[];
|
||||
session_handles?: SessionHandle[];
|
||||
quoted_context?: string;
|
||||
workspace_scope?: WorkspaceScopePayload;
|
||||
turn_id?: string;
|
||||
|
||||
@@ -1049,7 +1049,7 @@ describe("webui API helpers", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("maps generated session titles from the sessions list", async () => {
|
||||
it("maps title-free handle handles", async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
@@ -1061,6 +1061,11 @@ describe("webui API helpers", () => {
|
||||
title: "优化 WebUI 标题",
|
||||
model_preset: "fast",
|
||||
run_started_at: 1_700_000_000,
|
||||
handle: {
|
||||
id: "handle_1234567890abcdef1234567890abcdef",
|
||||
name: "webui-review",
|
||||
color_slot: 5,
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
@@ -1073,10 +1078,39 @@ describe("webui API helpers", () => {
|
||||
preview: "",
|
||||
modelPreset: "fast",
|
||||
runStartedAt: 1_700_000_000,
|
||||
handle: {
|
||||
id: "handle_1234567890abcdef1234567890abcdef",
|
||||
name: "webui-review",
|
||||
color_slot: 5,
|
||||
session_key: "websocket:chat-1",
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects malformed session-list handle DTOs instead of trusting enriched fields", async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
sessions: [
|
||||
{
|
||||
key: "websocket:chat-1",
|
||||
created_at: null,
|
||||
updated_at: null,
|
||||
handle: {
|
||||
id: "handle_1234567890abcdef1234567890abcdef",
|
||||
name: "valid-handle",
|
||||
color_slot: 8,
|
||||
session_key: "websocket:attacker-controlled",
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
} as Response);
|
||||
|
||||
await expect(listSessions("tok")).resolves.toMatchObject([{ handle: null }]);
|
||||
});
|
||||
|
||||
it("maps slash command metadata from the commands endpoint", async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
|
||||
@@ -519,7 +519,7 @@ describe("App layout", () => {
|
||||
|
||||
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
|
||||
const firstMessage = "keep this first turn visible";
|
||||
fireEvent.change(screen.getByRole("textbox", { name: "Message input" }), {
|
||||
fireEvent.change(screen.getByRole("combobox", { name: "Message input" }), {
|
||||
target: { value: firstMessage },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
|
||||
@@ -3375,7 +3375,7 @@ describe("App layout", () => {
|
||||
.toEqual(["Alpha", "New topic"]);
|
||||
|
||||
const activeComposer = screen.getByTestId("active-pane-composer");
|
||||
const paneInput = within(activeComposer).getByRole("textbox", {
|
||||
const paneInput = within(activeComposer).getByRole("combobox", {
|
||||
name: "Message New topic",
|
||||
});
|
||||
expect(paneInput).toHaveClass("min-h-[50px]");
|
||||
|
||||
@@ -66,6 +66,104 @@ describe("ChatList", () => {
|
||||
expect(onTogglePin).toHaveBeenCalledWith("websocket:review");
|
||||
});
|
||||
|
||||
it("keeps each handle handle visible beside its conversation title", () => {
|
||||
render(
|
||||
<ChatList
|
||||
sessions={[session({
|
||||
chatId: "review",
|
||||
title: "Review the patch",
|
||||
handle: {
|
||||
id: "handle_1234",
|
||||
name: "mira",
|
||||
color_slot: 3,
|
||||
session_key: "websocket:review",
|
||||
},
|
||||
})]}
|
||||
activeKey="websocket:review"
|
||||
onSelect={vi.fn()}
|
||||
onRequestDelete={vi.fn()}
|
||||
onTogglePin={vi.fn()}
|
||||
onRequestRename={vi.fn()}
|
||||
onToggleArchive={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<ChatList
|
||||
sessions={[session({
|
||||
key: "tab:group",
|
||||
chatId: "workbench-tab:group",
|
||||
title: "Grouped work",
|
||||
})]}
|
||||
activeKey="websocket:root"
|
||||
paneGroups={{
|
||||
"tab:group": {
|
||||
tabKey: "tab:group",
|
||||
title: "Grouped work",
|
||||
activePaneKey: "websocket:root",
|
||||
visible: true,
|
||||
panes: [
|
||||
{ key: "websocket:root", chatId: "root", title: "Short", handle: mira },
|
||||
{
|
||||
key: "websocket:child",
|
||||
chatId: "child",
|
||||
title: "A much longer conversation title",
|
||||
handle: nora,
|
||||
},
|
||||
],
|
||||
},
|
||||
}}
|
||||
onSelect={vi.fn()}
|
||||
onRequestDelete={vi.fn()}
|
||||
onTogglePin={vi.fn()}
|
||||
onRequestRename={vi.fn()}
|
||||
onToggleArchive={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<ChatList
|
||||
@@ -982,8 +1080,10 @@ describe("ChatList", () => {
|
||||
|
||||
const activeButton = screen.getByRole("button", { name: "Active topic" });
|
||||
expect(activeButton).toHaveAttribute("aria-current", "page");
|
||||
expect(activeButton.querySelector("[data-sidebar-selection-track]"))
|
||||
.toHaveClass("origin-left", "scale-x-100", "transition-transform", "bg-current");
|
||||
const activeTrack = activeButton.querySelector("[data-sidebar-selection-track]");
|
||||
expect(activeTrack)
|
||||
.toHaveClass("origin-left", "scale-x-100", "transition-transform");
|
||||
expect(activeTrack?.getAttribute("style")).toContain("currentcolor");
|
||||
|
||||
rerender(
|
||||
<ChatList
|
||||
|
||||
@@ -23,6 +23,7 @@ describe("generic tool activity semantics", () => {
|
||||
['generate_image({"prompt":"private launch art"})', "Generated image", ""],
|
||||
['spawn({"label":"Research competitors","task":"private task"})', "Delegated task", "Research competitors"],
|
||||
['message({"channel":"telegram","content":"private message"})', "Sent message", "telegram"],
|
||||
['send_session_message({"to":"@reviewer","content":"private message","expect_reply":true})', "Asked", "@reviewer"],
|
||||
['my({"action":"check","key":"context_window_tokens"})', "Checked agent settings", "context_window_tokens"],
|
||||
['my({"action":"set","key":"model","value":"private-model"})', "Updated agent settings", "model"],
|
||||
['cron({"action":"add","name":"Daily digest","message":"private prompt"})', "Scheduled automation", "Daily digest"],
|
||||
@@ -40,6 +41,60 @@ describe("generic tool activity semantics", () => {
|
||||
expect(`${presentation.label} ${presentation.detail}`).not.toMatch(/[{}]|private|tool-results/);
|
||||
});
|
||||
|
||||
it("renders a handle target once and uses plural copy for grouped messages", () => {
|
||||
const first = parseGenericToolTrace(
|
||||
'send_session_message({"to":"@kai","content":"first","expect_reply":false})',
|
||||
)!;
|
||||
const second = parseGenericToolTrace(
|
||||
'send_session_message({"to":"@mira","content":"second","expect_reply":false})',
|
||||
)!;
|
||||
|
||||
const single = describeGenericToolRun([{ trace: first, status: "done" }]);
|
||||
expect([single.label, single.detail].filter(Boolean).join(" ")).toBe("Sent to @kai");
|
||||
|
||||
const grouped = describeGenericToolRun([
|
||||
{ trace: first, status: "done" },
|
||||
{ trace: second, status: "done" },
|
||||
]);
|
||||
expect(grouped).toMatchObject({
|
||||
label: "Sent messages",
|
||||
detail: "",
|
||||
aside: "2 messages",
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
[true, "running", "Asking"],
|
||||
[true, "done", "Asked"],
|
||||
[false, "running", "Sending to"],
|
||||
[false, "done", "Sent to"],
|
||||
[false, "error", "Could not reach"],
|
||||
] as const)(
|
||||
"describes expect_reply=%s handle activity while %s",
|
||||
(expectReply, status, label) => {
|
||||
const presentation = describeRun(
|
||||
`send_session_message({"to":"@kai","content":"private","expect_reply":${expectReply}})`,
|
||||
status,
|
||||
);
|
||||
expect(presentation).toMatchObject({ label, detail: "@kai" });
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
["true", "Asked"],
|
||||
["1", "Asked"],
|
||||
["yes", "Asked"],
|
||||
["false", "Sent to"],
|
||||
["0", "Sent to"],
|
||||
["no", "Sent to"],
|
||||
])("matches backend boolean casting for expect_reply=%s", (expectReply, label) => {
|
||||
const presentation = describeRun(
|
||||
`send_session_message({"to":"@kai","content":"private","expect_reply":"${expectReply}"})`,
|
||||
"done",
|
||||
);
|
||||
expect(presentation).toMatchObject({ label, detail: "@kai" });
|
||||
});
|
||||
|
||||
it.each([
|
||||
["running", "Generating image"],
|
||||
["done", "Generated image"],
|
||||
|
||||
@@ -28,6 +28,53 @@ describe("MarkdownTextRenderer", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("highlights only known handle handles in prose with their identity color", () => {
|
||||
render(
|
||||
<MarkdownTextRenderer
|
||||
sessionHandles={[{
|
||||
id: "handle-jules",
|
||||
name: "jules",
|
||||
session_key: "websocket:jules",
|
||||
color_slot: 0,
|
||||
}]}
|
||||
>
|
||||
{"已直接回复 @jules;未知 @ghost;邮箱 hello@jules.test;代码 `@jules`。"}
|
||||
</MarkdownTextRenderer>,
|
||||
);
|
||||
|
||||
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(
|
||||
<MarkdownTextRenderer
|
||||
sessionHandles={[{
|
||||
id: "handle-jules",
|
||||
name: "jules",
|
||||
session_key: "websocket:jules",
|
||||
color_slot: 0,
|
||||
}]}
|
||||
>
|
||||
{"<code>@jules</code> <span>@jules</span> <mark>@jules</mark> outside @jules"}
|
||||
</MarkdownTextRenderer>,
|
||||
);
|
||||
|
||||
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(
|
||||
<MarkdownTextRenderer>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { MessageBubble } from "@/components/MessageBubble";
|
||||
import { preloadMarkdownText } from "@/components/MarkdownText";
|
||||
import { fmtDateTime, formatMessageEndTime } from "@/lib/format";
|
||||
import type {
|
||||
CliAppInfo,
|
||||
@@ -593,11 +594,11 @@ describe("MessageBubble", () => {
|
||||
expect(screen.getByTestId("message-mcp-mention-logo-browserbase")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders persisted session mentions inside sent user messages", () => {
|
||||
it("renders new # session references as links", () => {
|
||||
const message: UIMessage = {
|
||||
id: "u-session",
|
||||
role: "user",
|
||||
content: "Use @收费设计 as context",
|
||||
content: "Use #收费设计",
|
||||
createdAt: Date.now(),
|
||||
sessionMentions: [{
|
||||
name: "收费设计",
|
||||
@@ -608,13 +609,113 @@ describe("MessageBubble", () => {
|
||||
|
||||
render(<MessageBubble message={message} />);
|
||||
|
||||
const token = screen.getByTestId("message-session-mention-收费设计");
|
||||
expect(token).toHaveTextContent("@收费设计");
|
||||
const token = screen.getByTestId("message-session-reference-收费设计");
|
||||
expect(token).toHaveTextContent("#收费设计");
|
||||
expect(token).toHaveAttribute("title", "Session: 收费设计");
|
||||
expect(token.closest("a")).toHaveAttribute("href", "#/chat/websocket%3Apricing");
|
||||
expect(token.closest("a")?.getAttribute("style")).toContain(
|
||||
"text-decoration-color: var(--inline-token-highlight)",
|
||||
});
|
||||
|
||||
it("prefers legacy @ session metadata over a same-name catalog capability", () => {
|
||||
const message: UIMessage = {
|
||||
id: "u-legacy-session",
|
||||
role: "user",
|
||||
content: "Review @zoom",
|
||||
createdAt: Date.now(),
|
||||
sessionMentions: [{
|
||||
name: "zoom",
|
||||
session_key: "websocket:zoom-notes",
|
||||
title: "Zoom notes",
|
||||
}],
|
||||
};
|
||||
|
||||
render(<MessageBubble message={message} cliApps={CLI_APPS} />);
|
||||
|
||||
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(<MessageBubble message={message} cliApps={CLI_APPS} />);
|
||||
|
||||
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(
|
||||
<MessageBubble message={message} sessionDirectory={[message.sessionMessage!.session]} />,
|
||||
);
|
||||
|
||||
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(<MessageBubble message={message} sessionDirectory={[]} />);
|
||||
|
||||
expect(screen.getByText("@noah")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("link", { name: "@noah" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("copies completed assistant replies from the action row", async () => {
|
||||
|
||||
@@ -504,7 +504,7 @@ describe("NanobotClient", () => {
|
||||
expect(handler).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("records goal_status run strip without an onChat subscriber", () => {
|
||||
it("records canonical run status without an onChat subscriber", () => {
|
||||
const client = new NanobotClient({
|
||||
url: "ws://test",
|
||||
reconnect: false,
|
||||
@@ -527,7 +527,50 @@ describe("NanobotClient", () => {
|
||||
expect(client.getRunStartedAt("chat-strip")).toBeNull();
|
||||
});
|
||||
|
||||
it("clears the local run strip immediately when a stop is requested", () => {
|
||||
it("starts the run projection immediately when a lifecycle message is submitted", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-08-13T10:00:00.000Z"));
|
||||
const client = new NanobotClient({
|
||||
url: "ws://test",
|
||||
reconnect: false,
|
||||
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
|
||||
});
|
||||
const handler = vi.fn();
|
||||
client.onRunStatus(handler);
|
||||
client.connect();
|
||||
lastSocket().fakeOpen();
|
||||
|
||||
client.sendMessage("chat-optimistic", "hello", undefined, {
|
||||
turnId: "turn-optimistic",
|
||||
});
|
||||
|
||||
const submittedAt = Date.now() / 1000;
|
||||
expect(client.getRunStartedAt("chat-optimistic")).toBe(submittedAt);
|
||||
expect(handler).toHaveBeenLastCalledWith("chat-optimistic", submittedAt);
|
||||
expect(client.hasUnsettledRun("chat-optimistic")).toBe(true);
|
||||
});
|
||||
|
||||
it("does not start a separate run projection for side-channel guidance", () => {
|
||||
const client = new NanobotClient({
|
||||
url: "ws://test",
|
||||
reconnect: false,
|
||||
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
|
||||
});
|
||||
const handler = vi.fn();
|
||||
client.onRunStatus(handler);
|
||||
client.connect();
|
||||
lastSocket().fakeOpen();
|
||||
|
||||
client.sendMessage("chat-guidance-only", "focus here", undefined, {
|
||||
turnId: "turn-guidance-only",
|
||||
startsNewRun: false,
|
||||
});
|
||||
|
||||
expect(client.getRunStartedAt("chat-guidance-only")).toBeNull();
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("clears the local run status immediately when a stop is requested", () => {
|
||||
const client = new NanobotClient({
|
||||
url: "ws://test",
|
||||
reconnect: false,
|
||||
@@ -552,7 +595,7 @@ describe("NanobotClient", () => {
|
||||
expect(handler).toHaveBeenLastCalledWith("chat-stop", null);
|
||||
});
|
||||
|
||||
it("clears stale run strip when reconnecting after a dropped socket", async () => {
|
||||
it("clears stale run status when reconnecting after a dropped socket", async () => {
|
||||
const client = new NanobotClient({
|
||||
url: "ws://test",
|
||||
reconnect: true,
|
||||
@@ -578,7 +621,7 @@ describe("NanobotClient", () => {
|
||||
expect(FakeSocket.instances.length).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it("clears run strip when a turn_end arrives without idle", () => {
|
||||
it("clears run status when a turn_end arrives without idle", () => {
|
||||
const client = new NanobotClient({
|
||||
url: "ws://test",
|
||||
reconnect: false,
|
||||
@@ -728,6 +771,7 @@ describe("NanobotClient", () => {
|
||||
expect(
|
||||
client.reconcileCanonicalCompletion("chat-rejected", requestGeneration, []),
|
||||
).toBe(true);
|
||||
expect(client.getRunStartedAt("chat-rejected")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not let an older rejection settle or stop a newer run", () => {
|
||||
@@ -2062,7 +2106,7 @@ describe("NanobotClient", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("includes session mentions in outbound messages", () => {
|
||||
it("keeps session references and handle mentions separate on the wire", () => {
|
||||
const client = new NanobotClient({
|
||||
url: "ws://test",
|
||||
reconnect: false,
|
||||
@@ -2071,23 +2115,35 @@ describe("NanobotClient", () => {
|
||||
client.connect();
|
||||
lastSocket().fakeOpen();
|
||||
|
||||
client.sendMessage("chat-current", "Use @pricing", undefined, {
|
||||
client.sendMessage("chat-current", "Use #pricing and ask @mira", undefined, {
|
||||
sessionMentions: [{
|
||||
name: "pricing",
|
||||
session_key: "websocket:pricing",
|
||||
title: "Pricing",
|
||||
}],
|
||||
sessionHandles: [{
|
||||
id: "handle_mira",
|
||||
name: "mira",
|
||||
session_key: "websocket:mira",
|
||||
color_slot: 3,
|
||||
}],
|
||||
});
|
||||
|
||||
expect(lastSocket().sent).toContain(JSON.stringify({
|
||||
type: "message",
|
||||
chat_id: "chat-current",
|
||||
content: "Use @pricing",
|
||||
content: "Use #pricing and ask @mira",
|
||||
session_mentions: [{
|
||||
name: "pricing",
|
||||
session_key: "websocket:pricing",
|
||||
title: "Pricing",
|
||||
}],
|
||||
session_handles: [{
|
||||
id: "handle_mira",
|
||||
name: "mira",
|
||||
session_key: "websocket:mira",
|
||||
color_slot: 3,
|
||||
}],
|
||||
webui: true,
|
||||
}));
|
||||
});
|
||||
|
||||
@@ -127,7 +127,12 @@ const MCP_PRESETS: McpPresetInfo[] = [
|
||||
},
|
||||
];
|
||||
|
||||
function session(chatId: string, title: string, preview = ""): ChatSummary {
|
||||
function session(
|
||||
chatId: string,
|
||||
title: string,
|
||||
preview = "",
|
||||
mentionName = title,
|
||||
): ChatSummary {
|
||||
return {
|
||||
key: `websocket:${chatId}`,
|
||||
channel: "websocket",
|
||||
@@ -136,6 +141,12 @@ function session(chatId: string, title: string, preview = ""): ChatSummary {
|
||||
updatedAt: null,
|
||||
title,
|
||||
preview,
|
||||
handle: {
|
||||
id: `handle_${chatId}`,
|
||||
name: mentionName,
|
||||
color_slot: 2,
|
||||
session_key: `websocket:${chatId}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1722,30 +1733,31 @@ describe("ThreadComposer", () => {
|
||||
|
||||
const input = screen.getByLabelText("Message input");
|
||||
fireEvent.change(input, {
|
||||
target: { value: "普通文字 @收费设计", selectionStart: 10 },
|
||||
target: { value: "普通文字 #收费设计", selectionStart: 10 },
|
||||
});
|
||||
expect(screen.queryByTestId("composer-session-mention-收费设计")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("composer-session-reference-收费设计")).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
|
||||
expect(onSend).toHaveBeenLastCalledWith("普通文字 @收费设计", undefined, undefined);
|
||||
expect(onSend).toHaveBeenLastCalledWith("普通文字 #收费设计", undefined, undefined);
|
||||
|
||||
fireEvent.change(input, {
|
||||
target: { value: "参考 @收费", selectionStart: 6 },
|
||||
target: { value: "参考 #收费", selectionStart: 6 },
|
||||
});
|
||||
|
||||
expect(screen.getByRole("group", { name: "Nanobot conversations" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("option", { name: /@收费设计/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole("option", { name: /^收费设计 #收费设计$/i }))
|
||||
.toBeInTheDocument();
|
||||
fireEvent.keyDown(input, { key: "Tab" });
|
||||
|
||||
expect(input).toHaveValue("参考 @收费设计 ");
|
||||
const mention = screen.getByTestId("composer-session-mention-收费设计");
|
||||
expect(mention).toHaveTextContent("@收费设计");
|
||||
expect(input).toHaveValue("参考 #收费设计 ");
|
||||
const mention = screen.getByTestId("composer-session-reference-收费设计");
|
||||
expect(mention).toHaveTextContent("#收费设计");
|
||||
expect(mention).toHaveClass("font-normal");
|
||||
expect(mention).not.toHaveClass("font-[550]");
|
||||
expect(mention.closest("a")).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
|
||||
|
||||
expect(onSend).toHaveBeenCalledWith("参考 @收费设计", undefined, {
|
||||
expect(onSend).toHaveBeenCalledWith("参考 #收费设计", undefined, {
|
||||
sessionMentions: [{
|
||||
name: "收费设计",
|
||||
session_key: "websocket:pricing",
|
||||
@@ -1754,6 +1766,198 @@ describe("ThreadComposer", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a selected session reference bound across title refreshes", () => {
|
||||
const onSend = vi.fn();
|
||||
const target = session("planning", "Plan");
|
||||
const { rerender } = render(
|
||||
<ThreadComposer
|
||||
onSend={onSend}
|
||||
placeholder="Type your message..."
|
||||
sessions={[target]}
|
||||
/>,
|
||||
);
|
||||
|
||||
const input = screen.getByLabelText("Message input");
|
||||
fireEvent.change(input, { target: { value: "#Pla", selectionStart: 4 } });
|
||||
fireEvent.keyDown(input, { key: "Tab" });
|
||||
|
||||
rerender(
|
||||
<ThreadComposer
|
||||
onSend={onSend}
|
||||
placeholder="Type your message..."
|
||||
sessions={[{ ...target, title: "Renamed plan" }]}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<ThreadComposer
|
||||
onSend={onSend}
|
||||
placeholder="Type your message..."
|
||||
sessions={[session("pricing", "收费设计", "讨论云存储")]}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<ThreadComposer
|
||||
onSend={onSend}
|
||||
placeholder="Type your message..."
|
||||
sessions={[session("pricing", "收费设计", "讨论云存储")]}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<ThreadComposer
|
||||
onSend={onSend}
|
||||
placeholder="Type your message..."
|
||||
cliApps={CLI_APPS}
|
||||
sessions={[session("blender-chat", "blender")]}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<ThreadComposer
|
||||
onSend={onSend}
|
||||
placeholder="Type your message..."
|
||||
sessions={[target]}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<ThreadComposer
|
||||
onSend={onSend}
|
||||
placeholder="Type your message..."
|
||||
sessions={[]}
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<ThreadComposer
|
||||
onSend={vi.fn()}
|
||||
placeholder="Type your message..."
|
||||
cliApps={CLI_APPS}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(<ThreadComposer onSend={vi.fn()} placeholder="Type your message..." />);
|
||||
|
||||
const input = screen.getByRole("combobox", { name: "Message input" });
|
||||
expect(input).toHaveAttribute("aria-autocomplete", "list");
|
||||
expect(input).toHaveAttribute("aria-expanded", "false");
|
||||
expect(input).not.toHaveAttribute("aria-controls");
|
||||
expect(input).not.toHaveAttribute("aria-activedescendant");
|
||||
});
|
||||
|
||||
it("turns a dropped sidebar session into the shared structured mention", () => {
|
||||
const onSend = vi.fn();
|
||||
render(
|
||||
@@ -1782,7 +1986,7 @@ describe("ThreadComposer", () => {
|
||||
|
||||
expect(input).toHaveValue("Compare notes");
|
||||
expect(screen.getByTestId("composer-session-drag-preview"))
|
||||
.toHaveTextContent("@收费设计");
|
||||
.toHaveTextContent("#收费设计");
|
||||
|
||||
fireEvent.dragEnd(document);
|
||||
expect(screen.queryByTestId("composer-session-drag-preview")).not.toBeInTheDocument();
|
||||
@@ -1792,13 +1996,13 @@ describe("ThreadComposer", () => {
|
||||
|
||||
fireEvent.drop(input, { dataTransfer });
|
||||
|
||||
expect(input).toHaveValue("Compare @收费设计 notes");
|
||||
expect(input).toHaveValue("Compare #收费设计 notes");
|
||||
expect(screen.queryByTestId("composer-session-drag-preview")).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId("composer-session-mention-收费设计"))
|
||||
.toHaveTextContent("@收费设计");
|
||||
expect(screen.getByTestId("composer-session-reference-收费设计"))
|
||||
.toHaveTextContent("#收费设计");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
|
||||
expect(onSend).toHaveBeenCalledWith("Compare @收费设计 notes", undefined, {
|
||||
expect(onSend).toHaveBeenCalledWith("Compare #收费设计 notes", undefined, {
|
||||
sessionMentions: [{
|
||||
name: "收费设计",
|
||||
session_key: "websocket:pricing",
|
||||
@@ -1829,17 +2033,19 @@ describe("ThreadComposer", () => {
|
||||
expect(screen.queryByTestId("composer-session-drag-preview")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("disambiguates duplicate and capability-colliding session names", () => {
|
||||
it("uses stable handle identities without exposing session titles", () => {
|
||||
const handles = [
|
||||
session("a", "First planning title", "", "Plan"),
|
||||
session("b", "Second planning title", "", "Plan-2"),
|
||||
session("blender-chat", "3D notes", "", "Blender"),
|
||||
];
|
||||
render(
|
||||
<ThreadComposer
|
||||
onSend={vi.fn()}
|
||||
placeholder="Type your message..."
|
||||
cliApps={CLI_APPS}
|
||||
mcpPresets={MCP_PRESETS}
|
||||
sessions={[
|
||||
...["a", "b"].map((chatId) => session(chatId, "Plan")),
|
||||
session("blender-chat", "Blender", "3D notes"),
|
||||
]}
|
||||
handleSessions={handles}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -1849,18 +2055,130 @@ describe("ThreadComposer", () => {
|
||||
const palette = screen.getByRole("listbox", { name: "Mentions" });
|
||||
expect(within(palette).getAllByRole("group").map((group) => (
|
||||
group.getAttribute("aria-label")
|
||||
))).toEqual(["CLI apps", "MCP services", "Nanobot conversations"]);
|
||||
const options = screen.getAllByRole("option", { name: /Plan @Plan/i });
|
||||
expect(options.map((option) => option.textContent)).toEqual([
|
||||
expect.stringContaining("@Plan"),
|
||||
expect.stringContaining("@Plan-chat"),
|
||||
]);
|
||||
expect(screen.getByRole("group", { name: "Nanobot conversations" })).toBeInTheDocument();
|
||||
))).toEqual(["Nanobot conversations", "CLI apps", "MCP services"]);
|
||||
const firstSession = screen.getByRole("option", { name: /^@Plan$/i });
|
||||
expect(firstSession).toHaveAttribute("aria-selected", "true");
|
||||
expect(input).toHaveAttribute("aria-activedescendant", firstSession.id);
|
||||
expect(screen.getByRole("option", { name: /^@Plan-2$/i }))
|
||||
.toBeInTheDocument();
|
||||
expect(screen.getByRole("group", { name: "Nanobot conversations" }))
|
||||
.toBeInTheDocument();
|
||||
expect(screen.getByRole("group", { name: "CLI apps" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("option", { name: /Blender @Blender-chat Reference/i }))
|
||||
expect(screen.getByRole("option", { name: /^@Blender$/i }))
|
||||
.toBeInTheDocument();
|
||||
expect(screen.getByRole("option", { name: /Blender @blender Use/i }))
|
||||
.toBeInTheDocument();
|
||||
expect(screen.queryByText("First planning title")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Second planning title")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("binds every same-name occurrence to one selected namespace across queue replay", () => {
|
||||
const onSend = vi.fn();
|
||||
const sameNameSession = session("blender-handle", "Session title", "", "blender");
|
||||
render(
|
||||
<ThreadComposer
|
||||
onSend={onSend}
|
||||
onStop={vi.fn()}
|
||||
isStreaming
|
||||
placeholder="Type your message..."
|
||||
cliApps={CLI_APPS}
|
||||
handleSessions={[sameNameSession]}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<ThreadComposer
|
||||
onSend={onSend}
|
||||
placeholder="Type your message..."
|
||||
cliApps={CLI_APPS}
|
||||
handleSessions={[handle]}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<ThreadComposer
|
||||
onSend={onSend}
|
||||
placeholder="Type your message..."
|
||||
cliApps={CLI_APPS}
|
||||
handleSessions={[]}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<ThreadComposer
|
||||
onSend={onSend}
|
||||
onStop={vi.fn()}
|
||||
isStreaming
|
||||
placeholder="Type your message..."
|
||||
mcpPresets={[constructorPreset]}
|
||||
/>,
|
||||
);
|
||||
|
||||
const input = screen.getByRole("combobox", { name: "Message input" });
|
||||
fireEvent.change(input, {
|
||||
target: { value: "use @constructor", selectionStart: 16 },
|
||||
});
|
||||
expect(screen.getByTestId("composer-mcp-mention-constructor")).toBeInTheDocument();
|
||||
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
expect(screen.getByText("use @constructor")).toBeInTheDocument();
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
expect(onSend).toHaveBeenCalledWith("use @constructor", undefined, {
|
||||
mcpPresets: [expect.objectContaining({ name: "constructor" })],
|
||||
continueActiveTurn: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("releases the eight-session limit when a mention is removed", () => {
|
||||
@@ -1878,11 +2196,21 @@ describe("ThreadComposer", () => {
|
||||
|
||||
const input = screen.getByLabelText("Message input") as HTMLTextAreaElement;
|
||||
for (let index = 0; index < 8; index += 1) {
|
||||
const value = `${input.value}${input.value ? " " : ""}@Topic${index}`;
|
||||
const value = `${input.value}${input.value ? " " : ""}#Topic${index}`;
|
||||
input.setSelectionRange(input.value.length, input.value.length);
|
||||
fireEvent.select(input);
|
||||
fireEvent.change(input, { target: { value, selectionStart: value.length } });
|
||||
fireEvent.keyDown(input, { key: "Tab" });
|
||||
}
|
||||
const replacement = `${input.value.replace("@Topic0 ", "")} @Topic8`;
|
||||
const withoutFirst = input.value.replace("#Topic0 ", "");
|
||||
input.setSelectionRange(0, "#Topic0 ".length);
|
||||
fireEvent.select(input);
|
||||
fireEvent.change(input, {
|
||||
target: { value: withoutFirst, selectionStart: 0 },
|
||||
});
|
||||
const replacement = `${withoutFirst}#Topic8`;
|
||||
input.setSelectionRange(withoutFirst.length, withoutFirst.length);
|
||||
fireEvent.select(input);
|
||||
fireEvent.change(input, {
|
||||
target: { value: replacement, selectionStart: replacement.length },
|
||||
});
|
||||
@@ -1896,7 +2224,7 @@ describe("ThreadComposer", () => {
|
||||
))).toEqual(expect.arrayContaining(["websocket:topic-8"]));
|
||||
});
|
||||
|
||||
it("keeps a selected session stable across refreshes and queued guidance", () => {
|
||||
it("keeps a selected handle mention when queuing guidance for the active turn", () => {
|
||||
const onSend = vi.fn();
|
||||
const target = session("z-target", "Plan", "Original plan");
|
||||
const { rerender } = render(
|
||||
@@ -1905,7 +2233,7 @@ describe("ThreadComposer", () => {
|
||||
onStop={vi.fn()}
|
||||
isStreaming
|
||||
placeholder="Type your message..."
|
||||
sessions={[target]}
|
||||
handleSessions={[target]}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -1919,22 +2247,26 @@ describe("ThreadComposer", () => {
|
||||
onStop={vi.fn()}
|
||||
isStreaming
|
||||
placeholder="Type your message..."
|
||||
sessions={[
|
||||
handleSessions={[
|
||||
{ ...target, title: "Renamed plan" },
|
||||
session("a-new", "Plan", target.preview),
|
||||
session("a-new", "Another title", target.preview, "Other"),
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("composer-session-mention-Plan")).toHaveTextContent("@Plan");
|
||||
expect(screen.getByTestId("composer-handle-mention-Plan")).toHaveTextContent("@Plan");
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
expect(
|
||||
within(screen.getByRole("group", { name: "Queued guidance" })).getByText("@Plan"),
|
||||
).toBeInTheDocument();
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Guide" }));
|
||||
|
||||
expect(onSend).toHaveBeenCalledWith("@Plan", undefined, {
|
||||
sessionMentions: [{
|
||||
sessionHandles: [{
|
||||
id: "handle_z-target",
|
||||
name: "Plan",
|
||||
session_key: "websocket:z-target",
|
||||
title: "Plan",
|
||||
color_slot: 2,
|
||||
}],
|
||||
continueActiveTurn: true,
|
||||
});
|
||||
@@ -1993,6 +2325,49 @@ describe("ThreadComposer", () => {
|
||||
expect(input).toHaveValue(`please use $${skillName} `);
|
||||
});
|
||||
|
||||
it("keeps a later session occurrence bound while completing an earlier skill", () => {
|
||||
const onSend = vi.fn();
|
||||
const skillName = "arxiv-intelligence-filter";
|
||||
render(
|
||||
<ThreadComposer
|
||||
onSend={onSend}
|
||||
placeholder="Type your message..."
|
||||
sessions={[session("plan", "Plan")]}
|
||||
skills={[{
|
||||
name: skillName,
|
||||
description: "Research papers",
|
||||
source: "builtin",
|
||||
enabled: true,
|
||||
available: true,
|
||||
}]}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<ThreadComposer
|
||||
@@ -3043,6 +3418,38 @@ describe("ThreadComposer", () => {
|
||||
});
|
||||
});
|
||||
|
||||
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(
|
||||
<ThreadComposer
|
||||
onSend={vi.fn()}
|
||||
onStop={vi.fn()}
|
||||
isStreaming
|
||||
pendingQueueKey="chat-a"
|
||||
placeholder="Type your message..."
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(await screen.findByText("keep this older queued prompt")).toBeInTheDocument();
|
||||
expect(window.localStorage.getItem(legacyKey)).toBeNull();
|
||||
expect(JSON.parse(window.localStorage.getItem(currentKey) ?? "[]"))
|
||||
.toEqual([expect.objectContaining({
|
||||
id: "legacy-guidance",
|
||||
text: "keep this older queued prompt",
|
||||
})]);
|
||||
});
|
||||
|
||||
it("keeps temporary chat guidance in memory only", async () => {
|
||||
const onSend = vi.fn();
|
||||
const view = render(
|
||||
@@ -3062,7 +3469,7 @@ describe("ThreadComposer", () => {
|
||||
expect(await screen.findByText("do not persist this")).toBeInTheDocument();
|
||||
expect(
|
||||
window.localStorage.getItem(
|
||||
"nanobot.webui.composerQueuedGuidance.v1:temporary-private",
|
||||
"nanobot.webui.composerQueuedGuidance.v2:temporary-private",
|
||||
),
|
||||
).toBeNull();
|
||||
|
||||
@@ -3082,7 +3489,7 @@ describe("ThreadComposer", () => {
|
||||
});
|
||||
expect(
|
||||
window.localStorage.getItem(
|
||||
"nanobot.webui.composerQueuedGuidance.v1:temporary-private",
|
||||
"nanobot.webui.composerQueuedGuidance.v2:temporary-private",
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
@@ -21,6 +21,7 @@ function makeClient() {
|
||||
(modelName: string | null, modelPreset?: string | null) => void
|
||||
>();
|
||||
const sessionUpdateHandlers = new Set<(chatId: string, scope?: string) => void>();
|
||||
const runStatusHandlers = new Set<(chatId: string, startedAt: number | null) => void>();
|
||||
const runStartedAtByChatId = new Map<string, number>();
|
||||
const runGenerationByChatId = new Map<string, number>();
|
||||
const latestRunTurnIdByChatId = new Map<string, string>();
|
||||
@@ -108,6 +109,13 @@ function makeClient() {
|
||||
},
|
||||
getRunStartedAt: (chatId: string) => runStartedAtByChatId.get(chatId) ?? null,
|
||||
getRunTurnId: (chatId: string) => latestRunTurnIdByChatId.get(chatId) ?? null,
|
||||
onRunStatus: (handler: (chatId: string, startedAt: number | null) => void) => {
|
||||
runStatusHandlers.add(handler);
|
||||
for (const [chatId, startedAt] of runStartedAtByChatId) handler(chatId, startedAt);
|
||||
return () => {
|
||||
runStatusHandlers.delete(handler);
|
||||
};
|
||||
},
|
||||
finishRunLocally: vi.fn((chatId: string) => {
|
||||
runStartedAtByChatId.delete(chatId);
|
||||
latestRunTurnIdByChatId.delete(chatId);
|
||||
@@ -417,6 +425,164 @@ describe("ThreadShell", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the current handle handle visible in the thread header", async () => {
|
||||
const client = makeClient();
|
||||
const currentSession = {
|
||||
...session("handle-handle"),
|
||||
handle: {
|
||||
id: "handle-current",
|
||||
name: "mira",
|
||||
session_key: "websocket:handle-handle",
|
||||
color_slot: 3,
|
||||
},
|
||||
};
|
||||
|
||||
render(wrap(
|
||||
client,
|
||||
<ThreadShell
|
||||
session={currentSession}
|
||||
title="A title that may change independently"
|
||||
onToggleSidebar={() => {}}
|
||||
/>,
|
||||
));
|
||||
|
||||
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,
|
||||
<ThreadShell
|
||||
session={currentSession}
|
||||
title="Investigate incoming messages"
|
||||
onToggleSidebar={() => {}}
|
||||
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,
|
||||
<ThreadShell
|
||||
session={source}
|
||||
sessions={[source, reviewer]}
|
||||
title="Source"
|
||||
onToggleSidebar={() => {}}
|
||||
/>,
|
||||
));
|
||||
|
||||
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,
|
||||
<ThreadShell
|
||||
session={source}
|
||||
sessions={[source]}
|
||||
title="Source"
|
||||
onToggleSidebar={() => {}}
|
||||
/>,
|
||||
));
|
||||
|
||||
const input = await screen.findByLabelText("Message input") as HTMLTextAreaElement;
|
||||
fireEvent.change(input, { target: { value: "@be", selectionStart: 3 } });
|
||||
fireEvent.keyDown(input, { key: "Tab" });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
|
||||
|
||||
expect(client.sendMessage).toHaveBeenCalledWith(
|
||||
source.chatId,
|
||||
"@bea",
|
||||
undefined,
|
||||
expect.objectContaining({
|
||||
sessionHandles: [source.handle],
|
||||
turnId: expect.any(String),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps inferred file paths non-interactive when the availability probe fails", async () => {
|
||||
await preloadMarkdownText();
|
||||
const client = makeClient();
|
||||
@@ -787,7 +953,7 @@ describe("ThreadShell", () => {
|
||||
fireEvent.click(badge);
|
||||
expect(onOpenModelSettings).toHaveBeenCalledTimes(1);
|
||||
|
||||
fireEvent.change(screen.getByRole("textbox", { name: "Message input" }), {
|
||||
fireEvent.change(screen.getByRole("combobox", { name: "Message input" }), {
|
||||
target: { value: "hello" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Configure model" }));
|
||||
@@ -943,6 +1109,39 @@ describe("ThreadShell", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("does not offer persisted sessions inside a temporary chat", async () => {
|
||||
const client = makeClient();
|
||||
const handle = {
|
||||
...session("handle"),
|
||||
title: "Reviewer",
|
||||
handle: {
|
||||
id: "handle_11111111111111111111111111111111",
|
||||
name: "reviewer",
|
||||
color_slot: 3,
|
||||
session_key: "websocket:handle",
|
||||
},
|
||||
};
|
||||
render(wrap(
|
||||
client,
|
||||
<ThreadShell
|
||||
session={session("temporary")}
|
||||
sessions={[handle]}
|
||||
title="Temporary chat"
|
||||
temporary
|
||||
temporaryChatIds={["temporary"]}
|
||||
onToggleSidebar={() => {}}
|
||||
/>,
|
||||
));
|
||||
|
||||
const input = await screen.findByLabelText("Message input");
|
||||
await act(async () => {
|
||||
fireEvent.change(input, { target: { value: "@", selectionStart: 1 } });
|
||||
});
|
||||
|
||||
expect(screen.queryByRole("group", { name: "Nanobot conversations" }))
|
||||
.not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("highlights sent skill references without skill metadata", async () => {
|
||||
const client = makeClient();
|
||||
render(wrap(
|
||||
@@ -2052,7 +2251,7 @@ describe("ThreadShell", () => {
|
||||
);
|
||||
|
||||
await waitFor(() => expect(historyCalls).toBe(1));
|
||||
const input = screen.getByRole("textbox", { name: "Message input" });
|
||||
const input = screen.getByRole("combobox", { name: "Message input" });
|
||||
fireEvent.change(input, { target: { value: "rejected local turn" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
|
||||
await waitFor(() => expect(screen.getByText("rejected local turn")).toBeInTheDocument());
|
||||
@@ -2289,7 +2488,7 @@ describe("ThreadShell", () => {
|
||||
act(() => client._emitSessionUpdate("chat-version-a"));
|
||||
await waitFor(() => expect(chatACalls).toBe(2));
|
||||
|
||||
fireEvent.change(screen.getByRole("textbox", { name: "Message input" }), {
|
||||
fireEvent.change(screen.getByRole("combobox", { name: "Message input" }), {
|
||||
target: { value: "new question" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
|
||||
@@ -2390,7 +2589,7 @@ describe("ThreadShell", () => {
|
||||
turn_id: newTurnId,
|
||||
});
|
||||
});
|
||||
const input = screen.getByRole("textbox", { name: "Message input" });
|
||||
const input = screen.getByRole("combobox", { name: "Message input" });
|
||||
fireEvent.change(input, { target: { value: "queued for the new run" } });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
expect(client.sendMessage).not.toHaveBeenCalled();
|
||||
@@ -2689,7 +2888,7 @@ describe("ThreadShell", () => {
|
||||
turn_id: turnId,
|
||||
});
|
||||
});
|
||||
const input = screen.getByRole("textbox", { name: "Message input" });
|
||||
const input = screen.getByRole("combobox", { name: "Message input" });
|
||||
fireEvent.change(input, { target: { value: "queued guidance" } });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
expect(client.sendMessage).not.toHaveBeenCalled();
|
||||
@@ -2792,7 +2991,7 @@ describe("ThreadShell", () => {
|
||||
});
|
||||
});
|
||||
await waitFor(() => expect(screen.getByText("partial answer")).toBeInTheDocument());
|
||||
const input = screen.getByRole("textbox", { name: "Message input" });
|
||||
const input = screen.getByRole("combobox", { name: "Message input" });
|
||||
fireEvent.change(input, { target: { value: "queued guidance" } });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
expect(screen.getByText("queued guidance")).toBeInTheDocument();
|
||||
@@ -2888,7 +3087,7 @@ describe("ThreadShell", () => {
|
||||
});
|
||||
|
||||
await waitFor(() => expect(screen.getByText("Continuing the search.")).toBeInTheDocument());
|
||||
const input = screen.getByRole("textbox", { name: "Message input" });
|
||||
const input = screen.getByRole("combobox", { name: "Message input" });
|
||||
fireEvent.change(input, { target: { value: "How is it going?" } });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
@@ -3930,41 +4129,56 @@ describe("ThreadShell", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("offers only same-project sessions in restricted mode", async () => {
|
||||
const client = makeClient();
|
||||
const currentScope = {
|
||||
project_path: "/projects/current",
|
||||
access_mode: "restricted" as const,
|
||||
};
|
||||
const sameProject = {
|
||||
...session("same-project"),
|
||||
title: "Same project",
|
||||
workspaceScope: currentScope,
|
||||
};
|
||||
const otherProject = {
|
||||
...session("other-project"),
|
||||
title: "Other project",
|
||||
workspaceScope: {
|
||||
project_path: "/projects/other",
|
||||
access_mode: "restricted" as const,
|
||||
},
|
||||
};
|
||||
it.each(["restricted", "full"] as const)(
|
||||
"offers routable sessions across projects in %s mode",
|
||||
async (accessMode) => {
|
||||
const client = makeClient();
|
||||
const currentScope = {
|
||||
project_path: "/projects/current",
|
||||
access_mode: accessMode,
|
||||
};
|
||||
const sameProject = {
|
||||
...session("same-project"),
|
||||
title: "Same project",
|
||||
workspaceScope: currentScope,
|
||||
handle: {
|
||||
id: "handle_same_project",
|
||||
name: "same-project",
|
||||
color_slot: 1,
|
||||
session_key: "websocket:same-project",
|
||||
},
|
||||
};
|
||||
const otherProject = {
|
||||
...session("other-project"),
|
||||
title: "Other project",
|
||||
workspaceScope: {
|
||||
project_path: "/projects/other",
|
||||
access_mode: accessMode,
|
||||
},
|
||||
handle: {
|
||||
id: "handle_other_project",
|
||||
name: "other-project",
|
||||
color_slot: 2,
|
||||
session_key: "websocket:other-project",
|
||||
},
|
||||
};
|
||||
|
||||
render(wrap(
|
||||
client,
|
||||
<ThreadShell
|
||||
session={session("current")}
|
||||
sessions={[sameProject, otherProject]}
|
||||
title="Current"
|
||||
onToggleSidebar={() => {}}
|
||||
workspaceScope={currentScope}
|
||||
/>,
|
||||
));
|
||||
render(wrap(
|
||||
client,
|
||||
<ThreadShell
|
||||
session={session("current")}
|
||||
sessions={[sameProject, otherProject]}
|
||||
title="Current"
|
||||
onToggleSidebar={() => {}}
|
||||
workspaceScope={currentScope}
|
||||
/>,
|
||||
));
|
||||
|
||||
const input = await screen.findByLabelText("Message input");
|
||||
fireEvent.change(input, { target: { value: "@", selectionStart: 1 } });
|
||||
const input = await screen.findByLabelText("Message input");
|
||||
fireEvent.change(input, { target: { value: "@", selectionStart: 1 } });
|
||||
|
||||
expect(screen.getByRole("option", { name: /Same project/i })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("option", { name: /Other project/i })).not.toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByRole("option", { name: /^@same-project$/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole("option", { name: /^@other-project$/i })).toBeInTheDocument();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -215,7 +215,7 @@ function ViewportWithPromptNavigator({ messages }: { messages: UIMessage[] }) {
|
||||
}
|
||||
|
||||
describe("ThreadViewport", () => {
|
||||
it("keeps reasoning disclosure anchored for pointer and keyboard toggles", () => {
|
||||
it("keeps unmanaged reasoning disclosure anchored for pointer and keyboard toggles", () => {
|
||||
const takeUserControl = vi.spyOn(
|
||||
ThreadMotionCoordinator.prototype,
|
||||
"takeUserControl",
|
||||
|
||||
@@ -38,6 +38,8 @@ const SEMANTIC_MESSAGE_FIELDS = [
|
||||
"cliApps",
|
||||
"mcpPresets",
|
||||
"sessionMentions",
|
||||
"sessionHandles",
|
||||
"handle",
|
||||
"reasoning",
|
||||
"latencyMs",
|
||||
"source",
|
||||
@@ -70,6 +72,7 @@ function fakeClient() {
|
||||
const handlers = new Map<string, Set<(ev: InboundEvent) => 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<string, number>();
|
||||
const unsettledRunByChatId = new Map<string, boolean>();
|
||||
const goalStateByChatId = new Map<string, GoalStateWsPayload>();
|
||||
@@ -113,6 +116,13 @@ function fakeClient() {
|
||||
errorHandlers.add(handler);
|
||||
return () => errorHandlers.delete(handler);
|
||||
},
|
||||
onRunStatus(handler: (chatId: string, startedAt: number | null) => void) {
|
||||
runStatusHandlers.add(handler);
|
||||
for (const [chatId, startedAt] of runStartedAtByChatId) {
|
||||
handler(chatId, startedAt);
|
||||
}
|
||||
return () => runStatusHandlers.delete(handler);
|
||||
},
|
||||
getRunStartedAt(chatId: string) {
|
||||
const v = runStartedAtByChatId.get(chatId);
|
||||
return v === undefined ? null : v;
|
||||
@@ -154,6 +164,11 @@ function fakeClient() {
|
||||
emitError(error: StreamError) {
|
||||
errorHandlers.forEach((handler) => handler(error));
|
||||
},
|
||||
emitRunStatus(chatId: string, startedAt: number | null) {
|
||||
if (startedAt === null) runStartedAtByChatId.delete(chatId);
|
||||
else runStartedAtByChatId.set(chatId, startedAt);
|
||||
runStatusHandlers.forEach((handler) => handler(chatId, startedAt));
|
||||
},
|
||||
setUnsettled(chatId: string, unsettled: boolean) {
|
||||
unsettledRunByChatId.set(chatId, unsettled);
|
||||
},
|
||||
@@ -182,6 +197,101 @@ async function flushStreamFrame() {
|
||||
}
|
||||
|
||||
describe("useNanobotStream", () => {
|
||||
it("keeps a handle mention on the focused chat's optimistic and outbound turn", () => {
|
||||
const fake = fakeClient();
|
||||
const { result } = renderHook(
|
||||
() => useNanobotStream("chat-source", EMPTY_MESSAGES),
|
||||
{ wrapper: wrap(fake.client) },
|
||||
);
|
||||
const reviewer = {
|
||||
id: "handle_00000000000000000000000000000001",
|
||||
name: "reviewer",
|
||||
session_key: "websocket:chat-reviewer",
|
||||
color_slot: 3,
|
||||
};
|
||||
|
||||
act(() => {
|
||||
result.current.send("@reviewer check this", undefined, {
|
||||
sessionHandles: [reviewer],
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.messages).toEqual([
|
||||
expect.objectContaining({
|
||||
role: "user",
|
||||
content: "@reviewer check this",
|
||||
deliveryStatus: "sending",
|
||||
sessionHandles: [reviewer],
|
||||
}),
|
||||
]);
|
||||
expect(result.current.isStreaming).toBe(true);
|
||||
expect(fake.client.sendMessage).toHaveBeenCalledWith(
|
||||
"chat-source",
|
||||
"@reviewer check this",
|
||||
undefined,
|
||||
expect.objectContaining({
|
||||
sessionHandles: [reviewer],
|
||||
turnId: expect.any(String),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("renders an incoming handle message before the target model responds", async () => {
|
||||
const fake = fakeClient();
|
||||
const { result } = renderHook(
|
||||
() => useNanobotStream("chat-handle", EMPTY_MESSAGES),
|
||||
{ wrapper: wrap(fake.client) },
|
||||
);
|
||||
const sessionMessageEvent: InboundEvent = {
|
||||
event: "session_message",
|
||||
chat_id: "chat-handle",
|
||||
text: "What did you change?",
|
||||
created_at_ms: 1_234,
|
||||
turn_id: "handle-turn-1",
|
||||
turn_phase: "user",
|
||||
session_message: {
|
||||
direction: "incoming",
|
||||
message_id: "handle-message-1",
|
||||
session: {
|
||||
id: "handle_11111111111111111111111111111111",
|
||||
name: "kai",
|
||||
session_key: "websocket:source",
|
||||
color_slot: 2,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
act(() => {
|
||||
fake.emit("chat-handle", sessionMessageEvent);
|
||||
fake.emit("chat-handle", sessionMessageEvent);
|
||||
});
|
||||
|
||||
expect(result.current.messages).toHaveLength(1);
|
||||
expect(result.current.messages[0]).toMatchObject({
|
||||
id: "session-message:handle-message-1",
|
||||
role: "user",
|
||||
content: "What did you change?",
|
||||
createdAt: 1_234,
|
||||
turnId: "handle-turn-1",
|
||||
turnPhase: "user",
|
||||
sessionMessage: sessionMessageEvent.session_message,
|
||||
});
|
||||
expect(result.current.isStreaming).toBe(true);
|
||||
|
||||
act(() => fake.emit("chat-handle", {
|
||||
event: "delta",
|
||||
chat_id: "chat-handle",
|
||||
text: "I changed",
|
||||
turn_id: "handle-turn-1",
|
||||
}));
|
||||
await flushStreamFrame();
|
||||
|
||||
expect(result.current.messages.map((message) => message.role)).toEqual([
|
||||
"user",
|
||||
"assistant",
|
||||
]);
|
||||
});
|
||||
|
||||
it("batches answer deltas into one animation-frame update", async () => {
|
||||
const fake = fakeClient();
|
||||
const requestFrame = vi.spyOn(window, "requestAnimationFrame");
|
||||
@@ -2865,6 +2975,28 @@ describe("useNanobotStream", () => {
|
||||
expect(result.current.isStreaming).toBe(false);
|
||||
});
|
||||
|
||||
it("clears the pane timer when canonical reconciliation settles the client run", () => {
|
||||
const fake = fakeClient();
|
||||
const { result } = renderHook(() => useNanobotStream("chat-g", EMPTY_MESSAGES), {
|
||||
wrapper: wrap(fake.client),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
fake.emit("chat-g", {
|
||||
event: "goal_status",
|
||||
chat_id: "chat-g",
|
||||
status: "running",
|
||||
started_at: 1700,
|
||||
turn_id: "handle:turn-1",
|
||||
});
|
||||
});
|
||||
expect(result.current.runStartedAt).toBe(1700);
|
||||
|
||||
act(() => fake.emitRunStatus("chat-g", null));
|
||||
|
||||
expect(result.current.runStartedAt).toBeNull();
|
||||
});
|
||||
|
||||
it("restores runStartedAt after switching away and back when goal_status was recorded without a subscriber", () => {
|
||||
const fake = fakeClient();
|
||||
const { result, rerender } = renderHook(
|
||||
|
||||
Reference in New Issue
Block a user