feat(webui): add lightweight session messaging via mentions

This commit is contained in:
chengyongru
2026-08-19 01:15:56 +08:00
committed by chengyongru
parent 2bdb11eeba
commit 0e184965e8
76 changed files with 8297 additions and 658 deletions
+38 -12
View File
@@ -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>
+149 -23
View File
@@ -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>
+7
View File
@@ -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>
+145 -2
View File
@@ -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 (
+93
View File
@@ -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>
+102 -12
View File
@@ -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">
+14 -1
View File
@@ -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
);
+46 -4
View File
@@ -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}
+16 -6
View File
@@ -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`;
}