feat(webui): polish agent output and app discovery
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user