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