feat: add CLI Apps settings MVP
This commit is contained in:
@@ -1,11 +1,12 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { AlertCircle, ChevronRight, Layers } from "lucide-react";
|
||||
import { AlertCircle, ChevronRight, Layers, Terminal } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { cliAppInitials } from "@/components/CliAppMentionText";
|
||||
import { FileReferenceChip } from "@/components/FileReferenceChip";
|
||||
import { ReasoningBubble, StreamingLabelSheen, TraceGroup } from "@/components/MessageBubble";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { UIFileEdit, UIMessage } from "@/lib/types";
|
||||
import type { CliAppInfo, ToolProgressEvent, UIFileEdit, UIMessage } from "@/lib/types";
|
||||
|
||||
/** Scrollport height for the Cursor-style “live trace” strip (tailwind spacing). */
|
||||
const CLUSTER_SCROLL_MAX_CLASS = "max-h-52";
|
||||
@@ -24,6 +25,7 @@ export function isAgentActivityMember(m: UIMessage): boolean {
|
||||
interface ActivityCounts {
|
||||
reasoningSteps: number;
|
||||
toolCalls: number;
|
||||
cliCount: number;
|
||||
fileCount: number;
|
||||
added: number;
|
||||
deleted: number;
|
||||
@@ -32,6 +34,8 @@ interface ActivityCounts {
|
||||
hasFailedFiles: boolean;
|
||||
primaryFilePath?: string;
|
||||
primaryFileTooltipPath?: string;
|
||||
primaryCliName?: string;
|
||||
primaryCliStatus?: CliRunStatus;
|
||||
}
|
||||
|
||||
interface FileEditSummary {
|
||||
@@ -47,17 +51,41 @@ interface FileEditSummary {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
function countActivity(messages: UIMessage[], fileEdits: FileEditSummary[]): ActivityCounts {
|
||||
interface CliRunSummary {
|
||||
key: string;
|
||||
name: string;
|
||||
args: string[];
|
||||
json: boolean;
|
||||
workingDir?: string;
|
||||
status: CliRunStatus;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
type CliRunStatus = "running" | "done" | "error";
|
||||
|
||||
function countActivity(
|
||||
messages: UIMessage[],
|
||||
fileEdits: FileEditSummary[],
|
||||
cliRuns: CliRunSummary[],
|
||||
): ActivityCounts {
|
||||
let reasoningSteps = 0;
|
||||
let toolCalls = 0;
|
||||
const cliCount = cliRuns.length;
|
||||
const primaryCli = cliRuns[cliRuns.length - 1];
|
||||
const primaryCliName = primaryCli?.name;
|
||||
const primaryCliStatus = primaryCli?.status;
|
||||
for (const m of messages) {
|
||||
if (isReasoningOnlyAssistant(m)) {
|
||||
reasoningSteps += 1;
|
||||
continue;
|
||||
}
|
||||
if (m.kind === "trace") {
|
||||
const lines = m.traces?.length ?? (m.content.trim() ? 1 : 0);
|
||||
toolCalls += lines;
|
||||
const lines = traceLines(m);
|
||||
for (const line of lines) {
|
||||
if (!isCliRunTraceLine(line)) {
|
||||
toolCalls += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let added = 0;
|
||||
@@ -89,6 +117,7 @@ function countActivity(messages: UIMessage[], fileEdits: FileEditSummary[]): Act
|
||||
return {
|
||||
reasoningSteps,
|
||||
toolCalls,
|
||||
cliCount,
|
||||
fileCount: fileEdits.length,
|
||||
added,
|
||||
deleted,
|
||||
@@ -97,6 +126,8 @@ function countActivity(messages: UIMessage[], fileEdits: FileEditSummary[]): Act
|
||||
hasFailedFiles: fileEdits.length > 0 && failedFileCount === fileEdits.length,
|
||||
primaryFilePath,
|
||||
primaryFileTooltipPath,
|
||||
primaryCliName,
|
||||
primaryCliStatus,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -105,6 +136,7 @@ interface AgentActivityClusterProps {
|
||||
/** True while the session turn is still running (drives “Working…” copy + header sheen). */
|
||||
isTurnStreaming: boolean;
|
||||
hasBodyBelow: boolean;
|
||||
cliApps?: CliAppInfo[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -115,15 +147,22 @@ export function AgentActivityCluster({
|
||||
messages,
|
||||
isTurnStreaming,
|
||||
hasBodyBelow,
|
||||
cliApps = [],
|
||||
}: AgentActivityClusterProps) {
|
||||
const { t } = useTranslation();
|
||||
const fileEdits = useMemo(
|
||||
() => summarizeFileEdits(collectFileEdits(messages), isTurnStreaming),
|
||||
[messages, isTurnStreaming],
|
||||
);
|
||||
const cliRuns = useMemo(() => collectCliRuns(messages), [messages]);
|
||||
const cliAppsByName = useMemo(
|
||||
() => new Map(cliApps.map((app) => [app.name.toLowerCase(), app])),
|
||||
[cliApps],
|
||||
);
|
||||
const {
|
||||
reasoningSteps,
|
||||
toolCalls,
|
||||
cliCount,
|
||||
fileCount,
|
||||
added,
|
||||
deleted,
|
||||
@@ -132,7 +171,9 @@ export function AgentActivityCluster({
|
||||
hasFailedFiles,
|
||||
primaryFilePath,
|
||||
primaryFileTooltipPath,
|
||||
} = countActivity(messages, fileEdits);
|
||||
primaryCliName,
|
||||
primaryCliStatus,
|
||||
} = countActivity(messages, fileEdits, cliRuns);
|
||||
const hasPendingFileEdit = fileEdits.some((edit) => edit.pending);
|
||||
|
||||
const [userToggledOuter, setUserToggledOuter] = useState(false);
|
||||
@@ -148,7 +189,7 @@ export function AgentActivityCluster({
|
||||
const headerBusy = fileCount > 0 ? hasEditingFiles : isTurnStreaming;
|
||||
const singleFilePath = fileCount === 1 ? primaryFilePath : undefined;
|
||||
const singleFileTooltipPath = fileCount === 1 ? primaryFileTooltipPath : undefined;
|
||||
const hasVisibleActivity = reasoningSteps > 0 || toolCalls > 0 || fileCount > 0;
|
||||
const hasVisibleActivity = reasoningSteps > 0 || toolCalls > 0 || cliCount > 0 || fileCount > 0;
|
||||
|
||||
const fileActivitySummary = fileCount > 0
|
||||
? hasPendingFileEdit && !singleFilePath
|
||||
@@ -164,8 +205,22 @@ export function AgentActivityCluster({
|
||||
})
|
||||
: "";
|
||||
|
||||
const cliActivitySummary = cliCount > 0
|
||||
? cliCount === 1 && primaryCliName
|
||||
? t(cliActivitySummaryKey(primaryCliStatus, isTurnStreaming), {
|
||||
name: primaryCliName,
|
||||
defaultValue: cliActivitySummaryDefault(primaryCliStatus, isTurnStreaming),
|
||||
})
|
||||
: t(cliActivityManySummaryKey(cliRuns, isTurnStreaming), {
|
||||
count: cliCount,
|
||||
defaultValue: cliActivityManySummaryDefault(cliRuns, isTurnStreaming),
|
||||
})
|
||||
: "";
|
||||
|
||||
const summary = fileCount > 0
|
||||
? fileActivitySummary
|
||||
: cliCount > 0
|
||||
? cliActivitySummary
|
||||
: isTurnStreaming
|
||||
? reasoningSteps > 0
|
||||
? t("message.agentActivityLiveSummary", {
|
||||
@@ -254,6 +309,8 @@ export function AgentActivityCluster({
|
||||
|
||||
if (!hasVisibleActivity) return null;
|
||||
|
||||
const HeaderIcon = cliCount > 0 && fileCount === 0 && toolCalls === 0 ? Terminal : Layers;
|
||||
|
||||
return (
|
||||
<div className={cn("w-full", hasBodyBelow && "mb-2")}>
|
||||
<button
|
||||
@@ -266,7 +323,7 @@ export function AgentActivityCluster({
|
||||
aria-expanded={outerExpanded}
|
||||
aria-label={summary}
|
||||
>
|
||||
<Layers className="h-3.5 w-3.5 shrink-0" aria-hidden />
|
||||
<HeaderIcon className="h-3.5 w-3.5 shrink-0" aria-hidden />
|
||||
<span className="flex min-w-0 flex-1 flex-wrap items-center gap-x-1.5 gap-y-0.5 text-left">
|
||||
{singleFilePath ? (
|
||||
<span className="inline-flex min-w-0 items-center gap-1.5">
|
||||
@@ -337,15 +394,25 @@ export function AgentActivityCluster({
|
||||
);
|
||||
}
|
||||
if (m.kind === "trace") {
|
||||
const hasTraceLines = (m.traces?.length ?? 0) > 0 || m.content.trim().length > 0;
|
||||
return hasTraceLines ? (
|
||||
const normalLines = traceLines(m).filter((line) => !parseCliRunTrace(line));
|
||||
return normalLines.length > 0 ? (
|
||||
<div key={m.id} className="flex flex-col gap-1">
|
||||
<TraceGroup message={m} animClass="" />
|
||||
<TraceGroup
|
||||
message={{
|
||||
...m,
|
||||
traces: normalLines,
|
||||
content: normalLines[normalLines.length - 1],
|
||||
}}
|
||||
animClass=""
|
||||
/>
|
||||
</div>
|
||||
) : null;
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
{cliRuns.length ? (
|
||||
<CliRunGroup runs={cliRuns} active={isTurnStreaming} cliAppsByName={cliAppsByName} />
|
||||
) : null}
|
||||
{fileEdits.length ? <FileEditGroup edits={fileEdits} /> : null}
|
||||
</div>
|
||||
</div>
|
||||
@@ -359,6 +426,181 @@ function shortFileName(path: string): string {
|
||||
return path.split(/[\\/]/).pop() || path;
|
||||
}
|
||||
|
||||
function traceLines(message: UIMessage): string[] {
|
||||
if (message.traces?.length) return message.traces;
|
||||
return message.content.trim() ? [message.content] : [];
|
||||
}
|
||||
|
||||
const CLI_RUN_TOOL_NAMES = new Set(["run_cli_app", "cli_anything_run"]);
|
||||
const CLI_RUN_STATUS_RANK: Record<CliRunStatus, number> = { running: 1, done: 2, error: 3 };
|
||||
|
||||
function isCliRunTraceLine(line: string): boolean {
|
||||
return /^(run_cli_app|cli_anything_run)\(/.test(line.trim());
|
||||
}
|
||||
|
||||
function parseCliRunTrace(line: string, status: CliRunStatus = "running"): CliRunSummary | null {
|
||||
const match = /^(run_cli_app|cli_anything_run)\((.*)\)$/.exec(line.trim());
|
||||
if (!match) return null;
|
||||
const argsText = match[2].trim();
|
||||
let argsObject: unknown = {};
|
||||
if (argsText) {
|
||||
try {
|
||||
argsObject = JSON.parse(argsText);
|
||||
} catch {
|
||||
return {
|
||||
key: line,
|
||||
name: "cli",
|
||||
args: [argsText],
|
||||
json: false,
|
||||
status,
|
||||
};
|
||||
}
|
||||
}
|
||||
return cliRunFromArguments(argsObject, { key: line, status });
|
||||
}
|
||||
|
||||
function parseToolEventArguments(event: ToolProgressEvent): unknown {
|
||||
const fnArgs = (event as { function?: { arguments?: unknown } }).function?.arguments;
|
||||
const raw = fnArgs ?? event.arguments;
|
||||
if (typeof raw !== "string") return raw ?? {};
|
||||
if (!raw.trim()) return {};
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return { args: [raw] };
|
||||
}
|
||||
}
|
||||
|
||||
function cliRunStatusFromPhase(phase: unknown): CliRunStatus {
|
||||
if (phase === "error") return "error";
|
||||
if (phase === "end") return "done";
|
||||
return "running";
|
||||
}
|
||||
|
||||
function cliRunError(event: ToolProgressEvent): string | undefined {
|
||||
const error = event.error;
|
||||
if (typeof error === "string") return error;
|
||||
if (error && typeof error === "object") return JSON.stringify(error);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function cliRunFromArguments(
|
||||
argsObject: unknown,
|
||||
options: { key: string; status: CliRunStatus; error?: string },
|
||||
): CliRunSummary {
|
||||
if (!argsObject || typeof argsObject !== "object" || Array.isArray(argsObject)) {
|
||||
return {
|
||||
key: options.key,
|
||||
name: "cli",
|
||||
args: [],
|
||||
json: false,
|
||||
status: options.status,
|
||||
error: options.error,
|
||||
};
|
||||
}
|
||||
const record = argsObject as Record<string, unknown>;
|
||||
const appName = typeof record.name === "string" && record.name.trim()
|
||||
? record.name.trim()
|
||||
: "cli";
|
||||
const rawArgs = Array.isArray(record.args) ? record.args : [];
|
||||
const cliArgs = rawArgs.filter((item): item is string => typeof item === "string");
|
||||
return {
|
||||
key: options.key,
|
||||
name: appName,
|
||||
args: cliArgs,
|
||||
json: record.json === true || record.json === "true",
|
||||
workingDir: typeof record.working_dir === "string" ? record.working_dir : undefined,
|
||||
status: options.status,
|
||||
error: options.error,
|
||||
};
|
||||
}
|
||||
|
||||
function cliRunFromEvent(event: ToolProgressEvent): CliRunSummary | null {
|
||||
const name =
|
||||
typeof (event as { function?: { name?: unknown } }).function?.name === "string"
|
||||
? String((event as { function?: { name?: unknown } }).function?.name)
|
||||
: typeof event.name === "string"
|
||||
? event.name
|
||||
: "";
|
||||
if (!CLI_RUN_TOOL_NAMES.has(name)) return null;
|
||||
const argsObject = parseToolEventArguments(event);
|
||||
const key = event.call_id ? `call:${event.call_id}` : `${name}:${JSON.stringify(argsObject)}`;
|
||||
return cliRunFromArguments(argsObject, {
|
||||
key,
|
||||
status: cliRunStatusFromPhase(event.phase),
|
||||
error: cliRunError(event),
|
||||
});
|
||||
}
|
||||
|
||||
function mergeCliRun(existing: CliRunSummary | undefined, incoming: CliRunSummary): CliRunSummary {
|
||||
if (!existing) return incoming;
|
||||
return CLI_RUN_STATUS_RANK[incoming.status] >= CLI_RUN_STATUS_RANK[existing.status]
|
||||
? { ...existing, ...incoming }
|
||||
: existing;
|
||||
}
|
||||
|
||||
function collectCliRuns(messages: UIMessage[]): CliRunSummary[] {
|
||||
const runsByKey = new Map<string, CliRunSummary>();
|
||||
for (const message of messages) {
|
||||
if (message.kind !== "trace") continue;
|
||||
let hasStructuredCliRun = false;
|
||||
for (const event of message.toolEvents ?? []) {
|
||||
const run = cliRunFromEvent(event);
|
||||
if (!run) continue;
|
||||
hasStructuredCliRun = true;
|
||||
runsByKey.set(run.key, mergeCliRun(runsByKey.get(run.key), run));
|
||||
}
|
||||
if (hasStructuredCliRun) continue;
|
||||
for (const line of traceLines(message)) {
|
||||
const run = parseCliRunTrace(line);
|
||||
if (!run || runsByKey.has(run.key)) continue;
|
||||
runsByKey.set(run.key, run);
|
||||
}
|
||||
}
|
||||
return [...runsByKey.values()];
|
||||
}
|
||||
|
||||
function displayCliArg(arg: string): string {
|
||||
return /\s/.test(arg) ? JSON.stringify(arg) : arg;
|
||||
}
|
||||
|
||||
function formatCliArgs(run: CliRunSummary): string {
|
||||
const args = [...(run.json ? ["--json"] : []), ...run.args].map(displayCliArg);
|
||||
return args.join(" ");
|
||||
}
|
||||
|
||||
function cliActivitySummaryKey(status: CliRunStatus | undefined, active: boolean): string {
|
||||
if (status === "error") return "message.cliActivityFailedOne";
|
||||
return active && status === "running" ? "message.cliActivityRunningOne" : "message.cliActivityRanOne";
|
||||
}
|
||||
|
||||
function cliActivitySummaryDefault(status: CliRunStatus | undefined, active: boolean): string {
|
||||
if (status === "error") return "CLI failed @{{name}}";
|
||||
return `${active && status === "running" ? "Running" : "Ran"} CLI @{{name}}`;
|
||||
}
|
||||
|
||||
function cliActivityManySummaryKey(runs: CliRunSummary[], active: boolean): string {
|
||||
if (runs.some((run) => run.status === "error")) return "message.cliActivityFailedMany";
|
||||
return active && runs.some((run) => run.status === "running")
|
||||
? "message.cliActivityRunningMany"
|
||||
: "message.cliActivityRanMany";
|
||||
}
|
||||
|
||||
function cliActivityManySummaryDefault(runs: CliRunSummary[], active: boolean): string {
|
||||
if (runs.some((run) => run.status === "error")) return "{{count}} CLI failed";
|
||||
return `${active && runs.some((run) => run.status === "running") ? "Running" : "Ran"} {{count}} CLIs`;
|
||||
}
|
||||
|
||||
function cliRunLabelKey(run: CliRunSummary, active: boolean): string {
|
||||
if (run.status === "error") return "message.cliRunFailed";
|
||||
return active && run.status === "running" ? "message.cliRunRunning" : "message.cliRunRan";
|
||||
}
|
||||
|
||||
function cliRunLabelDefault(run: CliRunSummary, active: boolean): string {
|
||||
if (run.status === "error") return "CLI failed";
|
||||
return active && run.status === "running" ? "Running CLI" : "Ran CLI";
|
||||
}
|
||||
|
||||
function fileActivityVerb(editing: boolean, failed: boolean): string {
|
||||
if (failed) return "Failed";
|
||||
return editing ? "Editing" : "Edited";
|
||||
@@ -519,6 +761,120 @@ function hasVisibleDiffStats(edit: Pick<FileEditSummary, "added" | "deleted">):
|
||||
return edit.added > 0 || edit.deleted > 0;
|
||||
}
|
||||
|
||||
function CliRunGroup({
|
||||
runs,
|
||||
active,
|
||||
cliAppsByName,
|
||||
}: {
|
||||
runs: CliRunSummary[];
|
||||
active: boolean;
|
||||
cliAppsByName: Map<string, CliAppInfo>;
|
||||
}) {
|
||||
if (runs.length === 0) return null;
|
||||
return (
|
||||
<ul className="space-y-1 border-l border-cyan-500/20 pl-3" data-testid="activity-cli-runs">
|
||||
{runs.map((run) => (
|
||||
<CliRunRow
|
||||
key={run.key}
|
||||
run={run}
|
||||
active={active}
|
||||
app={cliAppsByName.get(run.name.toLowerCase())}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
function CliRunRow({ run, active, app }: { run: CliRunSummary; active: boolean; app?: CliAppInfo }) {
|
||||
const { t } = useTranslation();
|
||||
const [logoFailed, setLogoFailed] = useState(false);
|
||||
const args = formatCliArgs(run);
|
||||
const failed = run.status === "error";
|
||||
const rowActive = active && run.status === "running";
|
||||
const color = failed ? "#DC2626" : app?.brand_color || "#0891B2";
|
||||
const logoUrl = app?.logo_url && !logoFailed ? app.logo_url : null;
|
||||
return (
|
||||
<li
|
||||
className={cn(
|
||||
"grid min-w-0 grid-cols-[minmax(0,1fr)] rounded-[10px] border px-2.5 py-2 text-xs",
|
||||
"shadow-[0_6px_18px_rgba(15,23,42,0.045)] transition-colors",
|
||||
)}
|
||||
style={{
|
||||
borderColor: alphaColor(color, rowActive ? 34 : failed ? 28 : 22),
|
||||
backgroundColor: alphaColor(color, rowActive ? 9 : failed ? 7 : 6),
|
||||
}}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span
|
||||
data-testid={`activity-cli-logo-${run.name.toLowerCase()}`}
|
||||
className={cn(
|
||||
"grid h-7 w-7 shrink-0 place-items-center overflow-hidden rounded-[8px] border text-[10px] font-semibold text-white",
|
||||
rowActive && "animate-pulse",
|
||||
)}
|
||||
style={{
|
||||
borderColor: alphaColor(color, 26),
|
||||
backgroundColor: logoUrl ? "hsl(var(--background))" : color,
|
||||
boxShadow: `0 0 0 3px ${alphaColor(color, rowActive ? 10 : 6)}`,
|
||||
}}
|
||||
>
|
||||
{logoUrl ? (
|
||||
<img
|
||||
src={logoUrl}
|
||||
alt=""
|
||||
className="h-[70%] w-[70%] object-contain"
|
||||
onError={() => setLogoFailed(true)}
|
||||
/>
|
||||
) : app ? (
|
||||
cliAppInitials(app).slice(0, 2)
|
||||
) : (
|
||||
<Terminal className="h-3.5 w-3.5" aria-hidden />
|
||||
)}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex min-w-0 items-center gap-1.5">
|
||||
<StreamingLabelSheen active={rowActive} className="shrink-0 text-[12px]">
|
||||
{t(cliRunLabelKey(run, active), {
|
||||
defaultValue: cliRunLabelDefault(run, active),
|
||||
})}
|
||||
</StreamingLabelSheen>
|
||||
<span className="min-w-0 truncate font-mono text-[12px] font-semibold text-foreground/90">
|
||||
@{run.name}
|
||||
</span>
|
||||
{failed ? (
|
||||
<AlertCircle className="h-3 w-3 shrink-0 text-destructive/75" aria-hidden />
|
||||
) : null}
|
||||
</span>
|
||||
{args ? (
|
||||
<span className="mt-0.5 block truncate font-mono text-[11px] leading-relaxed text-muted-foreground/82">
|
||||
{args}
|
||||
</span>
|
||||
) : null}
|
||||
{run.error ? (
|
||||
<span className="mt-0.5 block truncate text-[10.5px] leading-relaxed text-destructive/70">
|
||||
{run.error}
|
||||
</span>
|
||||
) : null}
|
||||
{run.workingDir ? (
|
||||
<span className="mt-0.5 block truncate text-[10.5px] leading-relaxed text-muted-foreground/58">
|
||||
{run.workingDir}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function alphaColor(color: string, percent: number): string {
|
||||
if (/^#[0-9a-f]{6}$/i.test(color)) {
|
||||
const alpha = Math.round((percent / 100) * 255)
|
||||
.toString(16)
|
||||
.padStart(2, "0");
|
||||
return `${color}${alpha}`;
|
||||
}
|
||||
return `color-mix(in srgb, ${color} ${percent}%, transparent)`;
|
||||
}
|
||||
|
||||
function FileEditGroup({ edits }: { edits: FileEditSummary[] }) {
|
||||
if (edits.length === 0) return null;
|
||||
return (
|
||||
|
||||
@@ -9,9 +9,16 @@ import {
|
||||
} from "react";
|
||||
|
||||
import { MarkdownText, preloadMarkdownText } from "@/components/MarkdownText";
|
||||
import {
|
||||
CliAppMentionToken,
|
||||
cliAppInitials,
|
||||
splitCliAppMentionSegments,
|
||||
type CliAppMentionSegment,
|
||||
} from "@/components/CliAppMentionText";
|
||||
import {
|
||||
Activity,
|
||||
ArrowUp,
|
||||
AtSign,
|
||||
BookOpen,
|
||||
Check,
|
||||
ChevronDown,
|
||||
@@ -41,7 +48,7 @@ import {
|
||||
} from "@/hooks/useAttachedImages";
|
||||
import { useClipboardAndDrop } from "@/hooks/useClipboardAndDrop";
|
||||
import type { SendImage, SendOptions } from "@/hooks/useNanobotStream";
|
||||
import type { SlashCommand, GoalStateWsPayload } from "@/lib/types";
|
||||
import type { CliAppInfo, GoalStateWsPayload, OutboundCliAppMention, SlashCommand } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/** ``<input accept>``: aligned with the server's MIME whitelist. SVG is
|
||||
@@ -62,6 +69,7 @@ interface ThreadComposerProps {
|
||||
modelLabel?: string | null;
|
||||
variant?: "thread" | "hero";
|
||||
slashCommands?: SlashCommand[];
|
||||
cliApps?: CliAppInfo[];
|
||||
imageMode?: boolean;
|
||||
onImageModeChange?: (enabled: boolean) => void;
|
||||
onStop?: () => void;
|
||||
@@ -98,6 +106,12 @@ interface SlashPaletteLayout {
|
||||
maxHeight: number;
|
||||
}
|
||||
|
||||
interface CliAppMentionQuery {
|
||||
query: string;
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
function slashCommandI18nKey(command: string): string {
|
||||
return command.replace(/^\//, "").replace(/-/g, "_");
|
||||
}
|
||||
@@ -167,6 +181,17 @@ function buildGoalMarkdownBody(summary: string, objective: string): string {
|
||||
return o || s;
|
||||
}
|
||||
|
||||
function cliAppMentionPayload(app: CliAppInfo): OutboundCliAppMention {
|
||||
return {
|
||||
name: app.name,
|
||||
display_name: app.display_name,
|
||||
category: app.category,
|
||||
entry_point: app.entry_point,
|
||||
logo_url: app.logo_url ?? null,
|
||||
brand_color: app.brand_color ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function RunElapsedStrip({
|
||||
startedAt,
|
||||
goalState,
|
||||
@@ -371,6 +396,7 @@ export function ThreadComposer({
|
||||
modelLabel = null,
|
||||
variant = "thread",
|
||||
slashCommands = [],
|
||||
cliApps = [],
|
||||
imageMode: controlledImageMode,
|
||||
onImageModeChange,
|
||||
onStop,
|
||||
@@ -382,6 +408,9 @@ export function ThreadComposer({
|
||||
const [inlineError, setInlineError] = useState<string | null>(null);
|
||||
const [slashMenuDismissed, setSlashMenuDismissed] = useState(false);
|
||||
const [selectedCommandIndex, setSelectedCommandIndex] = useState(0);
|
||||
const [cliAppMenuDismissed, setCliAppMenuDismissed] = useState(false);
|
||||
const [selectedCliAppIndex, setSelectedCliAppIndex] = useState(0);
|
||||
const [cursorPosition, setCursorPosition] = useState(0);
|
||||
const [uncontrolledImageMode, setUncontrolledImageMode] = useState(false);
|
||||
const [imageAspectRatio, setImageAspectRatio] = useState<ImageAspectRatio>("auto");
|
||||
const [aspectMenuOpen, setAspectMenuOpen] = useState(false);
|
||||
@@ -491,6 +520,52 @@ export function ThreadComposer({
|
||||
}, [slashCommands, slashQuery, t]);
|
||||
|
||||
const showSlashMenu = filteredSlashCommands.length > 0;
|
||||
const cliAppMention = useMemo<CliAppMentionQuery | null>(() => {
|
||||
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);
|
||||
if (!match) return null;
|
||||
const query = match[1].toLowerCase();
|
||||
return {
|
||||
query,
|
||||
start: caret - query.length - 1,
|
||||
end: caret,
|
||||
};
|
||||
}, [cliAppMenuDismissed, cursorPosition, disabled, value]);
|
||||
|
||||
const filteredCliApps = useMemo(() => {
|
||||
if (!cliAppMention) return [];
|
||||
return cliApps
|
||||
.filter((app) => app.installed)
|
||||
.filter((app) => {
|
||||
const haystack = [
|
||||
app.name,
|
||||
app.display_name,
|
||||
app.category,
|
||||
app.description,
|
||||
app.entry_point,
|
||||
].join(" ").toLowerCase();
|
||||
return haystack.includes(cliAppMention.query);
|
||||
})
|
||||
.slice(0, 8);
|
||||
}, [cliAppMention, cliApps]);
|
||||
|
||||
const showCliAppMenu = filteredCliApps.length > 0;
|
||||
const showAnyPalette = showSlashMenu || showCliAppMenu;
|
||||
const mentionSegments = useMemo(
|
||||
() => splitCliAppMentionSegments(value, cliApps),
|
||||
[cliApps, value],
|
||||
);
|
||||
const hasCliMentionDecorations = mentionSegments.some((segment) => segment.kind === "cli");
|
||||
const activeCliMentionApps = useMemo(() => {
|
||||
const seen = new Set<string>();
|
||||
return mentionSegments.flatMap((segment) => {
|
||||
if (segment.kind !== "cli" || seen.has(segment.app.name)) return [];
|
||||
seen.add(segment.app.name);
|
||||
return [segment.app];
|
||||
});
|
||||
}, [mentionSegments]);
|
||||
const [slashPaletteLayout, setSlashPaletteLayout] = useState<SlashPaletteLayout>({
|
||||
placement: "above",
|
||||
maxHeight: SLASH_PALETTE_MAX_HEIGHT_PX,
|
||||
@@ -500,6 +575,10 @@ export function ThreadComposer({
|
||||
setSelectedCommandIndex(0);
|
||||
}, [slashQuery]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedCliAppIndex(0);
|
||||
}, [cliAppMention?.query]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedCommandIndex >= filteredSlashCommands.length) {
|
||||
setSelectedCommandIndex(0);
|
||||
@@ -507,22 +586,29 @@ export function ThreadComposer({
|
||||
}, [filteredSlashCommands.length, selectedCommandIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showSlashMenu) return;
|
||||
if (selectedCliAppIndex >= filteredCliApps.length) {
|
||||
setSelectedCliAppIndex(0);
|
||||
}
|
||||
}, [filteredCliApps.length, selectedCliAppIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showAnyPalette) return;
|
||||
|
||||
const dismissOnPointerDown = (event: PointerEvent) => {
|
||||
const target = event.target;
|
||||
if (target instanceof Node && formRef.current?.contains(target)) return;
|
||||
setSlashMenuDismissed(true);
|
||||
setCliAppMenuDismissed(true);
|
||||
};
|
||||
|
||||
document.addEventListener("pointerdown", dismissOnPointerDown, true);
|
||||
return () => {
|
||||
document.removeEventListener("pointerdown", dismissOnPointerDown, true);
|
||||
};
|
||||
}, [showSlashMenu]);
|
||||
}, [showAnyPalette]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!showSlashMenu) return;
|
||||
if (!showAnyPalette) return;
|
||||
|
||||
const updateLayout = () => {
|
||||
const form = formRef.current;
|
||||
@@ -554,7 +640,7 @@ export function ThreadComposer({
|
||||
window.removeEventListener("resize", updateLayout);
|
||||
document.removeEventListener("scroll", updateLayout, true);
|
||||
};
|
||||
}, [filteredSlashCommands.length, showSlashMenu]);
|
||||
}, [filteredCliApps.length, filteredSlashCommands.length, showAnyPalette]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!aspectMenuOpen) return;
|
||||
@@ -602,12 +688,36 @@ export function ThreadComposer({
|
||||
(command: SlashCommand) => {
|
||||
setValue(command.argHint ? `${command.command} ` : command.command);
|
||||
setSlashMenuDismissed(true);
|
||||
setCliAppMenuDismissed(false);
|
||||
setInlineError(null);
|
||||
resizeTextarea();
|
||||
},
|
||||
[resizeTextarea],
|
||||
);
|
||||
|
||||
const chooseCliApp = useCallback(
|
||||
(app: CliAppInfo) => {
|
||||
if (!cliAppMention) return;
|
||||
const suffix = value.slice(cliAppMention.end);
|
||||
const mention = `@${app.name}${suffix.startsWith(" ") ? "" : " "}`;
|
||||
const next = `${value.slice(0, cliAppMention.start)}${mention}${suffix}`;
|
||||
const nextCursor = cliAppMention.start + mention.length;
|
||||
setValue(next);
|
||||
setCursorPosition(nextCursor);
|
||||
setCliAppMenuDismissed(true);
|
||||
setSlashMenuDismissed(false);
|
||||
setInlineError(null);
|
||||
resizeTextarea();
|
||||
requestAnimationFrame(() => {
|
||||
const el = textareaRef.current;
|
||||
if (!el) return;
|
||||
el.focus();
|
||||
el.setSelectionRange(nextCursor, nextCursor);
|
||||
});
|
||||
},
|
||||
[cliAppMention, resizeTextarea, value],
|
||||
);
|
||||
|
||||
const submit = useCallback(() => {
|
||||
if (!canSend) return;
|
||||
const trimmed = value.trim();
|
||||
@@ -625,14 +735,21 @@ export function ThreadComposer({
|
||||
preview: { url: img.dataUrl, name: img.file.name },
|
||||
}))
|
||||
: undefined;
|
||||
const options: SendOptions | undefined = imageMode
|
||||
? {
|
||||
imageGeneration: {
|
||||
enabled: true,
|
||||
aspect_ratio: imageAspectRatio === "auto" ? null : imageAspectRatio,
|
||||
},
|
||||
}
|
||||
: undefined;
|
||||
const attachedCliApps = activeCliMentionApps.map(cliAppMentionPayload);
|
||||
const options: SendOptions | undefined =
|
||||
imageMode || attachedCliApps.length > 0
|
||||
? {
|
||||
...(imageMode
|
||||
? {
|
||||
imageGeneration: {
|
||||
enabled: true,
|
||||
aspect_ratio: imageAspectRatio === "auto" ? null : imageAspectRatio,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(attachedCliApps.length > 0 ? { cliApps: attachedCliApps } : {}),
|
||||
}
|
||||
: undefined;
|
||||
onSend(trimmed, payload, options);
|
||||
setValue("");
|
||||
setInlineError(null);
|
||||
@@ -640,10 +757,36 @@ export function ThreadComposer({
|
||||
// preview here without affecting the rendered message.
|
||||
clear();
|
||||
setSlashMenuDismissed(false);
|
||||
setCliAppMenuDismissed(false);
|
||||
setCursorPosition(0);
|
||||
resizeTextarea();
|
||||
}, [canSend, clear, imageAspectRatio, imageMode, onSend, readyImages, resizeTextarea, value]);
|
||||
}, [activeCliMentionApps, canSend, clear, imageAspectRatio, imageMode, onSend, readyImages, resizeTextarea, value]);
|
||||
|
||||
const onKeyDown = (e: ReactKeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (showCliAppMenu) {
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setSelectedCliAppIndex((idx) => (idx + 1) % filteredCliApps.length);
|
||||
return;
|
||||
}
|
||||
if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setSelectedCliAppIndex(
|
||||
(idx) => (idx - 1 + filteredCliApps.length) % filteredCliApps.length,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (e.key === "Tab" || (e.key === "Enter" && !e.shiftKey)) {
|
||||
e.preventDefault();
|
||||
chooseCliApp(filteredCliApps[selectedCliAppIndex]);
|
||||
return;
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
setCliAppMenuDismissed(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (showSlashMenu) {
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
@@ -719,6 +862,12 @@ export function ThreadComposer({
|
||||
|
||||
const attachButtonDisabled = disabled || full;
|
||||
const showStopButton = isStreaming && !!onStop;
|
||||
const inputTextClasses = cn(
|
||||
"w-full resize-none bg-transparent",
|
||||
isHero
|
||||
? "min-h-[78px] px-5 pb-2 pt-5 text-[15px] leading-6"
|
||||
: "min-h-[50px] px-4 pb-1.5 pt-3 text-[13.5px] leading-5",
|
||||
);
|
||||
|
||||
return (
|
||||
<form
|
||||
@@ -743,6 +892,16 @@ export function ThreadComposer({
|
||||
onChoose={chooseSlashCommand}
|
||||
/>
|
||||
) : null}
|
||||
{showCliAppMenu ? (
|
||||
<CliAppMentionPalette
|
||||
apps={filteredCliApps}
|
||||
selectedIndex={selectedCliAppIndex}
|
||||
layout={slashPaletteLayout}
|
||||
isHero={isHero}
|
||||
onHover={setSelectedCliAppIndex}
|
||||
onChoose={chooseCliApp}
|
||||
/>
|
||||
) : null}
|
||||
<div
|
||||
className={cn(
|
||||
"relative mx-auto flex w-full flex-col overflow-visible transition-all duration-200",
|
||||
@@ -787,30 +946,42 @@ export function ThreadComposer({
|
||||
{runStartedAt != null || goalState?.active ? (
|
||||
<RunElapsedStrip startedAt={runStartedAt} goalState={goalState} />
|
||||
) : null}
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
setValue(e.target.value);
|
||||
setSlashMenuDismissed(false);
|
||||
}}
|
||||
onInput={onInput}
|
||||
onKeyDown={onKeyDown}
|
||||
onPaste={onPaste}
|
||||
rows={1}
|
||||
placeholder={resolvedPlaceholder}
|
||||
disabled={disabled}
|
||||
aria-label={t("thread.composer.inputAria")}
|
||||
className={cn(
|
||||
"w-full resize-none bg-transparent",
|
||||
isHero
|
||||
? "min-h-[78px] px-5 pb-2 pt-5 text-[15px] leading-6"
|
||||
: "min-h-[50px] px-4 pb-1.5 pt-3 text-[13.5px] leading-5",
|
||||
"placeholder:text-muted-foreground/70",
|
||||
"focus:outline-none focus-visible:outline-none",
|
||||
"disabled:cursor-not-allowed",
|
||||
)}
|
||||
/>
|
||||
<div className="relative">
|
||||
{hasCliMentionDecorations ? (
|
||||
<ComposerCliMentionOverlay
|
||||
segments={mentionSegments}
|
||||
isHero={isHero}
|
||||
className={inputTextClasses}
|
||||
/>
|
||||
) : null}
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
setValue(e.target.value);
|
||||
setSlashMenuDismissed(false);
|
||||
setCliAppMenuDismissed(false);
|
||||
setCursorPosition(e.target.selectionStart ?? e.target.value.length);
|
||||
}}
|
||||
onInput={onInput}
|
||||
onKeyDown={onKeyDown}
|
||||
onKeyUp={(e) => setCursorPosition(e.currentTarget.selectionStart ?? e.currentTarget.value.length)}
|
||||
onSelect={(e) => setCursorPosition(e.currentTarget.selectionStart ?? e.currentTarget.value.length)}
|
||||
onClick={(e) => setCursorPosition(e.currentTarget.selectionStart ?? e.currentTarget.value.length)}
|
||||
onPaste={onPaste}
|
||||
rows={1}
|
||||
placeholder={resolvedPlaceholder}
|
||||
disabled={disabled}
|
||||
aria-label={t("thread.composer.inputAria")}
|
||||
className={cn(
|
||||
inputTextClasses,
|
||||
"relative z-10 caret-foreground placeholder:text-muted-foreground/70",
|
||||
"focus:outline-none focus-visible:outline-none",
|
||||
"disabled:cursor-not-allowed",
|
||||
hasCliMentionDecorations && "text-transparent selection:bg-primary/20",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{inlineError ? (
|
||||
<div
|
||||
role="alert"
|
||||
@@ -962,6 +1133,40 @@ export function ThreadComposer({
|
||||
);
|
||||
}
|
||||
|
||||
function ComposerCliMentionOverlay({
|
||||
segments,
|
||||
isHero,
|
||||
className,
|
||||
}: {
|
||||
segments: CliAppMentionSegment[];
|
||||
isHero: boolean;
|
||||
className: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
aria-hidden
|
||||
className={cn(
|
||||
className,
|
||||
"pointer-events-none absolute inset-0 z-0 overflow-hidden whitespace-pre-wrap break-words text-foreground",
|
||||
)}
|
||||
>
|
||||
{segments.map((segment, index) => {
|
||||
if (segment.kind === "text") {
|
||||
return <span key={`text-${index}`}>{segment.text}</span>;
|
||||
}
|
||||
return (
|
||||
<CliAppMentionToken
|
||||
key={`cli-${segment.app.name}-${index}`}
|
||||
app={segment.app}
|
||||
label={segment.text}
|
||||
variant="composer"
|
||||
isHero={isHero}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
interface SlashCommandPaletteProps {
|
||||
commands: SlashCommand[];
|
||||
selectedIndex: number;
|
||||
@@ -971,6 +1176,15 @@ interface SlashCommandPaletteProps {
|
||||
onChoose: (command: SlashCommand) => void;
|
||||
}
|
||||
|
||||
interface CliAppMentionPaletteProps {
|
||||
apps: CliAppInfo[];
|
||||
selectedIndex: number;
|
||||
layout: SlashPaletteLayout;
|
||||
isHero: boolean;
|
||||
onHover: (index: number) => void;
|
||||
onChoose: (app: CliAppInfo) => void;
|
||||
}
|
||||
|
||||
function ImageAspectMenu({
|
||||
selected,
|
||||
isHero,
|
||||
@@ -1024,6 +1238,121 @@ function ImageAspectMenu({
|
||||
);
|
||||
}
|
||||
|
||||
function CliAppMentionPalette({
|
||||
apps,
|
||||
selectedIndex,
|
||||
layout,
|
||||
isHero,
|
||||
onHover,
|
||||
onChoose,
|
||||
}: CliAppMentionPaletteProps) {
|
||||
const { t } = useTranslation();
|
||||
const listMaxHeight = Math.max(
|
||||
0,
|
||||
layout.maxHeight - SLASH_PALETTE_CHROME_PX,
|
||||
);
|
||||
return (
|
||||
<div
|
||||
role="listbox"
|
||||
aria-label={t("thread.composer.mentions.ariaLabel")}
|
||||
style={{ maxHeight: layout.maxHeight }}
|
||||
className={cn(
|
||||
"absolute left-1/2 z-30 w-[calc(100%-0.5rem)] -translate-x-1/2 overflow-hidden rounded-[18px] border",
|
||||
layout.placement === "above" ? "bottom-full mb-2" : "top-full mt-2",
|
||||
"border-border/65 bg-popover p-1.5 text-popover-foreground shadow-[0_18px_55px_rgba(15,23,42,0.18)]",
|
||||
"dark:border-white/10 dark:shadow-[0_22px_55px_rgba(0,0,0,0.45)]",
|
||||
isHero ? "max-w-[58rem]" : "max-w-[49.5rem]",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 px-2 pb-1 pt-1 text-[11px] font-medium tracking-[0.08em] text-muted-foreground/70">
|
||||
<AtSign className="h-3 w-3" aria-hidden />
|
||||
<span>{t("thread.composer.mentions.label")}</span>
|
||||
</div>
|
||||
<div className="overflow-y-auto pr-0.5" style={{ maxHeight: listMaxHeight }}>
|
||||
{apps.map((app, index) => {
|
||||
const selected = index === selectedIndex;
|
||||
return (
|
||||
<button
|
||||
key={app.name}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={selected}
|
||||
onMouseEnter={() => onHover(index)}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
onChoose(app);
|
||||
}}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-3 rounded-[13px] px-3 py-2.5 text-left transition-colors",
|
||||
selected
|
||||
? "bg-primary/10 text-foreground"
|
||||
: "text-foreground/86 hover:bg-accent/55",
|
||||
)}
|
||||
>
|
||||
<CliAppMentionLogo app={app} selected={selected} />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex min-w-0 items-baseline gap-2">
|
||||
<span className="font-mono text-[13px] font-semibold text-foreground">
|
||||
@{app.name}
|
||||
</span>
|
||||
<span className="truncate text-[13px] font-medium">
|
||||
{app.display_name}
|
||||
</span>
|
||||
</span>
|
||||
<span className="mt-0.5 block truncate text-[12px] text-muted-foreground">
|
||||
{app.category}
|
||||
{app.entry_point ? ` · ${app.entry_point}` : ""}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 px-2 pt-1.5 text-[10.5px] text-muted-foreground/70">
|
||||
<span>{t("thread.composer.slash.navigateHint")}</span>
|
||||
<span>{t("thread.composer.slash.selectHint")}</span>
|
||||
<span>{t("thread.composer.slash.closeHint")}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CliAppMentionLogo({
|
||||
app,
|
||||
selected,
|
||||
}: {
|
||||
app: CliAppInfo;
|
||||
selected: boolean;
|
||||
}) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
const color = app.brand_color || "hsl(var(--primary))";
|
||||
if (app.logo_url && !failed) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"flex h-8 w-8 shrink-0 items-center justify-center rounded-[8px] border bg-background",
|
||||
selected ? "border-primary/25" : "border-border/65",
|
||||
)}
|
||||
>
|
||||
<img
|
||||
src={app.logo_url}
|
||||
alt=""
|
||||
className="h-4.5 w-4.5 object-contain"
|
||||
onError={() => setFailed(true)}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span
|
||||
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-[8px] text-[10.5px] font-semibold text-white"
|
||||
style={{ backgroundColor: color }}
|
||||
>
|
||||
{cliAppInitials(app)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function SlashCommandPalette({
|
||||
commands,
|
||||
selectedIndex,
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
AgentActivityCluster,
|
||||
isAgentActivityMember,
|
||||
} from "@/components/thread/AgentActivityCluster";
|
||||
import type { UIMessage } from "@/lib/types";
|
||||
import type { CliAppInfo, UIMessage } from "@/lib/types";
|
||||
|
||||
interface ThreadMessagesProps {
|
||||
messages: UIMessage[];
|
||||
@@ -14,6 +14,7 @@ interface ThreadMessagesProps {
|
||||
isStreaming?: boolean;
|
||||
hiddenMessageCount?: number;
|
||||
onLoadEarlier?: () => void;
|
||||
cliApps?: CliAppInfo[];
|
||||
}
|
||||
|
||||
export type DisplayUnit =
|
||||
@@ -164,6 +165,7 @@ export function ThreadMessages({
|
||||
isStreaming = false,
|
||||
hiddenMessageCount = 0,
|
||||
onLoadEarlier,
|
||||
cliApps = [],
|
||||
}: ThreadMessagesProps) {
|
||||
const { t } = useTranslation();
|
||||
const units = useMemo(() => buildDisplayUnits(messages), [messages]);
|
||||
@@ -208,6 +210,7 @@ export function ThreadMessages({
|
||||
messages={unit.messages}
|
||||
isTurnStreaming={index === liveActivityClusterIndex}
|
||||
hasBodyBelow={hasBodyBelow}
|
||||
cliApps={cliApps}
|
||||
/>
|
||||
) : (
|
||||
<MessageBubble
|
||||
@@ -217,6 +220,7 @@ export function ThreadMessages({
|
||||
? copyFlags[index]
|
||||
: true
|
||||
}
|
||||
cliApps={cliApps}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -19,8 +19,8 @@ import { StreamErrorNotice } from "@/components/thread/StreamErrorNotice";
|
||||
import { ThreadViewport } from "@/components/thread/ThreadViewport";
|
||||
import { useNanobotStream, type SendImage, type SendOptions } from "@/hooks/useNanobotStream";
|
||||
import { useSessionHistory } from "@/hooks/useSessions";
|
||||
import { listSlashCommands } from "@/lib/api";
|
||||
import type { ChatSummary, SlashCommand, UIMessage } from "@/lib/types";
|
||||
import { fetchCliApps, listSlashCommands } from "@/lib/api";
|
||||
import type { ChatSummary, CliAppInfo, SlashCommand, UIMessage } from "@/lib/types";
|
||||
import { normalizeLegacyLongTaskMessages } from "@/lib/thread-display-compat";
|
||||
import { scrubSubagentUiMessages } from "@/lib/subagent-channel-display";
|
||||
import { useClient } from "@/providers/ClientProvider";
|
||||
@@ -97,6 +97,7 @@ export function ThreadShell({
|
||||
const { client, modelName, token } = useClient();
|
||||
const [booting, setBooting] = useState(false);
|
||||
const [slashCommands, setSlashCommands] = useState<SlashCommand[]>([]);
|
||||
const [cliApps, setCliApps] = useState<CliAppInfo[]>([]);
|
||||
const [heroImageMode, setHeroImageMode] = useState(false);
|
||||
const [scrollToBottomSignal, setScrollToBottomSignal] = useState(0);
|
||||
const pendingFirstRef = useRef<PendingFirstMessage | null>(null);
|
||||
@@ -247,6 +248,40 @@ export function ThreadShell({
|
||||
};
|
||||
}, [token]);
|
||||
|
||||
const refreshCliApps = useCallback(async () => {
|
||||
try {
|
||||
const payload = await fetchCliApps(token);
|
||||
setCliApps(payload.apps.filter((app) => app.installed));
|
||||
} catch {
|
||||
setCliApps([]);
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const load = async () => {
|
||||
try {
|
||||
const payload = await fetchCliApps(token);
|
||||
if (!cancelled) setCliApps(payload.apps.filter((app) => app.installed));
|
||||
} catch {
|
||||
if (!cancelled) setCliApps([]);
|
||||
}
|
||||
};
|
||||
load();
|
||||
|
||||
const refreshOnFocus = () => {
|
||||
if (document.visibilityState === "hidden") return;
|
||||
void refreshCliApps();
|
||||
};
|
||||
window.addEventListener("focus", refreshOnFocus);
|
||||
document.addEventListener("visibilitychange", refreshOnFocus);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.removeEventListener("focus", refreshOnFocus);
|
||||
document.removeEventListener("visibilitychange", refreshOnFocus);
|
||||
};
|
||||
}, [refreshCliApps, token]);
|
||||
|
||||
const handleWelcomeSend = useCallback(
|
||||
async (content: string, images?: SendImage[], options?: SendOptions) => {
|
||||
if (booting) return;
|
||||
@@ -332,6 +367,7 @@ export function ThreadShell({
|
||||
modelLabel={toModelBadgeLabel(modelName)}
|
||||
variant={showHeroComposer ? "hero" : "thread"}
|
||||
slashCommands={slashCommands}
|
||||
cliApps={cliApps}
|
||||
imageMode={showHeroComposer ? heroImageMode : undefined}
|
||||
onImageModeChange={showHeroComposer ? setHeroImageMode : undefined}
|
||||
onStop={stop}
|
||||
@@ -351,6 +387,7 @@ export function ThreadShell({
|
||||
modelLabel={toModelBadgeLabel(modelName)}
|
||||
variant="hero"
|
||||
slashCommands={slashCommands}
|
||||
cliApps={cliApps}
|
||||
imageMode={heroImageMode}
|
||||
onImageModeChange={setHeroImageMode}
|
||||
runStartedAt={runStartedAt}
|
||||
@@ -391,6 +428,7 @@ export function ThreadShell({
|
||||
scrollToBottomSignal={scrollToBottomSignal}
|
||||
conversationKey={historyKey}
|
||||
showScrollToBottomButton={!!session}
|
||||
cliApps={cliApps}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -14,7 +14,7 @@ import { ThreadMessages } from "@/components/thread/ThreadMessages";
|
||||
import { isAgentActivityMember } from "@/components/thread/AgentActivityCluster";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { UIMessage } from "@/lib/types";
|
||||
import type { CliAppInfo, UIMessage } from "@/lib/types";
|
||||
|
||||
interface ThreadViewportProps {
|
||||
messages: UIMessage[];
|
||||
@@ -24,6 +24,7 @@ interface ThreadViewportProps {
|
||||
scrollToBottomSignal?: number;
|
||||
conversationKey?: string | null;
|
||||
showScrollToBottomButton?: boolean;
|
||||
cliApps?: CliAppInfo[];
|
||||
}
|
||||
|
||||
const NEAR_BOTTOM_PX = 48;
|
||||
@@ -53,6 +54,7 @@ export function ThreadViewport({
|
||||
scrollToBottomSignal = 0,
|
||||
conversationKey = null,
|
||||
showScrollToBottomButton = true,
|
||||
cliApps = [],
|
||||
}: ThreadViewportProps) {
|
||||
const { t } = useTranslation();
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
@@ -249,6 +251,7 @@ export function ThreadViewport({
|
||||
isStreaming={isStreaming}
|
||||
hiddenMessageCount={hiddenMessageCount}
|
||||
onLoadEarlier={loadEarlierMessages}
|
||||
cliApps={cliApps}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user