feat(webui): add lightweight session messaging via mentions
This commit is contained in:
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`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user