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