feat(webui): polish agent output and app discovery

This commit is contained in:
Xubin Ren
2026-07-22 22:42:31 +08:00
parent b189a37648
commit aa8387fb4d
87 changed files with 6225 additions and 2379 deletions
+92 -14
View File
@@ -35,6 +35,7 @@ import {
Loader2,
Mic,
Plus,
Quote,
RotateCw,
Shield,
Sparkles,
@@ -71,6 +72,7 @@ import {
import { useClipboardAndDrop } from "@/hooks/useClipboardAndDrop";
import { useLogoFallback } from "@/hooks/useLogoFallback";
import type { SendAttachment, SendOptions } from "@/hooks/useNanobotStream";
import { usePageVisibility } from "@/hooks/usePageVisibility";
import { useVoiceRecorder, type VoiceRecorderErrorKey } from "@/hooks/useVoiceRecorder";
import type {
CliAppInfo,
@@ -189,6 +191,9 @@ interface ThreadComposerProps {
pendingQueueKey?: string | null;
transcriptionProvider?: string | null;
ingressLimits?: WebUIIngressLimits | null;
quotedContext?: string | null;
focusRequest?: number;
onQuotedContextChange?: (text: string | null) => void;
}
const COMMAND_ICONS: Record<string, LucideIcon> = {
@@ -265,6 +270,7 @@ interface QueuedPrompt {
id: string;
text: string;
images?: QueuedPromptImage[];
quotedContext?: string;
}
interface QueuedPromptImage {
@@ -355,11 +361,19 @@ function normalizeQueuedPrompt(item: unknown, index: number): QueuedPrompt | nul
}];
}).slice(0, MAX_ATTACHMENTS_PER_MESSAGE)
: [];
const quotedContext = typeof record.quotedContext === "string"
? record.quotedContext.trim().slice(0, QUEUED_PROMPT_MAX_CHARS)
: "";
if (!text && images.length === 0) return null;
const id = typeof record.id === "string" && record.id.trim()
? record.id
: `queued-prompt-restored-${index}`;
return { id, text, ...(images.length > 0 ? { images } : {}) };
return {
id,
text,
...(images.length > 0 ? { images } : {}),
...(quotedContext ? { quotedContext } : {}),
};
}
function readQueuedPrompts(storageKey: string): QueuedPrompt[] {
@@ -391,6 +405,7 @@ function storeQueuedPrompts(storageKey: string, prompts: QueuedPrompt[]): void {
id: prompt.id,
text: prompt.text.slice(0, QUEUED_PROMPT_MAX_CHARS),
...(prompt.images?.length ? { images: prompt.images.slice(0, MAX_ATTACHMENTS_PER_MESSAGE) } : {}),
...(prompt.quotedContext ? { quotedContext: prompt.quotedContext } : {}),
})),
),
);
@@ -555,6 +570,7 @@ function RunElapsedStrip({
goalState?: GoalStateWsPayload;
}) {
const { t } = useTranslation();
const pageVisible = usePageVisibility();
const [goalPanelOpen, setGoalPanelOpen] = useState(false);
const showTimer = startedAt != null;
const stripLabel = goalStateStripPreview(goalState, t);
@@ -594,10 +610,11 @@ function RunElapsedStrip({
}, [active, renderStrip]);
useEffect(() => {
if (startedAt == null) return;
if (startedAt == null || !pageVisible) return;
setTick((n) => n + 1);
const id = window.setInterval(() => setTick((n) => n + 1), 1000);
return () => window.clearInterval(id);
}, [startedAt]);
}, [pageVisible, startedAt]);
const display = active
? { startedAt, goalState, stripLabel }
@@ -629,7 +646,7 @@ function RunElapsedStrip({
relayout();
preloadMarkdownText();
void preloadMarkdownText();
const ro =
typeof ResizeObserver !== "undefined"
? new ResizeObserver(() => relayout())
@@ -817,6 +834,9 @@ export function ThreadComposer({
pendingQueueKey = null,
transcriptionProvider = null,
ingressLimits = null,
quotedContext = null,
focusRequest = 0,
onQuotedContextChange,
}: ThreadComposerProps) {
const { t } = useTranslation();
const [value, setValue] = useState("");
@@ -938,6 +958,14 @@ export function ThreadComposer({
return () => cancelAnimationFrame(id);
}, [disabled]);
useEffect(() => {
if (!focusRequest || disabled) return;
const id = requestAnimationFrame(() => textareaRef.current?.focus());
return () => cancelAnimationFrame(id);
}, [disabled, focusRequest]);
const normalizedQuotedContext = quotedContext?.trim().slice(0, QUEUED_PROMPT_MAX_CHARS) || null;
const readyImages = useMemo(
() => images.filter((img): img is AttachedImage & { dataUrl: string } =>
img.status === "ready" && typeof img.dataUrl === "string",
@@ -1450,11 +1478,23 @@ export function ThreadComposer({
id,
text,
...(queuedImages.length > 0 ? { images: queuedImages } : {}),
...(normalizedQuotedContext ? { quotedContext: normalizedQuotedContext } : {}),
},
]);
clear();
clearComposerText();
}, [canQueueGuidance, clear, clearComposerText, maxTextBytes, readyImages, textTooLargeMessage, value]);
onQuotedContextChange?.(null);
}, [
canQueueGuidance,
clear,
clearComposerText,
maxTextBytes,
normalizedQuotedContext,
onQuotedContextChange,
readyImages,
textTooLargeMessage,
value,
]);
const removeQueuedPrompt = useCallback((id: string) => {
secondEnterPromptIdRef.current = null;
@@ -1470,6 +1510,7 @@ export function ThreadComposer({
setSlashMenuDismissed(false);
setCliAppMenuDismissed(false);
setCursorPosition(prompt.text.length);
onQuotedContextChange?.(prompt.quotedContext ?? null);
if (prompt.images?.length) {
restoreReadyImages(prompt.images as RestoredReadyImage[]);
} else {
@@ -1482,7 +1523,7 @@ export function ThreadComposer({
el.focus();
el.setSelectionRange(prompt.text.length, prompt.text.length);
});
}, [clear, resizeTextarea, restoreReadyImages]);
}, [clear, onQuotedContextChange, resizeTextarea, restoreReadyImages]);
const moveQueuedPrompt = useCallback((dragId: string, targetId: string) => {
if (dragId === targetId) return;
@@ -1505,12 +1546,17 @@ export function ThreadComposer({
const queuedImages = queuedImagesToSendImages(prompt.images);
setQueuedPrompts((items) => items.filter((item) => item.id !== prompt.id));
if (text || queuedImages?.length) {
if (queuedImages?.length) onSend(text, queuedImages);
else onSend(text);
const options: SendOptions | undefined = prompt.quotedContext || isStreaming
? {
...(prompt.quotedContext ? { quotedContext: prompt.quotedContext } : {}),
...(isStreaming ? { continueActiveTurn: true } : {}),
}
: undefined;
onSend(text, queuedImages, options);
}
requestAnimationFrame(() => textareaRef.current?.focus());
},
[onSend],
[isStreaming, onSend],
);
const sendNextQueuedPrompt = useCallback(() => {
@@ -1522,7 +1568,12 @@ export function ThreadComposer({
}
setQueuedPrompts((items) => items.filter((item) => item.id !== nextPrompt.id));
const queuedImages = queuedImagesToSendImages(nextPrompt.images);
if (queuedImages?.length) onSend(nextPrompt.text.trim(), queuedImages);
const options = nextPrompt.quotedContext
? { quotedContext: nextPrompt.quotedContext }
: undefined;
if (queuedImages?.length && options) onSend(nextPrompt.text.trim(), queuedImages, options);
else if (queuedImages?.length) onSend(nextPrompt.text.trim(), queuedImages);
else if (options) onSend(nextPrompt.text.trim(), undefined, options);
else onSend(nextPrompt.text.trim());
requestAnimationFrame(() => textareaRef.current?.focus());
}, [onSend, queuedPrompts]);
@@ -1576,10 +1627,11 @@ export function ThreadComposer({
const attachedCliApps = activeCliMentionApps.map(cliAppMentionPayload);
const attachedMcpPresets = activeMcpPresetMentions.map(mcpPresetMentionPayload);
const options: SendOptions | undefined =
attachedCliApps.length > 0 || attachedMcpPresets.length > 0
attachedCliApps.length > 0 || attachedMcpPresets.length > 0 || normalizedQuotedContext
? {
...(attachedCliApps.length > 0 ? { cliApps: attachedCliApps } : {}),
...(attachedMcpPresets.length > 0 ? { mcpPresets: attachedMcpPresets } : {}),
...(normalizedQuotedContext ? { quotedContext: normalizedQuotedContext } : {}),
}
: undefined;
const hasPlainTextCommandPayload =
@@ -1598,6 +1650,7 @@ export function ThreadComposer({
setQueuedPrompts([]);
clear();
clearComposerText();
onQuotedContextChange?.(null);
return;
}
const isSlashSideChannel = isSideChannelLifecycle(slashLifecycle);
@@ -1619,6 +1672,7 @@ export function ThreadComposer({
// preview here without affecting the rendered message.
clear();
clearComposerText();
onQuotedContextChange?.(null);
}, [
activeCliMentionApps,
activeMcpPresetMentions,
@@ -1632,6 +1686,8 @@ export function ThreadComposer({
onModelBadgeClick,
onSend,
onStop,
onQuotedContextChange,
normalizedQuotedContext,
readyImages,
slashCommands,
textTooLargeMessage,
@@ -1884,6 +1940,28 @@ export function ThreadComposer({
))}
</div>
) : null}
{normalizedQuotedContext ? (
<div
className="mx-3 mt-3 flex min-w-0 items-start gap-2 border-l-2 border-muted-foreground/25 pl-3 pr-1 text-muted-foreground"
aria-label={t("thread.composer.quotedContext")}
>
<Quote className="mt-0.5 h-3.5 w-3.5 shrink-0" aria-hidden />
<p className="line-clamp-2 min-w-0 flex-1 text-[13px]/[1.45]">
{normalizedQuotedContext}
</p>
<button
type="button"
className="touch-target -mr-1 inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-full transition-colors hover:bg-muted/70 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label={t("thread.composer.removeQuotedContext")}
onClick={() => {
onQuotedContextChange?.(null);
requestAnimationFrame(() => textareaRef.current?.focus());
}}
>
<X className="h-3.5 w-3.5" aria-hidden />
</button>
</div>
) : null}
<RunElapsedStrip startedAt={runStartedAt} goalState={goalState} />
<div className="relative">
{hasMentionDecorations ? (
@@ -1962,7 +2040,7 @@ export function ThreadComposer({
aria-label={t("thread.composer.attachImage")}
onClick={() => fileInputRef.current?.click()}
className={cn(
"rounded-full text-muted-foreground hover:text-foreground",
"touch-target rounded-full text-muted-foreground hover:text-foreground",
isHero
? "h-8 w-8 border border-border/55 bg-card shadow-[0_2px_8px_rgba(15,23,42,0.05)] hover:bg-card"
: "h-9 w-9 border border-border/55 bg-card shadow-[0_2px_8px_rgba(15,23,42,0.05)] hover:bg-card",
@@ -2016,7 +2094,7 @@ export function ThreadComposer({
onPointerCancel={voiceRecorder.endPress}
onClick={voiceRecorder.handleClick}
className={cn(
"rounded-full border border-transparent text-muted-foreground hover:bg-muted/65 hover:text-foreground",
"touch-target rounded-full border border-transparent text-muted-foreground hover:bg-muted/65 hover:text-foreground",
isHero ? "h-8 w-8" : "h-9 w-9",
voiceRecorder.isRecording &&
"bg-red-500 text-white shadow-[0_8px_20px_rgba(239,68,68,0.22)] hover:bg-red-500 hover:text-white",
@@ -2059,7 +2137,7 @@ export function ThreadComposer({
}
onClick={showStopButton ? handleStop : modelNeedsSetup ? onModelBadgeClick : undefined}
className={cn(
"rounded-full transition-transform",
"touch-target rounded-full transition-transform",
showStopButton
? "border border-border/70 bg-card text-foreground/85 shadow-[0_3px_10px_rgba(15,23,42,0.08)] hover:bg-muted/65 hover:text-foreground disabled:text-muted-foreground/50"
: isHero