From c388364d2c8e6408c6e2c006408a569bbe62c2ee Mon Sep 17 00:00:00 2001 From: viettranx Date: Wed, 1 Apr 2026 16:12:44 +0700 Subject: [PATCH] fix(ui): fix chat streaming race condition + require agent selection + improve chat UX - Fix race condition where session-change effect cleared runIdRef after run.started already captured it, causing chunk events to be filtered out (user saw "thinking" but no streamed tokens on new chats) - Add SessionRunID to router + return runId in session status response as backup restoration for event filtering - Require explicit agent selection before chat input is shown - Redesign ChatInput: attach icon inside input container, aligned send - Port desktop UX: wobble animation for tool calls, auto-expand thinking block on stream start, amber icon for streaming, iteration step count --- internal/agent/router.go | 9 ++ internal/gateway/methods/chat.go | 5 + .../components/chat/activity-indicator.tsx | 19 ++-- .../components/chat/agent-picker-prompt.tsx | 67 +++++++++++++ ui/web/src/components/chat/agent-selector.tsx | 4 - ui/web/src/components/chat/chat-input.tsx | 97 ++++++++++--------- ui/web/src/components/chat/thinking-block.tsx | 28 +++--- ui/web/src/components/chat/tool-call-card.tsx | 8 +- ui/web/src/i18n/locales/en/chat.json | 4 + ui/web/src/i18n/locales/vi/chat.json | 4 + ui/web/src/i18n/locales/zh/chat.json | 4 + ui/web/src/index.css | 10 ++ ui/web/src/pages/chat/chat-page.tsx | 20 ++-- .../src/pages/chat/hooks/use-chat-messages.ts | 22 +++-- 14 files changed, 214 insertions(+), 87 deletions(-) create mode 100644 ui/web/src/components/chat/agent-picker-prompt.tsx diff --git a/internal/agent/router.go b/internal/agent/router.go index 1ef3a776..7880d678 100644 --- a/internal/agent/router.go +++ b/internal/agent/router.go @@ -307,6 +307,15 @@ func (r *Router) IsSessionBusy(sessionKey string) bool { return ok } +// SessionRunID returns the active run ID for a session, if any. +func (r *Router) SessionRunID(sessionKey string) (string, bool) { + val, ok := r.sessionRuns.Load(sessionKey) + if !ok { + return "", false + } + return val.(string), true +} + // AbortRunsForSession cancels all active runs for a session key. // Returns the list of aborted run IDs. func (r *Router) AbortRunsForSession(sessionKey string) []string { diff --git a/internal/gateway/methods/chat.go b/internal/gateway/methods/chat.go index 42891196..2cb57955 100644 --- a/internal/gateway/methods/chat.go +++ b/internal/gateway/methods/chat.go @@ -60,6 +60,10 @@ func (m *ChatMethods) handleSessionStatus(_ context.Context, client *gateway.Cli } isRunning := m.agents.IsSessionBusy(params.SessionKey) + var runId string + if rid, ok := m.agents.SessionRunID(params.SessionKey); ok { + runId = rid + } var activity map[string]any if status := m.agents.GetActivity(params.SessionKey); status != nil { activity = map[string]any{ @@ -71,6 +75,7 @@ func (m *ChatMethods) handleSessionStatus(_ context.Context, client *gateway.Cli client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{ "isRunning": isRunning, + "runId": runId, "activity": activity, })) } diff --git a/ui/web/src/components/chat/activity-indicator.tsx b/ui/web/src/components/chat/activity-indicator.tsx index 62aabfd9..783aec06 100644 --- a/ui/web/src/components/chat/activity-indicator.tsx +++ b/ui/web/src/components/chat/activity-indicator.tsx @@ -25,8 +25,11 @@ export function ActivityIndicator({ activity, isRunning }: ActivityIndicatorProp return (
- + {config.label} + {activity.phase !== "retrying" && activity.iteration && activity.iteration > 1 && ( + · Step {activity.iteration} + )}
); } @@ -34,26 +37,28 @@ export function ActivityIndicator({ activity, isRunning }: ActivityIndicatorProp function getPhaseConfig(activity: RunActivity) { switch (activity.phase) { case "thinking": - return { icon: Brain, color: "text-orange-500", label: "Thinking..." }; + return { icon: Brain, animation: "animate-pulse", color: "text-amber-500", label: "Thinking..." }; case "tool_exec": return { icon: Wrench, + animation: "animate-wobble", color: "text-blue-500", label: activity.tool ? `Running ${activity.tool}...` : "Running tools...", }; case "streaming": - return { icon: Pencil, color: "text-foreground", label: "Writing..." }; + return { icon: Pencil, animation: "", color: "text-foreground", label: "Writing..." }; case "compacting": - return { icon: Archive, color: "text-amber-500", label: "Optimizing context..." }; + return { icon: Archive, animation: "animate-pulse", color: "text-amber-500", label: "Optimizing context..." }; case "retrying": return { icon: RefreshCw, - color: "text-orange-500", + animation: "animate-spin", + color: "text-amber-500", label: `Retrying (${activity.retryAttempt ?? 0}/${activity.retryMax ?? 0})...`, }; case "leader_processing": - return { icon: Users, color: "text-emerald-500", label: "Processing team results..." }; + return { icon: Users, animation: "animate-pulse", color: "text-emerald-500", label: "Processing team results..." }; default: - return { icon: Brain, color: "text-muted-foreground", label: "Working..." }; + return { icon: Brain, animation: "animate-pulse", color: "text-muted-foreground", label: "Working..." }; } } diff --git a/ui/web/src/components/chat/agent-picker-prompt.tsx b/ui/web/src/components/chat/agent-picker-prompt.tsx new file mode 100644 index 00000000..55f42180 --- /dev/null +++ b/ui/web/src/components/chat/agent-picker-prompt.tsx @@ -0,0 +1,67 @@ +import { useState, useEffect } from "react"; +import { useTranslation } from "react-i18next"; +import { Bot } from "lucide-react"; +import { useHttp } from "@/hooks/use-ws"; +import { useAuthStore } from "@/stores/use-auth-store"; +import type { AgentData } from "@/types/agent"; + +interface AgentPickerPromptProps { + onSelect: (agentId: string) => void; +} + +function agentEmoji(agent: AgentData): string | undefined { + return (agent.other_config?.emoji as string) || undefined; +} + +export function AgentPickerPrompt({ onSelect }: AgentPickerPromptProps) { + const { t } = useTranslation("chat"); + const http = useHttp(); + const connected = useAuthStore((s) => s.connected); + const [agents, setAgents] = useState([]); + + useEffect(() => { + if (!connected) return; + http + .get<{ agents: AgentData[] }>("/v1/agents") + .then((res) => { + setAgents((res.agents ?? []).filter((a) => a.status === "active")); + }) + .catch(() => {}); + }, [http, connected]); + + return ( +
+
+

{t("selectAgent.title")}

+

{t("selectAgent.description")}

+
+ {agents.map((agent) => { + const emoji = agentEmoji(agent); + return ( + + ); + })} +
+
+
+ ); +} diff --git a/ui/web/src/components/chat/agent-selector.tsx b/ui/web/src/components/chat/agent-selector.tsx index 25cb9b78..d6c2a8f7 100644 --- a/ui/web/src/components/chat/agent-selector.tsx +++ b/ui/web/src/components/chat/agent-selector.tsx @@ -33,10 +33,6 @@ export function AgentSelector({ value, onChange }: AgentSelectorProps) { .then((res) => { const active = (res.agents ?? []).filter((a) => a.status === "active"); setAgents(active); - if (active.length > 0 && !active.some((a) => a.agent_key === value)) { - const defaultAgent = active.find((a) => a.is_default) ?? active[0]!; - onChange(defaultAgent.agent_key); - } }) .catch(() => {}); }, [http, connected]); diff --git a/ui/web/src/components/chat/chat-input.tsx b/ui/web/src/components/chat/chat-input.tsx index 9d85463f..29a0b777 100644 --- a/ui/web/src/components/chat/chat-input.tsx +++ b/ui/web/src/components/chat/chat-input.tsx @@ -1,7 +1,6 @@ import { useState, useRef, useCallback, type KeyboardEvent } from "react"; import { useTranslation } from "react-i18next"; import { Send, Square, Paperclip, X } from "lucide-react"; -import { Button } from "@/components/ui/button"; export interface AttachedFile { file: File; @@ -61,7 +60,6 @@ export function ChatInput({ onSend, onAbort, isBusy, disabled, files, onFilesCha if (!selected) return; const newFiles: AttachedFile[] = Array.from(selected).map((f) => ({ file: f })); onFilesChange([...files, ...newFiles]); - // Reset input so the same file can be re-selected e.target.value = ""; }, [files, onFilesChange]); @@ -69,14 +67,16 @@ export function ChatInput({ onSend, onAbort, isBusy, disabled, files, onFilesCha onFilesChange(files.filter((_, i) => i !== index)); }, [files, onFilesChange]); + const hasContent = value.trim().length > 0 || files.length > 0; + return (
{/* Attached files preview */} {files.length > 0 && ( -
+
{files.map((af, i) => ( )} -
- {/* File attach button */} - - + + {/* Textarea — no border, transparent bg */}