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
+10 -1
View File
@@ -47,16 +47,24 @@ export function useLogoFallback(urls: readonly string[] | undefined) {
const safeUrls = useMemo(() => logoUrlsFromKey(cacheKey), [cacheKey]);
const [logoIndex, setLogoIndex] = useState(() => firstUsableLogoIndex(safeUrls));
const logoUrl = logoIndex >= 0 ? safeUrls[logoIndex] : undefined;
const [logoLoaded, setLogoLoaded] = useState(
() => Boolean(logoUrl && loadedLogoUrls.has(logoUrl)),
);
useEffect(() => {
setLogoIndex(firstUsableLogoIndex(safeUrls));
}, [cacheKey, safeUrls]);
useEffect(() => {
setLogoLoaded(Boolean(logoUrl && loadedLogoUrls.has(logoUrl)));
}, [logoUrl]);
const onLogoLoad = useCallback(() => {
if (!logoUrl || logoIndex < 0) return;
loadedLogoUrls.add(logoUrl);
failedLogoUrls.delete(logoUrl);
resolvedLogoIndexByKey.set(cacheKey, logoIndex);
setLogoLoaded(true);
}, [cacheKey, logoIndex, logoUrl]);
const onLogoError = useCallback(() => {
@@ -65,10 +73,11 @@ export function useLogoFallback(urls: readonly string[] | undefined) {
if (resolvedLogoIndexByKey.get(cacheKey) === logoIndex) {
resolvedLogoIndexByKey.delete(cacheKey);
}
setLogoLoaded(false);
setLogoIndex(nextLogoIndex(safeUrls, logoIndex));
}, [cacheKey, logoIndex, logoUrl, safeUrls]);
return { logoUrl, onLogoLoad, onLogoError };
return { logoUrl, logoLoaded, onLogoLoad, onLogoError };
}
export function __clearLogoFallbackCacheForTests(): void {
+69 -23
View File
@@ -42,6 +42,7 @@ type UIMessageTurnFields = Pick<UIMessage, "turnId" | "turnPhase" | "turnSeq">;
const FILE_EDIT_TOOL_NAMES = new Set(["write_file", "edit_file", "apply_patch"]);
const STREAM_END_IDLE_DELAY_MS = 1000;
const BACKGROUND_STREAM_FLUSH_INTERVAL_MS = 1_000;
function turnFieldsFromEvent(
ev: { turn_id?: string; turn_phase?: UITurnPhase; turn_seq?: number },
@@ -478,9 +479,12 @@ export interface SendAttachment {
export interface SendOptions {
cliApps?: OutboundCliAppMention[];
mcpPresets?: OutboundMcpPresetMention[];
quotedContext?: string;
workspaceScope?: WorkspaceScopePayload | null;
sideChannel?: boolean;
finalizeActiveTurn?: boolean;
/** Append guidance to the running turn without detaching its active answer segment. */
continueActiveTurn?: boolean;
}
function eventExtendsModelActivity(ev: InboundEvent): boolean {
@@ -548,6 +552,7 @@ export function useNanobotStream(
const activitySegmentCounterRef = useRef(0);
const pendingStreamEventsRef = useRef<PendingStreamEvent[]>([]);
const streamFrameRef = useRef<number | null>(null);
const streamTimerRef = useRef<number | null>(null);
const suppressStreamUntilTurnEndRef = useRef(false);
const sideChannelTurnIdsRef = useRef<Set<string>>(new Set());
/** Timer that defers ``isStreaming = false`` after ``stream_end``.
@@ -570,6 +575,10 @@ export function useNanobotStream(
window.cancelAnimationFrame(streamFrameRef.current);
streamFrameRef.current = null;
}
if (streamTimerRef.current !== null) {
window.clearTimeout(streamTimerRef.current);
streamTimerRef.current = null;
}
pendingStreamEventsRef.current = [];
}, []);
@@ -734,6 +743,10 @@ export function useNanobotStream(
window.cancelAnimationFrame(streamFrameRef.current);
streamFrameRef.current = null;
}
if (streamTimerRef.current !== null) {
window.clearTimeout(streamTimerRef.current);
streamTimerRef.current = null;
}
const events = pendingStreamEventsRef.current;
const finalAnswerText = options?.finalAnswerText;
const turn = options?.turn ?? {};
@@ -748,37 +761,47 @@ export function useNanobotStream(
const targetIndex =
resolveActiveAssistantIndex(next, turn)
?? findStreamingAssistantIndex(next, closedAssistantStreamIdsRef.current, turn);
if (targetIndex !== null) {
const target = next[targetIndex];
next = replaceMessageAt(next, targetIndex, {
...target,
if (targetIndex !== null) {
const target = next[targetIndex];
next = replaceMessageAt(next, targetIndex, {
...target,
content: finalAnswerText,
isStreaming: true,
...turn,
});
} else {
const id = crypto.randomUUID();
closedAssistantStreamIdsRef.current.add(id);
next = [
...next,
{
id,
role: "assistant",
content: finalAnswerText,
isStreaming: true,
...turn,
});
} else {
const id = crypto.randomUUID();
closedAssistantStreamIdsRef.current.add(id);
next = [
...next,
{
id,
role: "assistant",
content: finalAnswerText,
isStreaming: true,
...turn,
createdAt: Date.now(),
},
];
}
createdAt: Date.now(),
},
];
}
}
if (options?.closeAnswerSegment) closeActiveAssistantStream();
return next;
});
}, [applyPendingStreamEvents, closeActiveAssistantStream, resolveActiveAssistantIndex]);
const schedulePendingStreamFlush = useCallback(() => {
if (streamFrameRef.current !== null) return;
if (streamFrameRef.current !== null || streamTimerRef.current !== null) return;
if (document.visibilityState === "hidden") {
streamTimerRef.current = window.setTimeout(() => {
streamTimerRef.current = null;
const events = pendingStreamEventsRef.current;
if (events.length === 0) return;
pendingStreamEventsRef.current = [];
setMessages((prev) => applyPendingStreamEvents(prev, events));
}, BACKGROUND_STREAM_FLUSH_INTERVAL_MS);
return;
}
streamFrameRef.current = window.requestAnimationFrame(() => {
streamFrameRef.current = null;
const events = pendingStreamEventsRef.current;
@@ -788,6 +811,16 @@ export function useNanobotStream(
});
}, [applyPendingStreamEvents]);
useEffect(() => {
const flushOnReturn = () => {
if (document.visibilityState !== "visible") return;
if (pendingStreamEventsRef.current.length === 0) return;
flushPendingStreamEvents();
};
document.addEventListener("visibilitychange", flushOnReturn);
return () => document.removeEventListener("visibilitychange", flushOnReturn);
}, [flushPendingStreamEvents]);
// Reset local state when switching chats. Do not reset on every
// ``initialMessages`` update: a brand-new chat can receive an empty/404
// history response after the optimistic first message has already rendered.
@@ -863,6 +896,12 @@ export function useNanobotStream(
turn,
});
if (suppressStreamUntilTurnEndRef.current) return;
if (ev.resuming) {
cancelStreamEndTimer();
setIsStreaming(true);
setMessages((prev) => finalizeStreamedTurn(prev, turn));
return;
}
scheduleStreamEndTimer(turn);
return;
}
@@ -1144,6 +1183,7 @@ export function useNanobotStream(
const sideChannel = options?.sideChannel === true;
const finalizeActiveTurn = options?.finalizeActiveTurn === true;
const continueActiveTurn = options?.continueActiveTurn === true;
flushPendingStreamEvents();
if (finalizeActiveTurn) {
cancelStreamEndTimer();
@@ -1153,16 +1193,21 @@ export function useNanobotStream(
if (sideChannel) sideChannelTurnIdsRef.current.add(turnId);
const previews = hasAttachments ? images!.map((i) => i.preview) : undefined;
setMessages((prev) => {
if (!sideChannel || finalizeActiveTurn) {
if ((!sideChannel && !continueActiveTurn) || finalizeActiveTurn) {
buffer.current = null;
activeAssistantRef.current = null;
closedAssistantStreamIdsRef.current.clear();
clearActivitySegment();
suppressStreamUntilTurnEndRef.current = false;
} else if (continueActiveTurn) {
// Guidance belongs to the active backend turn. Preserve the answer
// cursor so its resuming stream_end can finalize the text already
// shown before the new user row, while starting fresh activity after it.
clearActivitySegment();
}
const base = finalizeActiveTurn ? finalizeStreamedTurn(prev) : prev;
return [
...(sideChannel ? base : pruneReasoningOnlyPlaceholders(base)),
...(sideChannel || continueActiveTurn ? base : pruneReasoningOnlyPlaceholders(base)),
{
id: crypto.randomUUID(),
role: "user",
@@ -1182,6 +1227,7 @@ export function useNanobotStream(
const wireOptions = { ...options, turnId };
delete wireOptions.sideChannel;
delete wireOptions.finalizeActiveTurn;
delete wireOptions.continueActiveTurn;
client.sendMessage(chatId, content, wireMedia, wireOptions);
},
[cancelStreamEndTimer, chatId, clearActivitySegment, client, flushPendingStreamEvents],
+18
View File
@@ -0,0 +1,18 @@
import { useEffect, useState } from "react";
function pageIsVisible(): boolean {
return typeof document === "undefined" || document.visibilityState !== "hidden";
}
/** Keep background tabs quiet while resuming work immediately on return. */
export function usePageVisibility(): boolean {
const [visible, setVisible] = useState(pageIsVisible);
useEffect(() => {
const update = () => setVisible(pageIsVisible());
document.addEventListener("visibilitychange", update);
return () => document.removeEventListener("visibilitychange", update);
}, []);
return visible;
}
+7 -9
View File
@@ -1,18 +1,20 @@
import { useEffect, useState } from "react";
import { usePageVisibility } from "@/hooks/usePageVisibility";
import { fetchSessionAutomations } from "@/lib/api";
import type { SessionAutomationJob } from "@/lib/types";
const AUTOMATIONS_REFRESH_MS = 3000;
export function useSessionAutomationJobs(open: boolean, token: string, sessionKey: string) {
const pageVisible = usePageVisibility();
const [jobs, setJobs] = useState<SessionAutomationJob[]>([]);
const [loading, setLoading] = useState(false);
const [loadFailed, setLoadFailed] = useState(false);
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
if (!open) return;
if (!open || !pageVisible) return;
let cancelled = false;
let loadedOnce = false;
@@ -37,25 +39,21 @@ export function useSessionAutomationJobs(open: boolean, token: string, sessionKe
void refresh(true);
const refreshId = window.setInterval(() => void refresh(false), AUTOMATIONS_REFRESH_MS);
const refreshOnFocus = () => {
if (document.visibilityState !== "hidden") void refresh(false);
};
const refreshOnFocus = () => void refresh(false);
window.addEventListener("focus", refreshOnFocus);
document.addEventListener("visibilitychange", refreshOnFocus);
return () => {
cancelled = true;
window.clearInterval(refreshId);
window.removeEventListener("focus", refreshOnFocus);
document.removeEventListener("visibilitychange", refreshOnFocus);
};
}, [open, sessionKey, token]);
}, [open, pageVisible, sessionKey, token]);
useEffect(() => {
if (!open) return;
if (!open || !pageVisible) return;
setNow(Date.now());
const tickId = window.setInterval(() => setNow(Date.now()), 1000);
return () => window.clearInterval(tickId);
}, [open]);
}, [open, pageVisible]);
return { jobs, loading, loadFailed, now };
}