feat(session): add cross-session references

This commit is contained in:
Xubin Ren
2026-08-04 12:14:51 +08:00
parent 44b7e1bf41
commit 9b25da7b92
31 changed files with 1196 additions and 110 deletions
+1
View File
@@ -2088,6 +2088,7 @@ function Shell({
>
<ThreadShell
session={activeSession}
sessions={sessions}
title={headerTitle}
onToggleSidebar={toggleSidebar}
onNewChat={onNewChat}
+52 -9
View File
@@ -7,7 +7,7 @@ import {
} from "@/components/InlineTokenHighlight";
import { useLogoFallback } from "@/hooks/useLogoFallback";
import { logoFallbackUrls } from "@/lib/provider-brand";
import type { CliAppInfo, McpPresetInfo } from "@/lib/types";
import type { CliAppInfo, McpPresetInfo, SessionMention } from "@/lib/types";
import { cn } from "@/lib/utils";
type CliAppMentionSegment =
@@ -16,7 +16,8 @@ type CliAppMentionSegment =
export type CapabilityMentionSegment =
| CliAppMentionSegment
| { kind: "mcp"; text: string; preset: McpPresetInfo };
| { kind: "mcp"; text: string; preset: McpPresetInfo }
| { kind: "session"; text: string; mention: SessionMention };
export function cliAppInitials(app: CliAppInfo): string {
const value = app.display_name || app.name;
@@ -44,8 +45,9 @@ export function splitCapabilityMentionSegments(
value: string,
cliApps: CliAppInfo[],
mcpPresets: McpPresetInfo[] = [],
sessionMentions: SessionMention[] = [],
): CapabilityMentionSegment[] {
if (!value || (cliApps.length === 0 && mcpPresets.length === 0)) {
if (!value || (cliApps.length === 0 && mcpPresets.length === 0 && sessionMentions.length === 0)) {
return value ? [{ kind: "text", text: value }] : [];
}
const cliAppsByName = new Map(
@@ -58,12 +60,15 @@ export function splitCapabilityMentionSegments(
.filter((preset) => preset.installed && preset.configured)
.map((preset) => [preset.name.toLowerCase(), preset]),
);
if (cliAppsByName.size === 0 && mcpPresetsByName.size === 0) {
const sessionsByName = new Map(
sessionMentions.map((mention) => [mention.name.toLowerCase(), mention]),
);
if (cliAppsByName.size === 0 && mcpPresetsByName.size === 0 && sessionsByName.size === 0) {
return [{ kind: "text", text: value }];
}
const segments: CapabilityMentionSegment[] = [];
const mentionRe = /(^|[\s([{])@([a-z0-9_-]+)\b/gi;
const mentionRe = /(^|[\s([{])@([\p{L}\p{N}_-]+)(?=$|[^\p{L}\p{N}_-])/giu;
let cursor = 0;
let match: RegExpExecArray | null;
while ((match = mentionRe.exec(value)) !== null) {
@@ -72,7 +77,8 @@ export function splitCapabilityMentionSegments(
const key = name.toLowerCase();
const app = cliAppsByName.get(key);
const preset = app ? null : mcpPresetsByName.get(key);
if (!app && !preset) continue;
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;
@@ -83,6 +89,12 @@ 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,
});
}
cursor = mentionEnd;
}
@@ -96,13 +108,15 @@ export function CliAppMentionText({
text,
cliApps,
mcpPresets = [],
sessionMentions = [],
}: {
text: string;
cliApps: CliAppInfo[];
mcpPresets?: McpPresetInfo[];
sessionMentions?: SessionMention[];
}) {
const segments = splitCapabilityMentionSegments(text, cliApps, mcpPresets);
if (!segments.some((segment) => segment.kind === "cli" || segment.kind === "mcp")) return <>{text}</>;
const segments = splitCapabilityMentionSegments(text, cliApps, mcpPresets, sessionMentions);
if (!segments.some((segment) => segment.kind !== "text")) return <>{text}</>;
return (
<>
{segments.map((segment, index) => {
@@ -117,7 +131,7 @@ export function CliAppMentionText({
variant="message"
/>
);
return (
if (segment.kind === "mcp") return (
<McpPresetMentionToken
key={`mcp-${segment.preset.name}-${index}`}
preset={segment.preset}
@@ -125,11 +139,40 @@ export function CliAppMentionText({
variant="message"
/>
);
return (
<SessionMentionToken
key={`session-${segment.mention.session_key}-${index}`}
mention={segment.mention}
label={segment.text}
variant="message"
/>
);
})}
</>
);
}
export function SessionMentionToken({
mention,
label,
variant,
}: {
mention: SessionMention;
label: string;
variant: "composer" | "message";
}) {
const testIdPrefix = variant === "composer" ? "composer" : "message";
return (
<InlineTokenHighlight
testId={`${testIdPrefix}-session-mention-${mention.name}`}
title={`Session: ${mention.title || mention.name}`}
color={INLINE_TOKEN_HIGHLIGHT_COLOR}
>
{label}
</InlineTokenHighlight>
);
}
export function CliAppMentionToken({
app,
label,
+2
View File
@@ -265,6 +265,7 @@ export function MessageBubble({
text={userContent.slice(slashCommand.command.length)}
cliApps={mentionCliApps}
mcpPresets={mentionMcpPresets}
sessionMentions={message.sessionMentions}
/>
</>
) : (
@@ -272,6 +273,7 @@ export function MessageBubble({
text={userContent}
cliApps={mentionCliApps}
mcpPresets={mentionMcpPresets}
sessionMentions={message.sessionMentions}
/>
);
return (
+21 -4
View File
@@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next";
import {
CliAppMentionToken,
McpPresetMentionToken,
SessionMentionToken,
splitCapabilityMentionSegments,
type CapabilityMentionSegment,
} from "@/components/CliAppMentionText";
@@ -11,7 +12,7 @@ import {
INLINE_TOKEN_HIGHLIGHT_COLOR,
InlineTokenHighlight,
} from "@/components/InlineTokenHighlight";
import type { CliAppInfo, McpPresetInfo } from "@/lib/types";
import type { CliAppInfo, McpPresetInfo, SessionMention } from "@/lib/types";
type SkillReferenceSegment =
| { kind: "text"; text: string }
@@ -49,9 +50,15 @@ function splitUserMessageSegments(
value: string,
cliApps: CliAppInfo[],
mcpPresets: McpPresetInfo[],
sessionMentions: SessionMention[],
): UserMessageSegment[] {
const segments: UserMessageSegment[] = [];
for (const segment of splitCapabilityMentionSegments(value, cliApps, mcpPresets)) {
for (const segment of splitCapabilityMentionSegments(
value,
cliApps,
mcpPresets,
sessionMentions,
)) {
if (segment.kind === "text") {
segments.push(...splitSkillReferenceSegments(segment.text));
} else {
@@ -65,13 +72,15 @@ export function UserMessageText({
text,
cliApps,
mcpPresets,
sessionMentions = [],
}: {
text: string;
cliApps: CliAppInfo[];
mcpPresets: McpPresetInfo[];
sessionMentions?: SessionMention[];
}) {
const { t } = useTranslation();
const segments = splitUserMessageSegments(text, cliApps, mcpPresets);
const segments = splitUserMessageSegments(text, cliApps, mcpPresets, sessionMentions);
return (
<>
{segments.map((segment, index) => {
@@ -97,7 +106,7 @@ export function UserMessageText({
variant="message"
/>
);
return (
if (segment.kind === "mcp") return (
<McpPresetMentionToken
key={`mcp-${segment.preset.name}-${index}`}
preset={segment.preset}
@@ -105,6 +114,14 @@ export function UserMessageText({
variant="message"
/>
);
return (
<SessionMentionToken
key={`session-${segment.mention.session_key}-${index}`}
mention={segment.mention}
label={segment.text}
variant="message"
/>
);
})}
</>
);
+209 -69
View File
@@ -13,6 +13,7 @@ import { MarkdownText, preloadMarkdownText } from "@/components/MarkdownText";
import {
CliAppMentionToken,
McpPresetMentionToken,
SessionMentionToken,
cliAppInitials,
mcpPresetInitials,
splitCapabilityMentionSegments,
@@ -33,6 +34,7 @@ import {
History,
ImageIcon,
Loader2,
MessageCircle,
Mic,
Plus,
Quote,
@@ -81,10 +83,12 @@ import { usePageVisibility } from "@/hooks/usePageVisibility";
import { useVoiceRecorder, type VoiceRecorderErrorKey } from "@/hooks/useVoiceRecorder";
import type {
CliAppInfo,
ChatSummary,
GoalStateWsPayload,
McpPresetInfo,
OutboundCliAppMention,
OutboundMcpPresetMention,
SessionMention,
SlashCommand,
SkillSummary,
WebUIIngressLimits,
@@ -184,6 +188,7 @@ interface ThreadComposerProps {
slashCommands?: SlashCommand[];
cliApps?: CliAppInfo[];
mcpPresets?: McpPresetInfo[];
sessions?: ChatSummary[];
skills?: SkillSummary[];
onStop?: () => void;
onTranscribeAudio?: (dataUrl: string, options?: { durationMs?: number }) => Promise<string>;
@@ -296,7 +301,44 @@ interface CliAppMentionQuery {
type MentionCandidate =
| { kind: "cli"; name: string; app: CliAppInfo }
| { kind: "mcp"; name: string; preset: McpPresetInfo };
| { kind: "mcp"; name: string; preset: McpPresetInfo }
| { kind: "session"; name: string; mention: SessionMention };
function sessionMentionBase(session: ChatSummary): string {
const label = session.title?.trim() || session.preview.trim() || "session";
const slug = label
.normalize("NFKC")
.replace(/\s+/g, "-")
.replace(/[^\p{L}\p{N}_-]+/gu, "")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "");
return Array.from(slug || "session").slice(0, 40).join("");
}
function sessionMentionOptions(
sessions: ChatSummary[],
reservedNames: string[],
): SessionMention[] {
const used = new Set(reservedNames.map((name) => name.toLowerCase()));
const namesByKey = new Map<string, string>();
for (const session of [...sessions].sort((a, b) => a.key.localeCompare(b.key))) {
const base = sessionMentionBase(session);
let name = base;
let suffix = 2;
if (used.has(name.toLowerCase())) name = `${base}-chat`;
while (used.has(name.toLowerCase())) {
name = `${base}-chat-${suffix}`;
suffix += 1;
}
used.add(name.toLowerCase());
namesByKey.set(session.key, name);
}
return sessions.map((session) => ({
name: namesByKey.get(session.key) ?? sessionMentionBase(session),
session_key: session.key,
title: session.title?.trim() || session.preview.trim(),
}));
}
interface SlashPaletteCommand {
command: string;
@@ -834,6 +876,7 @@ export function ThreadComposer({
slashCommands = [],
cliApps = [],
mcpPresets = [],
sessions = [],
skills = [],
onStop,
onTranscribeAudio,
@@ -1155,7 +1198,7 @@ export function ThreadComposer({
if (disabled || cliAppMenuDismissed) return null;
const caret = Math.min(Math.max(cursorPosition, 0), value.length);
const beforeCaret = value.slice(0, caret);
const match = /(?:^|\s)@([a-z0-9_-]*)$/i.exec(beforeCaret);
const match = /(?:^|\s)@([\p{L}\p{N}_-]*)$/iu.exec(beforeCaret);
if (!match) return null;
const query = match[1].toLowerCase();
return {
@@ -1165,8 +1208,30 @@ export function ThreadComposer({
};
}, [cliAppMenuDismissed, cursorPosition, disabled, value]);
const availableSessionMentions = useMemo(
() => sessionMentionOptions(
sessions,
[
...cliApps.filter((app) => app.installed).map((app) => app.name),
...mcpPresets
.filter((preset) => preset.installed && preset.configured)
.map((preset) => preset.name),
],
),
[cliApps, mcpPresets, sessions],
);
const filteredMentionCandidates = useMemo<MentionCandidate[]>(() => {
if (!cliAppMention) return [];
const sessionCandidates: MentionCandidate[] = availableSessionMentions
.filter((mention) => [
mention.name,
mention.title,
].join(" ").toLowerCase().includes(cliAppMention.query))
.map((mention) => ({
kind: "session",
name: mention.name,
mention,
}));
const cliCandidates: MentionCandidate[] = cliApps
.filter((app) => app.installed)
.filter((app) => {
@@ -1193,17 +1258,30 @@ export function ThreadComposer({
return haystack.includes(cliAppMention.query);
})
.map((preset) => ({ kind: "mcp", name: preset.name, preset }));
return [...cliCandidates, ...mcpCandidates].slice(0, 8);
}, [cliAppMention, cliApps, mcpPresets]);
const groups = [sessionCandidates, cliCandidates, mcpCandidates];
const limits = groups.map((group, index) => Math.min(group.length, [4, 2, 2][index]));
let remaining = 8 - limits.reduce((total, limit) => total + limit, 0);
for (let index = 0; index < groups.length && remaining > 0; index += 1) {
const extra = Math.min(groups[index].length - limits[index], remaining);
limits[index] += extra;
remaining -= extra;
}
return groups.flatMap((group, index) => group.slice(0, limits[index]));
}, [availableSessionMentions, cliAppMention, cliApps, mcpPresets]);
const showCliAppMenu = filteredMentionCandidates.length > 0;
const showAnyPalette = showSlashMenu || showCliAppMenu;
const mentionSegments = useMemo(
() => splitCapabilityMentionSegments(value, cliApps, mcpPresets),
[cliApps, mcpPresets, value],
() => splitCapabilityMentionSegments(
value,
cliApps,
mcpPresets,
availableSessionMentions,
),
[availableSessionMentions, cliApps, mcpPresets, value],
);
const hasMentionDecorations = mentionSegments.some(
(segment) => segment.kind === "cli" || segment.kind === "mcp",
(segment) => segment.kind !== "text",
);
const activeCliMentionApps = useMemo(() => {
const seen = new Set<string>();
@@ -1221,6 +1299,14 @@ export function ThreadComposer({
return [segment.preset];
});
}, [mentionSegments]);
const activeSessionMentions = useMemo(() => {
const seen = new Set<string>();
return mentionSegments.flatMap((segment) => {
if (segment.kind !== "session" || seen.has(segment.mention.session_key)) return [];
seen.add(segment.mention.session_key);
return [segment.mention];
});
}, [mentionSegments]);
const [slashPaletteLayout, setSlashPaletteLayout] = useState<SlashPaletteLayout>({
placement: "above",
maxHeight: SLASH_PALETTE_MAX_HEIGHT_PX,
@@ -1654,17 +1740,24 @@ export function ThreadComposer({
const attachedCliApps = activeCliMentionApps.map(cliAppMentionPayload);
const attachedMcpPresets = activeMcpPresetMentions.map(mcpPresetMentionPayload);
const options: SendOptions | undefined =
attachedCliApps.length > 0 || attachedMcpPresets.length > 0 || normalizedQuotedContext
attachedCliApps.length > 0
|| attachedMcpPresets.length > 0
|| activeSessionMentions.length > 0
|| normalizedQuotedContext
? {
...(attachedCliApps.length > 0 ? { cliApps: attachedCliApps } : {}),
...(attachedMcpPresets.length > 0 ? { mcpPresets: attachedMcpPresets } : {}),
...(activeSessionMentions.length > 0
? { sessionMentions: activeSessionMentions }
: {}),
...(normalizedQuotedContext ? { quotedContext: normalizedQuotedContext } : {}),
}
: undefined;
const hasPlainTextCommandPayload =
payload === undefined
&& attachedCliApps.length === 0
&& attachedMcpPresets.length === 0;
&& attachedMcpPresets.length === 0
&& activeSessionMentions.length === 0;
const slashLifecycle = hasPlainTextCommandPayload
? slashCommandLifecycle(content, slashCommands)
: null;
@@ -1704,6 +1797,7 @@ export function ThreadComposer({
}, [
activeCliMentionApps,
activeMcpPresetMentions,
activeSessionMentions,
canSend,
clear,
clearComposerText,
@@ -2434,7 +2528,7 @@ function ComposerCliMentionOverlay({
isHero={isHero}
/>
);
return (
if (segment.kind === "mcp") return (
<McpPresetMentionToken
key={`mcp-${segment.preset.name}-${index}`}
preset={segment.preset}
@@ -2443,6 +2537,14 @@ function ComposerCliMentionOverlay({
isHero={isHero}
/>
);
return (
<SessionMentionToken
key={`session-${segment.mention.session_key}-${index}`}
mention={segment.mention}
label={segment.text}
variant="composer"
/>
);
})}
</div>
);
@@ -2496,6 +2598,19 @@ function CliAppMentionPalette({
layout.maxHeight - SLASH_PALETTE_CHROME_PX,
);
const listRef = useSelectedOptionScroll(selectedIndex);
const groupedCandidates = (["session", "cli", "mcp"] as const)
.map((kind) => ({
kind,
label: kind === "session"
? t("thread.composer.mentions.sessionGroup")
: kind === "cli"
? t("thread.composer.mentions.cliGroup")
: t("thread.composer.mentions.mcpGroup"),
items: candidates
.map((candidate, index) => ({ candidate, index }))
.filter(({ candidate }) => candidate.kind === kind),
}))
.filter((group) => group.items.length > 0);
return (
<div
role="listbox"
@@ -2509,64 +2624,76 @@ function CliAppMentionPalette({
isHero ? "max-w-[58rem]" : "max-w-[49.5rem]",
)}
>
<div className="px-2 pb-1.5 pt-0.5 text-[13px] font-semibold text-muted-foreground/78">
{t("thread.composer.mentions.label")}
</div>
<div ref={listRef} className="overflow-y-auto" style={{ maxHeight: listMaxHeight }}>
{candidates.map((candidate, index) => {
const selected = index === selectedIndex;
const name = candidate.name;
const displayName = candidate.kind === "cli"
? candidate.app.display_name
: candidate.preset.display_name;
const typeLabel = candidate.kind === "cli"
? t("thread.composer.mentions.cliBadge")
: t("thread.composer.mentions.mcpBadge");
const ariaDescription = candidate.kind === "cli"
? t("thread.composer.mentions.cliDescription", { name })
: t("thread.composer.mentions.mcpDescription", { name });
return (
<button
key={`${candidate.kind}-${name}`}
type="button"
role="option"
data-palette-index={index}
aria-selected={selected}
aria-label={`${displayName} @${name} ${ariaDescription} ${typeLabel}`}
onMouseEnter={() => onHover(index)}
onMouseDown={(e) => {
e.preventDefault();
onChoose(candidate);
}}
className={cn(
"flex min-h-10 w-full items-center gap-2.5 rounded-[13px] px-2.5 py-1.5 text-left transition-colors",
selected
? "bg-foreground/[0.055] text-foreground"
: "text-foreground/90 hover:bg-foreground/[0.04]",
)}
>
<MentionCandidateLogo candidate={candidate} selected={selected} />
<span className="flex min-w-0 flex-1 items-baseline gap-2">
<span className="min-w-0 truncate text-[15px] font-medium tracking-normal text-foreground">
{displayName}
</span>
<span className="truncate text-[15px] font-normal tracking-normal text-muted-foreground/72">
@{name}
</span>
</span>
<span
className={cn(
"ml-2 shrink-0 rounded-full px-2 py-0.5 text-[11px] font-semibold tracking-normal",
candidate.kind === "cli"
? "bg-orange-500/10 text-orange-600 dark:text-orange-300"
: "bg-sky-500/10 text-sky-600 dark:text-sky-300",
)}
>
{typeLabel}
</span>
</button>
);
})}
{groupedCandidates.map((group) => (
<div key={group.kind} role="group" aria-label={group.label} className="mt-1.5 first:mt-0">
<div className="px-2 pb-1 pt-1 text-[12px] font-medium text-muted-foreground/72">
{group.label}
</div>
{group.items.map(({ candidate, index }) => {
const selected = index === selectedIndex;
const name = candidate.name;
const displayName = candidate.kind === "cli"
? candidate.app.display_name
: candidate.kind === "mcp"
? candidate.preset.display_name
: candidate.mention.title || candidate.name;
const typeLabel = candidate.kind === "cli"
? t("thread.composer.mentions.cliBadge")
: candidate.kind === "mcp"
? t("thread.composer.mentions.mcpBadge")
: t("thread.composer.mentions.sessionBadge");
const ariaDescription = candidate.kind === "cli"
? t("thread.composer.mentions.cliDescription", { name })
: candidate.kind === "mcp"
? t("thread.composer.mentions.mcpDescription", { name })
: t("thread.composer.mentions.sessionDescription", { name });
return (
<button
key={`${candidate.kind}-${name}`}
type="button"
role="option"
data-palette-index={index}
aria-selected={selected}
aria-label={`${displayName} @${name} ${ariaDescription} ${typeLabel}`}
onMouseEnter={() => onHover(index)}
onMouseDown={(e) => {
e.preventDefault();
onChoose(candidate);
}}
className={cn(
"flex min-h-10 w-full items-center gap-2.5 rounded-[13px] px-2.5 py-1.5 text-left transition-colors",
selected
? "bg-foreground/[0.055] text-foreground"
: "text-foreground/90 hover:bg-foreground/[0.04]",
)}
>
<MentionCandidateLogo candidate={candidate} selected={selected} />
<span className="flex min-w-0 flex-1 items-baseline gap-2">
<span className="min-w-0 truncate text-[15px] font-medium tracking-normal text-foreground">
{displayName}
</span>
<span className="truncate text-[15px] font-normal tracking-normal text-muted-foreground/72">
{candidate.kind === "session" ? typeLabel : `@${name}`}
</span>
</span>
{candidate.kind !== "session" ? (
<span
className={cn(
"ml-2 shrink-0 rounded-full px-2 py-0.5 text-[11px] font-semibold tracking-normal",
candidate.kind === "cli"
? "bg-orange-500/10 text-orange-600 dark:text-orange-300"
: "bg-sky-500/10 text-sky-600 dark:text-sky-300",
)}
>
{typeLabel}
</span>
) : null}
</button>
);
})}
</div>
))}
</div>
</div>
);
@@ -2581,11 +2708,24 @@ function MentionCandidateLogo({
}) {
const color = (candidate.kind === "cli"
? candidate.app.brand_color
: candidate.preset.brand_color) || INLINE_TOKEN_HIGHLIGHT_COLOR;
const rawLogoUrl = candidate.kind === "cli" ? candidate.app.logo_url : candidate.preset.logo_url;
: candidate.kind === "mcp"
? candidate.preset.brand_color
: null) || INLINE_TOKEN_HIGHLIGHT_COLOR;
const rawLogoUrl = candidate.kind === "cli"
? candidate.app.logo_url
: candidate.kind === "mcp"
? candidate.preset.logo_url
: null;
const logoUrls = useMemo(() => logoFallbackUrls(rawLogoUrl), [rawLogoUrl]);
const { logoUrl, onLogoError, onLogoLoad } = useLogoFallback(logoUrls);
if (candidate.kind === "session") {
return (
<span className="flex h-5 w-5 shrink-0 items-center justify-center text-muted-foreground">
<MessageCircle className="h-4 w-4" aria-hidden />
</span>
);
}
if (logoUrl) {
return (
<span
@@ -293,6 +293,7 @@ function maxFilePreviewWidth(containerWidth: number): number {
interface ThreadShellProps {
session: ChatSummary | null;
sessions?: ChatSummary[];
title: string;
onToggleSidebar: () => void;
onGoHome?: () => void;
@@ -577,6 +578,7 @@ function useInstalledSettingItems<Payload, Item>({
export function ThreadShell({
session,
sessions = [],
title,
onToggleSidebar,
onCreateChat,
@@ -601,6 +603,10 @@ export function ThreadShell({
const { t } = useTranslation();
const chatId = session?.chatId ?? null;
const historyKey = session?.key ?? null;
const mentionSessions = useMemo(
() => sessions.filter((candidate) => candidate.key !== historyKey),
[historyKey, sessions],
);
const {
messages: historical,
loading,
@@ -1377,6 +1383,7 @@ export function ThreadShell({
slashCommands={slashCommands}
cliApps={cliApps}
mcpPresets={mcpPresets}
sessions={mentionSessions}
skills={skills}
onStop={stop}
onTranscribeAudio={transcribeAudio}
@@ -1419,6 +1426,7 @@ export function ThreadShell({
slashCommands={slashCommands}
cliApps={cliApps}
mcpPresets={mcpPresets}
sessions={mentionSessions}
skills={skills}
runStartedAt={currentRunStartedAt}
onTranscribeAudio={transcribeAudio}
+5
View File
@@ -16,6 +16,7 @@ import type {
OutboundCliAppMention,
OutboundMcpPresetMention,
OutboundMedia,
SessionMention,
GoalStateWsPayload,
MessageDeliveryStatus,
ToolProgressEvent,
@@ -481,6 +482,7 @@ export interface SendAttachment {
export interface SendOptions {
cliApps?: OutboundCliAppMention[];
mcpPresets?: OutboundMcpPresetMention[];
sessionMentions?: SessionMention[];
quotedContext?: string;
workspaceScope?: WorkspaceScopePayload | null;
sideChannel?: boolean;
@@ -1418,6 +1420,9 @@ export function useNanobotStream(
...(previews ? { media: previews } : {}),
...(options?.cliApps?.length ? { cliApps: options.cliApps } : {}),
...(options?.mcpPresets?.length ? { mcpPresets: options.mcpPresets } : {}),
...(options?.sessionMentions?.length
? { sessionMentions: options.sessionMentions }
: {}),
},
];
});
+5 -2
View File
@@ -1215,16 +1215,19 @@
}
},
"mentions": {
"ariaLabel": "Apps",
"ariaLabel": "Mentions",
"label": "Apps",
"cliGroup": "CLI apps",
"mcpGroup": "MCP services",
"sessionGroup": "Nanobot conversations",
"cliBadge": "CLI",
"mcpBadge": "MCP",
"cliDescription": "Use @{{name}} as a local CLI app",
"mcpDescription": "Use @{{name}} as an MCP server",
"cliTitle": "CLI app: {{name}}",
"mcpTitle": "MCP server: {{name}}"
"mcpTitle": "MCP server: {{name}}",
"sessionBadge": "Nanobot conversation",
"sessionDescription": "Reference @{{name}} as a previous chat"
},
"encoding": "Encoding…",
"remove": "Remove attachment",
+4 -1
View File
@@ -1222,12 +1222,15 @@
"label": "Aplicaciones",
"cliGroup": "Aplicaciones CLI",
"mcpGroup": "Servicios MCP",
"sessionGroup": "Conversaciones de Nanobot",
"cliBadge": "CLI",
"mcpBadge": "MCP",
"cliDescription": "Usar @{{name}} como aplicación CLI local",
"mcpDescription": "Usar @{{name}} como servidor MCP",
"cliTitle": "Aplicación CLI: {{name}}",
"mcpTitle": "Servidor MCP: {{name}}"
"mcpTitle": "Servidor MCP: {{name}}",
"sessionBadge": "Conversación de Nanobot",
"sessionDescription": "Referenciar @{{name}} como chat anterior"
},
"workspace": {
"accessAria": "Modo de acceso al espacio de trabajo",
+4 -1
View File
@@ -1221,12 +1221,15 @@
"label": "Applications",
"cliGroup": "Applications CLI",
"mcpGroup": "Services MCP",
"sessionGroup": "Conversations Nanobot",
"cliBadge": "CLI",
"mcpBadge": "MCP",
"cliDescription": "Utiliser @{{name}} comme application CLI locale",
"mcpDescription": "Utiliser @{{name}} comme serveur MCP",
"cliTitle": "Application CLI : {{name}}",
"mcpTitle": "Serveur MCP : {{name}}"
"mcpTitle": "Serveur MCP : {{name}}",
"sessionBadge": "Conversation Nanobot",
"sessionDescription": "Référencer @{{name}} comme discussion précédente"
},
"workspace": {
"accessAria": "Mode daccès à lespace de travail",
+5 -2
View File
@@ -1217,16 +1217,19 @@
"io": "Tidak dapat membaca file ini"
},
"mentions": {
"ariaLabel": "Aplikasi",
"ariaLabel": "Sebutan",
"label": "Aplikasi",
"cliGroup": "Aplikasi CLI",
"mcpGroup": "Layanan MCP",
"sessionGroup": "Percakapan Nanobot",
"cliBadge": "CLI",
"mcpBadge": "MCP",
"cliDescription": "Gunakan @{{name}} sebagai aplikasi CLI lokal",
"mcpDescription": "Gunakan @{{name}} sebagai server MCP",
"cliTitle": "Aplikasi CLI: {{name}}",
"mcpTitle": "Server MCP: {{name}}"
"mcpTitle": "Server MCP: {{name}}",
"sessionBadge": "Percakapan Nanobot",
"sessionDescription": "Referensikan @{{name}} sebagai chat sebelumnya"
},
"workspace": {
"accessAria": "Mode akses ruang kerja",
+5 -2
View File
@@ -1217,16 +1217,19 @@
"io": "このファイルを読み込めません"
},
"mentions": {
"ariaLabel": "アプリ",
"ariaLabel": "メンション",
"label": "アプリ",
"cliGroup": "CLI アプリ",
"mcpGroup": "MCP サービス",
"sessionGroup": "Nanobot の会話",
"cliBadge": "CLI",
"mcpBadge": "MCP",
"cliDescription": "@{{name}} をローカル CLI アプリとして使用",
"mcpDescription": "@{{name}} を MCP サーバーとして使用",
"cliTitle": "CLI アプリ: {{name}}",
"mcpTitle": "MCP サーバー: {{name}}"
"mcpTitle": "MCP サーバー: {{name}}",
"sessionBadge": "Nanobot の会話",
"sessionDescription": "@{{name}} を過去のチャットとして参照"
},
"workspace": {
"accessAria": "ワークスペースのアクセスモード",
+5 -2
View File
@@ -1217,16 +1217,19 @@
"io": "이 파일을 읽을 수 없습니다"
},
"mentions": {
"ariaLabel": "",
"ariaLabel": "멘션",
"label": "앱",
"cliGroup": "CLI 앱",
"mcpGroup": "MCP 서비스",
"sessionGroup": "Nanobot 대화",
"cliBadge": "CLI",
"mcpBadge": "MCP",
"cliDescription": "@{{name}}을 로컬 CLI 앱으로 사용",
"mcpDescription": "@{{name}}을 MCP 서버로 사용",
"cliTitle": "CLI 앱: {{name}}",
"mcpTitle": "MCP 서버: {{name}}"
"mcpTitle": "MCP 서버: {{name}}",
"sessionBadge": "Nanobot 대화",
"sessionDescription": "@{{name}}을 이전 채팅으로 참조"
},
"workspace": {
"accessAria": "작업공간 접근 모드",
+4 -1
View File
@@ -1219,12 +1219,15 @@
"label": "Aplicativos",
"cliGroup": "Aplicativos CLI",
"mcpGroup": "Serviços MCP",
"sessionGroup": "Conversas do Nanobot",
"cliBadge": "CLI",
"mcpBadge": "MCP",
"cliDescription": "Usar @{{name}} como aplicativo CLI local",
"mcpDescription": "Usar @{{name}} como servidor MCP",
"cliTitle": "Aplicativo CLI: {{name}}",
"mcpTitle": "Servidor MCP: {{name}}"
"mcpTitle": "Servidor MCP: {{name}}",
"sessionBadge": "Conversa do Nanobot",
"sessionDescription": "Referenciar @{{name}} como chat anterior"
},
"encoding": "Codificando…",
"remove": "Remover anexo",
+5 -2
View File
@@ -1217,16 +1217,19 @@
"io": "Không thể đọc tệp này"
},
"mentions": {
"ariaLabel": "Ứng dụng",
"ariaLabel": "Đề cập",
"label": "Ứng dụng",
"cliGroup": "Ứng dụng CLI",
"mcpGroup": "Dịch vụ MCP",
"sessionGroup": "Cuộc trò chuyện Nanobot",
"cliBadge": "CLI",
"mcpBadge": "MCP",
"cliDescription": "Dùng @{{name}} như ứng dụng CLI cục bộ",
"mcpDescription": "Dùng @{{name}} như máy chủ MCP",
"cliTitle": "Ứng dụng CLI: {{name}}",
"mcpTitle": "Máy chủ MCP: {{name}}"
"mcpTitle": "Máy chủ MCP: {{name}}",
"sessionBadge": "Cuộc trò chuyện Nanobot",
"sessionDescription": "Tham chiếu @{{name}} như cuộc trò chuyện trước"
},
"workspace": {
"accessAria": "Chế độ truy cập không gian làm việc",
+5 -2
View File
@@ -1214,16 +1214,19 @@
}
},
"mentions": {
"ariaLabel": "应用",
"ariaLabel": "提及",
"label": "应用",
"cliGroup": "CLI 应用",
"mcpGroup": "MCP 服务",
"sessionGroup": "Nanobot 对话",
"cliBadge": "CLI",
"mcpBadge": "MCP",
"cliDescription": "使用 @{{name}} 调用本地 CLI",
"mcpDescription": "使用 @{{name}} 调用 MCP 服务",
"cliTitle": "CLI 应用:{{name}}",
"mcpTitle": "MCP 服务:{{name}}"
"mcpTitle": "MCP 服务:{{name}}",
"sessionBadge": "Nanobot 对话",
"sessionDescription": "引用历史会话 @{{name}}"
},
"encoding": "处理中…",
"remove": "移除附件",
+5 -2
View File
@@ -1217,16 +1217,19 @@
"io": "無法讀取這個檔案"
},
"mentions": {
"ariaLabel": "應用程式",
"ariaLabel": "提及",
"label": "應用程式",
"cliGroup": "CLI 應用程式",
"mcpGroup": "MCP 伺服器",
"sessionGroup": "Nanobot 對話",
"cliBadge": "CLI",
"mcpBadge": "MCP",
"cliDescription": "將 @{{name}} 作為本機 CLI 應用程式使用",
"mcpDescription": "將 @{{name}} 作為 MCP 伺服器使用",
"cliTitle": "CLI 應用程式:{{name}}",
"mcpTitle": "MCP 伺服器:{{name}}"
"mcpTitle": "MCP 伺服器:{{name}}",
"sessionBadge": "Nanobot 對話",
"sessionDescription": "引用先前的對話 @{{name}}"
},
"workspace": {
"accessAria": "工作區存取模式",
+5
View File
@@ -5,6 +5,7 @@ import type {
OutboundCliAppMention,
OutboundMcpPresetMention,
OutboundMedia,
SessionMention,
GoalStateWsPayload,
WorkspaceScopePayload,
} from "./types";
@@ -804,6 +805,7 @@ export class NanobotClient {
options?: {
cliApps?: OutboundCliAppMention[];
mcpPresets?: OutboundMcpPresetMention[];
sessionMentions?: SessionMention[];
quotedContext?: string;
workspaceScope?: WorkspaceScopePayload | null;
turnId?: string;
@@ -819,6 +821,9 @@ export class NanobotClient {
...(media && media.length > 0 ? { media } : {}),
...(options?.cliApps?.length ? { cli_apps: options.cliApps } : {}),
...(options?.mcpPresets?.length ? { mcp_presets: options.mcpPresets } : {}),
...(options?.sessionMentions?.length
? { session_mentions: options.sessionMentions }
: {}),
...(options?.quotedContext?.trim() ? { quoted_context: options.quotedContext.trim() } : {}),
...(options?.workspaceScope ? { workspace_scope: options.workspaceScope } : {}),
...(options?.turnId ? { turn_id: options.turnId } : {}),
+11
View File
@@ -64,6 +64,8 @@ export interface UIMessage {
cliApps?: UICliAppAttachment[];
/** Settings-managed MCP presets explicitly attached to this user turn. */
mcpPresets?: UIMcpPresetAttachment[];
/** Persisted sessions explicitly referenced by this user turn. */
sessionMentions?: SessionMention[];
/** Assistant turn: accumulated model reasoning / thinking text. Built up
* incrementally from ``reasoning_delta`` frames; finalized when
* ``reasoning_end`` arrives. */
@@ -107,6 +109,14 @@ export interface UIMcpPresetAttachment {
brand_color?: string | null;
}
export interface SessionMention {
/** Text token inserted in the composer, without the leading @. */
name: string;
/** Stable persisted-session identifier used by read_session. */
session_key: string;
title: string;
}
export interface SessionAutomationJob {
id: string;
name: string;
@@ -1338,6 +1348,7 @@ export type Outbound =
media?: OutboundMedia[];
cli_apps?: OutboundCliAppMention[];
mcp_presets?: OutboundMcpPresetMention[];
session_mentions?: SessionMention[];
quoted_context?: string;
workspace_scope?: WorkspaceScopePayload;
turn_id?: string;
+20
View File
@@ -512,6 +512,26 @@ describe("MessageBubble", () => {
expect(screen.getByTestId("message-mcp-mention-logo-browserbase")).toBeInTheDocument();
});
it("renders persisted session mentions inside sent user messages", () => {
const message: UIMessage = {
id: "u-session",
role: "user",
content: "Use @收费设计 as context",
createdAt: Date.now(),
sessionMentions: [{
name: "收费设计",
session_key: "websocket:pricing",
title: "收费设计",
}],
};
render(<MessageBubble message={message} />);
const token = screen.getByTestId("message-session-mention-收费设计");
expect(token).toHaveTextContent("@收费设计");
expect(token).toHaveAttribute("title", "Session: 收费设计");
});
it("copies completed assistant replies from the action row", async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, "clipboard", {
+30
View File
@@ -1619,6 +1619,36 @@ describe("NanobotClient", () => {
);
});
it("includes session mentions in outbound messages", () => {
const client = new NanobotClient({
url: "ws://test",
reconnect: false,
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
});
client.connect();
lastSocket().fakeOpen();
client.sendMessage("chat-current", "Use @pricing", undefined, {
sessionMentions: [{
name: "pricing",
session_key: "websocket:pricing",
title: "Pricing",
}],
});
expect(lastSocket().sent).toContain(JSON.stringify({
type: "message",
chat_id: "chat-current",
content: "Use @pricing",
session_mentions: [{
name: "pricing",
session_key: "websocket:pricing",
title: "Pricing",
}],
webui: true,
}));
});
it("re-attaches known chats after a reconnect", async () => {
const client = new NanobotClient({
url: "ws://test",
+87 -2
View File
@@ -1395,7 +1395,7 @@ describe("ThreadComposer", () => {
const input = screen.getByLabelText("Message input");
fireEvent.change(input, { target: { value: "@", selectionStart: 1 } });
const palette = screen.getByRole("listbox", { name: "Apps" });
const palette = screen.getByRole("listbox", { name: "Mentions" });
expect(palette).toBeInTheDocument();
expect(screen.getByRole("option", { name: /@gimp/i })).toHaveAttribute(
"aria-selected",
@@ -1414,7 +1414,7 @@ describe("ThreadComposer", () => {
expect(screen.getByTestId("composer-cli-mention-blender")).toHaveTextContent("@blender");
expect(screen.queryByTestId("composer-cli-app-tray")).not.toBeInTheDocument();
expect(onSend).not.toHaveBeenCalled();
expect(screen.queryByRole("listbox", { name: "Apps" })).not.toBeInTheDocument();
expect(screen.queryByRole("listbox", { name: "Mentions" })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
@@ -1536,6 +1536,91 @@ describe("ThreadComposer", () => {
});
});
it("reuses the mention palette for persisted sessions", () => {
const onSend = vi.fn();
render(
<ThreadComposer
onSend={onSend}
placeholder="Type your message..."
sessions={[{
key: "websocket:pricing",
channel: "websocket",
chatId: "pricing",
createdAt: null,
updatedAt: null,
title: "收费设计",
preview: "讨论云存储",
}]}
/>,
);
const input = screen.getByLabelText("Message input");
fireEvent.change(input, {
target: { value: "参考 @收费", selectionStart: 6 },
});
expect(screen.getByRole("group", { name: "Nanobot conversations" })).toBeInTheDocument();
expect(screen.getByRole("option", { name: /@收费设计/i })).toBeInTheDocument();
fireEvent.keyDown(input, { key: "Tab" });
expect(input).toHaveValue("参考 @收费设计 ");
expect(screen.getByTestId("composer-session-mention-收费设计")).toHaveTextContent(
"@收费设计",
);
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
expect(onSend).toHaveBeenCalledWith("参考 @收费设计", undefined, {
sessionMentions: [{
name: "收费设计",
session_key: "websocket:pricing",
title: "收费设计",
}],
});
});
it("disambiguates a session mention that shares a capability name", () => {
render(
<ThreadComposer
onSend={vi.fn()}
placeholder="Type your message..."
cliApps={CLI_APPS}
sessions={[
{
key: "websocket:blender-chat",
channel: "websocket",
chatId: "blender-chat",
createdAt: null,
updatedAt: null,
title: "Blender",
preview: "3D notes",
},
...Array.from({ length: 8 }, (_, index) => ({
key: `websocket:chat-${index}`,
channel: "websocket",
chatId: `chat-${index}`,
createdAt: null,
updatedAt: null,
title: `Chat ${index}`,
preview: "",
})),
]}
/>,
);
const input = screen.getByLabelText("Message input");
fireEvent.change(input, { target: { value: "@", selectionStart: 1 } });
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,
})).toBeInTheDocument();
expect(screen.getByRole("option", {
name: /Blender @blender Use/i,
})).toBeInTheDocument();
});
it("opens skills only from a $ reference and prioritizes the skill name", () => {
const skillName = "arxiv-intelligence-filter";
render(
+2 -2
View File
@@ -3611,7 +3611,7 @@ describe("ThreadShell", () => {
));
const input = await screen.findByLabelText("Message input");
expect(screen.queryByRole("listbox", { name: "Apps" })).not.toBeInTheDocument();
expect(screen.queryByRole("listbox", { name: "Mentions" })).not.toBeInTheDocument();
const payload: CliAppsPayload = {
apps: [{
@@ -3639,7 +3639,7 @@ describe("ThreadShell", () => {
});
fireEvent.change(input, { target: { value: "@", selectionStart: 1 } });
expect(screen.getByRole("listbox", { name: "Apps" })).toBeInTheDocument();
expect(screen.getByRole("listbox", { name: "Mentions" })).toBeInTheDocument();
expect(screen.getByRole("option", { name: /@gimp/i })).toBeInTheDocument();
});