feat(webui): improve beta turn completion and streaming UX

This commit is contained in:
ramonpaolo
2026-05-03 22:28:40 +08:00
committed by Xubin Ren
parent 5853d5dfda
commit 76e3f74df7
18 changed files with 850 additions and 28 deletions
+2 -1
View File
@@ -22,7 +22,7 @@ interface ChatPaneProps {
export function ChatPane({ session, onNewChat }: ChatPaneProps) {
const chatId = session?.chatId ?? null;
const historyKey = session?.key ?? null;
const { messages: historical, loading } = useSessionHistory(historyKey);
const { messages: historical, loading, hasPendingToolCalls } = useSessionHistory(historyKey);
const { client } = useClient();
const [booting, setBooting] = useState(false);
const pendingFirstRef = useRef<string | null>(null);
@@ -31,6 +31,7 @@ export function ChatPane({ session, onNewChat }: ChatPaneProps) {
const { messages, isStreaming, send, setMessages } = useNanobotStream(
chatId,
initial,
hasPendingToolCalls,
);
useEffect(() => {
+10 -3
View File
@@ -40,6 +40,7 @@ interface ThreadComposerProps {
onSend: (content: string, images?: SendImage[]) => void;
disabled?: boolean;
placeholder?: string;
isStreaming?: boolean;
modelLabel?: string | null;
variant?: "thread" | "hero";
}
@@ -48,6 +49,7 @@ export function ThreadComposer({
onSend,
disabled,
placeholder,
isStreaming = false,
modelLabel = null,
variant = "thread",
}: ThreadComposerProps) {
@@ -58,8 +60,9 @@ export function ThreadComposer({
const fileInputRef = useRef<HTMLInputElement>(null);
const chipRefs = useRef(new Map<string, HTMLButtonElement>());
const isHero = variant === "hero";
const resolvedPlaceholder =
placeholder ?? t("thread.composer.placeholderThread");
const resolvedPlaceholder = isStreaming
? t("thread.composer.placeholderStreaming")
: placeholder ?? t("thread.composer.placeholderThread");
const { images, enqueue, remove, clear, encoding, full } =
useAttachedImages();
@@ -344,7 +347,11 @@ export function ThreadComposer({
canSend && "hover:scale-[1.03] active:scale-95",
)}
>
<ArrowUp className={cn(isHero ? "h-4.5 w-4.5" : "h-4 w-4")} />
{isStreaming ? (
<Loader2 className={cn(isHero ? "h-4.5 w-4.5" : "h-4 w-4", "animate-spin")} />
) : (
<ArrowUp className={cn(isHero ? "h-4.5 w-4.5" : "h-4 w-4")} />
)}
</Button>
</div>
</div>
+4 -2
View File
@@ -39,7 +39,7 @@ export function ThreadShell({
const { t } = useTranslation();
const chatId = session?.chatId ?? null;
const historyKey = session?.key ?? null;
const { messages: historical, loading } = useSessionHistory(historyKey);
const { messages: historical, loading, hasPendingToolCalls } = useSessionHistory(historyKey);
const { client, modelName } = useClient();
const [booting, setBooting] = useState(false);
const pendingFirstRef = useRef<string | null>(null);
@@ -56,7 +56,7 @@ export function ThreadShell({
setMessages,
streamError,
dismissStreamError,
} = useNanobotStream(chatId, initial);
} = useNanobotStream(chatId, initial, hasPendingToolCalls);
const showHeroComposer = messages.length === 0 && !loading;
const pendingAsk = useMemo(() => {
for (let index = messages.length - 1; index >= 0; index -= 1) {
@@ -179,6 +179,7 @@ export function ThreadShell({
<ThreadComposer
onSend={send}
disabled={!chatId}
isStreaming={isStreaming}
placeholder={
showHeroComposer
? t("thread.composer.placeholderHero")
@@ -191,6 +192,7 @@ export function ThreadShell({
<ThreadComposer
onSend={handleWelcomeSend}
disabled={booting}
isStreaming={isStreaming}
placeholder={
booting
? t("thread.composer.placeholderOpening")
+72 -20
View File
@@ -37,6 +37,7 @@ export interface SendImage {
export function useNanobotStream(
chatId: string | null,
initialMessages: UIMessage[] = [],
hasPendingToolCalls = false,
): {
messages: UIMessage[];
isStreaming: boolean;
@@ -51,9 +52,23 @@ export function useNanobotStream(
} {
const { client } = useClient();
const [messages, setMessages] = useState<UIMessage[]>(initialMessages);
const [isStreaming, setIsStreaming] = useState(false);
/** If the last loaded message is a trace row (e.g. "Using 2 tools"),
* the model was still processing when the page loaded — keep the
* loading spinner alive so the user sees the model is active. */
const initialStreaming = initialMessages.length > 0
? initialMessages[initialMessages.length - 1].kind === "trace"
: false;
const [isStreaming, setIsStreaming] = useState(initialStreaming || hasPendingToolCalls);
const [streamError, setStreamError] = useState<StreamError | null>(null);
const buffer = useRef<StreamBuffer | null>(null);
/** Timer that defers ``isStreaming = false`` after ``stream_end``.
*
* When the model finishes a text segment and calls a tool, the server
* sends ``stream_end`` but the agent is still "thinking" while the tool
* executes. By deferring the flag reset by a short window (1 s) we keep
* the loading spinner alive across tool-call boundaries without needing
* backend changes. */
const streamEndTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
return client.onError((err) => setStreamError(err));
@@ -62,21 +77,43 @@ export function useNanobotStream(
const dismissStreamError = useCallback(() => setStreamError(null), []);
// Reset local state when switching chats. ``streamError`` is scoped to the
// send that triggered it, so a chat swap should wipe it out: a stale
// "Message too large" banner on a freshly-opened chat-B would confuse the
// user about which send actually failed (and in which chat).
useEffect(() => {
setMessages(initialMessages);
setIsStreaming(false);
setStreamError(null);
buffer.current = null;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [chatId]);
// send that triggered it, so a chat swap should wipe it out: a stale
// "Message too large" banner on a freshly-opened chat-B would confuse the
// user about which send actually failed (and in which chat).
useEffect(() => {
setMessages(initialMessages);
// Check if the new chat's last message is a trace row — if so, the
// model may still be processing.
setIsStreaming(
initialMessages.length > 0
? initialMessages[initialMessages.length - 1].kind === "trace"
: false,
);
// Also consider hasPendingToolCalls from session history.
if (hasPendingToolCalls) {
setIsStreaming(true);
}
setStreamError(null);
buffer.current = null;
if (streamEndTimerRef.current !== null) {
clearTimeout(streamEndTimerRef.current);
streamEndTimerRef.current = null;
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [chatId, initialMessages, hasPendingToolCalls]);
useEffect(() => {
if (!chatId) return;
const handle = (ev: InboundEvent) => {
// Any incoming event while the debounce timer is alive means the model
// is still working (e.g. tool result arrived, more text to stream).
// Cancel the pending "stream ended" timer so we don't hide the spinner.
if (streamEndTimerRef.current !== null) {
clearTimeout(streamEndTimerRef.current);
streamEndTimerRef.current = null;
}
if (ev.event === "delta") {
const id = buffer.current?.messageId ?? crypto.randomUUID();
if (!buffer.current) {
@@ -103,17 +140,24 @@ export function useNanobotStream(
}
if (ev.event === "stream_end") {
if (!buffer.current) {
setIsStreaming(false);
return;
}
const finalId = buffer.current.messageId;
// stream_end only means the text segment finished — the model may
// still be executing tools. Do NOT reset isStreaming here; the
// definitive "turn is complete" signal is ``turn_end``.
if (!buffer.current) return;
buffer.current = null;
return;
}
if (ev.event === "turn_end") {
// Definitive signal that the turn is fully complete. Cancel any
// pending debounce timer and stop the loading indicator immediately.
if (streamEndTimerRef.current !== null) {
clearTimeout(streamEndTimerRef.current);
streamEndTimerRef.current = null;
}
setIsStreaming(false);
setMessages((prev) =>
prev.map((m) =>
m.id === finalId ? { ...m, isStreaming: false } : m,
),
prev.map((m) => (m.isStreaming ? { ...m, isStreaming: false } : m)),
);
return;
}
@@ -157,7 +201,8 @@ export function useNanobotStream(
// flight, drop the placeholder so we don't render the text twice.
const activeId = buffer.current?.messageId;
buffer.current = null;
setIsStreaming(false);
// Do NOT reset isStreaming here — only ``turn_end`` signals that
// the full turn (all tool calls + final text) is complete.
setMessages((prev) => {
const filtered = activeId ? prev.filter((m) => m.id !== activeId) : prev;
const content = ev.buttons?.length ? (ev.button_prompt ?? ev.text) : ev.text;
@@ -183,6 +228,10 @@ export function useNanobotStream(
return () => {
unsub();
buffer.current = null;
if (streamEndTimerRef.current !== null) {
clearTimeout(streamEndTimerRef.current);
streamEndTimerRef.current = null;
}
};
}, [chatId, client]);
@@ -205,6 +254,9 @@ export function useNanobotStream(
...(previews ? { images: previews } : {}),
},
]);
// Mark streaming immediately so the UI shows the loading indicator
// right away, before the first delta arrives from the server.
setIsStreaming(true);
const wireMedia = hasImages ? images!.map((i) => i.media) : undefined;
client.sendMessage(chatId, content, wireMedia);
},
+20 -2
View File
@@ -84,6 +84,9 @@ export function useSessionHistory(key: string | null): {
messages: UIMessage[];
loading: boolean;
error: string | null;
/** ``true`` when the last persisted message has ``tool_calls`` but no
* final text yet — the model was still processing when the page loaded. */
hasPendingToolCalls: boolean;
} {
const { token } = useClient();
const [state, setState] = useState<{
@@ -91,11 +94,13 @@ export function useSessionHistory(key: string | null): {
messages: UIMessage[];
loading: boolean;
error: string | null;
hasPendingToolCalls: boolean;
}>({
key: null,
messages: [],
loading: false,
error: null,
hasPendingToolCalls: false,
});
useEffect(() => {
@@ -105,6 +110,7 @@ export function useSessionHistory(key: string | null): {
messages: [],
loading: false,
error: null,
hasPendingToolCalls: false,
});
return;
}
@@ -116,6 +122,7 @@ export function useSessionHistory(key: string | null): {
messages: [],
loading: true,
error: null,
hasPendingToolCalls: false,
});
(async () => {
try {
@@ -146,11 +153,19 @@ export function useSessionHistory(key: string | null): {
},
];
});
// Check if the last persisted message has tool_calls but no final
// text yet — the model was still processing when the page loaded.
const lastRaw = body.messages[body.messages.length - 1];
const hasPending =
lastRaw?.role === "assistant" &&
Array.isArray(lastRaw.tool_calls) &&
lastRaw.tool_calls.length > 0;
setState({
key,
messages: ui,
loading: false,
error: null,
hasPendingToolCalls: hasPending,
});
} catch (e) {
if (cancelled) return;
@@ -162,6 +177,7 @@ export function useSessionHistory(key: string | null): {
messages: [],
loading: false,
error: null,
hasPendingToolCalls: false,
});
} else {
setState({
@@ -169,6 +185,7 @@ export function useSessionHistory(key: string | null): {
messages: [],
loading: false,
error: (e as Error).message,
hasPendingToolCalls: false,
});
}
}
@@ -179,19 +196,20 @@ export function useSessionHistory(key: string | null): {
}, [key, token]);
if (!key) {
return { messages: EMPTY_MESSAGES, loading: false, error: null };
return { messages: EMPTY_MESSAGES, loading: false, error: null, hasPendingToolCalls: false };
}
// Even before the effect above commits its loading state, never surface the
// previous session's payload for a brand-new key.
if (state.key !== key) {
return { messages: EMPTY_MESSAGES, loading: true, error: null };
return { messages: EMPTY_MESSAGES, loading: true, error: null, hasPendingToolCalls: false };
}
return {
messages: state.messages,
loading: state.loading,
error: state.error,
hasPendingToolCalls: state.hasPendingToolCalls,
};
}
+1
View File
@@ -62,6 +62,7 @@
"placeholderThread": "Type your message…",
"placeholderHero": "What's on your mind?",
"placeholderOpening": "Opening a new chat…",
"placeholderStreaming": "Model is responding…",
"inputAria": "Message input",
"sendHint": "Enter to send · Shift+Enter for newline",
"send": "Send message",
+1
View File
@@ -62,6 +62,7 @@
"placeholderThread": "Escribe tu mensaje…",
"placeholderHero": "¿Qué tienes en mente?",
"placeholderOpening": "Abriendo un nuevo chat…",
"placeholderStreaming": "El modelo está respondiendo…",
"inputAria": "Entrada de mensaje",
"sendHint": "Enter para enviar · Shift+Enter para nueva línea",
"send": "Enviar mensaje",
+1
View File
@@ -62,6 +62,7 @@
"placeholderThread": "Saisissez votre message…",
"placeholderHero": "Quavez-vous en tête ?",
"placeholderOpening": "Ouverture dune nouvelle discussion…",
"placeholderStreaming": "Le modèle est en train de répondre…",
"inputAria": "Champ de message",
"sendHint": "Entrée pour envoyer · Maj+Entrée pour un retour à la ligne",
"send": "Envoyer le message",
+1
View File
@@ -62,6 +62,7 @@
"placeholderThread": "Ketik pesan Anda…",
"placeholderHero": "Apa yang sedang Anda pikirkan?",
"placeholderOpening": "Membuka obrolan baru…",
"placeholderStreaming": "Model sedang merespons…",
"inputAria": "Input pesan",
"sendHint": "Enter untuk kirim · Shift+Enter untuk baris baru",
"send": "Kirim pesan",
+1
View File
@@ -62,6 +62,7 @@
"placeholderThread": "メッセージを入力…",
"placeholderHero": "何を考えていますか?",
"placeholderOpening": "新しいチャットを開いています…",
"placeholderStreaming": "モデルが応答しています…",
"inputAria": "メッセージ入力欄",
"sendHint": "Enter で送信 · Shift+Enter で改行",
"send": "メッセージを送信",
+1
View File
@@ -62,6 +62,7 @@
"placeholderThread": "메시지를 입력하세요…",
"placeholderHero": "무슨 생각을 하고 있나요?",
"placeholderOpening": "새 채팅을 여는 중…",
"placeholderStreaming": "모델이 응답 중입니다…",
"inputAria": "메시지 입력",
"sendHint": "Enter로 전송 · Shift+Enter로 줄바꿈",
"send": "메시지 보내기",
+1
View File
@@ -62,6 +62,7 @@
"placeholderThread": "Nhập tin nhắn…",
"placeholderHero": "Bạn đang nghĩ gì?",
"placeholderOpening": "Đang mở cuộc trò chuyện mới…",
"placeholderStreaming": "Mô hình đang trả lời…",
"inputAria": "Ô nhập tin nhắn",
"sendHint": "Enter để gửi · Shift+Enter để xuống dòng",
"send": "Gửi tin nhắn",
+1
View File
@@ -62,6 +62,7 @@
"placeholderThread": "输入消息…",
"placeholderHero": "你在想什么?",
"placeholderOpening": "正在打开新对话…",
"placeholderStreaming": "模型正在回复…",
"inputAria": "消息输入框",
"sendHint": "Enter 发送 · Shift+Enter 换行",
"send": "发送消息",
+1
View File
@@ -62,6 +62,7 @@
"placeholderThread": "輸入訊息…",
"placeholderHero": "你在想什麼?",
"placeholderOpening": "正在開啟新對話…",
"placeholderStreaming": "模型正在回覆…",
"inputAria": "訊息輸入框",
"sendHint": "Enter 送出 · Shift+Enter 換行",
"send": "送出訊息",
+1
View File
@@ -124,6 +124,7 @@ export type InboundEvent =
chat_id: string;
stream_id?: string;
}
| { event: "turn_end"; chat_id: string }
| { event: "error"; chat_id?: string; detail?: string };
/** Base64-encoded image attached to an outbound ``message`` envelope.