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
This commit is contained in:
viettranx committed 2026-04-01 16:12:44 +07:00
1 parent 066c61f09a
commit c388364d2c
14 files changed
+214 -87

No files matched your search

+9
View File
@@ -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 {
+5
View File
@@ -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,
}))
}
@@ -25,8 +25,11 @@ export function ActivityIndicator({ activity, isRunning }: ActivityIndicatorProp
return (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<config.icon className={`h-4 w-4 animate-pulse ${config.color}`} />
<config.icon className={`h-4 w-4 ${config.animation} ${config.color}`} />
<span className={config.color}>{config.label}</span>
{activity.phase !== "retrying" && activity.iteration && activity.iteration > 1 && (
<span className="text-muted-foreground">· Step {activity.iteration}</span>
)}
</div>
);
}
@@ -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..." };
}
}
@@ -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<AgentData[]>([]);
useEffect(() => {
if (!connected) return;
http
.get<{ agents: AgentData[] }>("/v1/agents")
.then((res) => {
setAgents((res.agents ?? []).filter((a) => a.status === "active"));
})
.catch(() => {});
}, [http, connected]);
return (
<div className="mx-3 mb-3 safe-bottom">
<div className="rounded-xl border bg-background/95 backdrop-blur-sm shadow-sm p-4">
<p className="text-sm font-medium mb-1">{t("selectAgent.title")}</p>
<p className="text-xs text-muted-foreground mb-3">{t("selectAgent.description")}</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 max-h-60 overflow-y-auto">
{agents.map((agent) => {
const emoji = agentEmoji(agent);
return (
<button
key={agent.agent_key}
type="button"
onClick={() => onSelect(agent.agent_key)}
className="flex items-center gap-3 rounded-lg border bg-card px-3 py-2.5 text-left text-sm hover:bg-accent transition-colors cursor-pointer"
>
{emoji ? (
<span className="text-lg shrink-0">{emoji}</span>
) : (
<Bot className="h-5 w-5 shrink-0 text-muted-foreground" />
)}
<div className="min-w-0 flex-1">
<span className="font-medium truncate block">
{agent.display_name || agent.agent_key}
</span>
{agent.is_default && (
<span className="text-xs text-muted-foreground">Default</span>
)}
</div>
</button>
);
})}
</div>
</div>
</div>
);
}
@@ -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]);
+52 -45
View File
@@ -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 (
<div
className="mx-3 mb-3 rounded-xl border bg-background/95 backdrop-blur-sm shadow-sm safe-bottom"
className="mx-3 mb-3 safe-bottom"
style={{ paddingBottom: `calc(env(safe-area-inset-bottom) + var(--keyboard-height, 0px))` }}
>
{/* Attached files preview */}
{files.length > 0 && (
<div className="flex flex-wrap gap-1.5 px-4 pt-3">
<div className="flex flex-wrap gap-1.5 mb-2">
{files.map((af, i) => (
<span
key={i}
@@ -95,27 +95,28 @@ export function ChatInput({ onSend, onAbort, isBusy, disabled, files, onFilesCha
</div>
)}
<div className="flex items-end gap-2 p-4 pt-3">
{/* File attach button */}
<Button
<input
ref={fileInputRef}
type="file"
multiple
onChange={handleFileChange}
className="hidden"
/>
{/* Input container — attach + textarea + send/stop inside one rounded box */}
<div className="flex items-end rounded-xl border bg-background/95 backdrop-blur-sm shadow-sm transition-colors focus-within:ring-1 focus-within:ring-ring">
{/* Attach button inside input */}
<button
type="button"
variant="ghost"
size="icon-lg"
onClick={handleFileSelect}
disabled={disabled || isBusy}
title={t("attachFile")}
className="text-muted-foreground hover:text-foreground"
className="shrink-0 p-3 text-muted-foreground hover:text-foreground transition-colors disabled:opacity-40 cursor-pointer"
>
<Paperclip className="h-4 w-4" />
</Button>
<input
ref={fileInputRef}
type="file"
multiple
onChange={handleFileChange}
className="hidden"
/>
</button>
{/* Textarea — no border, transparent bg */}
<textarea
ref={textareaRef}
value={value}
@@ -125,37 +126,43 @@ export function ChatInput({ onSend, onAbort, isBusy, disabled, files, onFilesCha
placeholder={t("sendMessage")}
disabled={disabled}
rows={1}
className="flex-1 resize-none rounded-lg border bg-background px-4 py-2.5 text-base md:text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:opacity-50"
className="flex-1 resize-none bg-transparent py-3 px-0 text-base md:text-sm placeholder:text-muted-foreground focus:outline-none disabled:opacity-50"
/>
{isBusy ? (
<div className="flex gap-1">
<Button
size="icon-lg"
{/* Send / Stop buttons */}
<div className="shrink-0 p-2 flex items-center gap-1">
{isBusy ? (
<>
<button
type="button"
onClick={handleSend}
disabled={!value.trim() || disabled}
title={t("sendFollowUp")}
className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary text-primary-foreground hover:bg-primary/90 transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
>
<Send className="h-4 w-4" />
</button>
<button
type="button"
onClick={onAbort}
title={t("stopGeneration")}
className="flex h-8 w-8 items-center justify-center rounded-lg bg-destructive text-destructive-foreground hover:bg-destructive/90 transition-colors"
>
<Square className="h-3.5 w-3.5" />
</button>
</>
) : (
<button
type="button"
onClick={handleSend}
disabled={!value.trim() || disabled}
title={t("sendFollowUp")}
disabled={!hasContent || disabled}
title={t("sendMessageTitle")}
className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary text-primary-foreground hover:bg-primary/90 transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
>
<Send className="h-4 w-4" />
</Button>
<Button
variant="destructive"
size="icon-lg"
onClick={onAbort}
title={t("stopGeneration")}
>
<Square className="h-4 w-4" />
</Button>
</div>
) : (
<Button
size="icon-lg"
onClick={handleSend}
disabled={(!value.trim() && files.length === 0) || disabled}
title={t("sendMessageTitle")}
>
<Send className="h-4 w-4" />
</Button>
)}
</button>
)}
</div>
</div>
</div>
);
+17 -11
View File
@@ -1,6 +1,6 @@
import { useState } from "react";
import { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { ChevronDown, ChevronRight, Brain } from "lucide-react";
import { ChevronRight, Brain } from "lucide-react";
interface ThinkingBlockProps {
text: string;
@@ -9,31 +9,37 @@ interface ThinkingBlockProps {
export function ThinkingBlock({ text, isStreaming = false }: ThinkingBlockProps) {
const { t } = useTranslation("common");
const [expanded, setExpanded] = useState(isStreaming);
const [expanded, setExpanded] = useState(false);
// Auto-expand when streaming starts, keep user's choice when done
useEffect(() => {
if (isStreaming) setExpanded(true);
}, [isStreaming]);
return (
<div className="rounded-lg border border-muted bg-muted text-sm">
<div className="rounded-lg border border-muted bg-muted/50 text-sm overflow-hidden">
<button
type="button"
className="flex w-full items-center gap-2 px-3 py-2 text-muted-foreground hover:text-foreground transition-colors"
onClick={() => setExpanded((v) => !v)}
>
<Brain className="h-3.5 w-3.5 shrink-0" />
<Brain className={`h-3.5 w-3.5 shrink-0 ${isStreaming ? "text-amber-500" : ""}`} />
<span className="text-xs font-medium">
{isStreaming ? t("thinkingStreaming") : t("thinking")}
</span>
{expanded ? (
<ChevronDown className="ml-auto h-3.5 w-3.5" />
) : (
<ChevronRight className="ml-auto h-3.5 w-3.5" />
{isStreaming && (
<span className="inline-block w-1.5 h-3.5 bg-muted-foreground/50 animate-pulse rounded-sm" />
)}
<ChevronRight
className={`ml-auto h-3 w-3 transition-transform ${expanded ? "rotate-90" : ""}`}
/>
</button>
{expanded && (
<div className="border-t border-muted px-3 py-2">
<pre className="whitespace-pre-wrap text-xs text-muted-foreground font-mono leading-relaxed max-h-80 overflow-y-auto">
<pre className="whitespace-pre-wrap text-xs text-muted-foreground font-mono leading-relaxed max-h-80 overflow-y-auto break-words">
{text}
{isStreaming && (
<span className="inline-block w-1.5 h-3.5 bg-muted-foreground/50 animate-pulse ml-0.5 align-text-bottom" />
<span className="inline-block w-1.5 h-3.5 bg-muted-foreground/50 animate-pulse ml-0.5 align-text-bottom rounded-sm" />
)}
</pre>
</div>
@@ -1,6 +1,6 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Wrench, AlertTriangle, Loader2, ChevronDown, ChevronRight, Zap } from "lucide-react";
import { Wrench, AlertTriangle, ChevronRight, Zap } from "lucide-react";
import type { ToolStreamEntry } from "@/types/chat";
const isSkillTool = (name: string) => name === "use_skill";
@@ -44,9 +44,7 @@ export function ToolCallCard({ entry, compact }: ToolCallCardProps) {
<span className="ml-auto flex items-center gap-1 shrink-0">
<PhaseLabel phase={entry.phase} isSkill={skill} />
{canExpand && (
expanded
? <ChevronDown className="h-3 w-3 text-muted-foreground" />
: <ChevronRight className="h-3 w-3 text-muted-foreground" />
<ChevronRight className={`h-3 w-3 text-muted-foreground transition-transform ${expanded ? "rotate-90" : ""}`} />
)}
</span>
</button>
@@ -88,7 +86,7 @@ function ToolIcon({ phase, isSkill }: { phase: ToolStreamEntry["phase"]; isSkill
}
}
switch (phase) {
case "calling": return <Loader2 className={`${cls} animate-spin text-blue-500`} />;
case "calling": return <Wrench className={`${cls} animate-wobble text-blue-500`} />;
case "completed": return <Wrench className={`${cls} text-blue-500`} />;
case "error": return <AlertTriangle className={`${cls} text-red-500`} />;
default: return <Wrench className={`${cls} text-muted-foreground`} />;
+4
View File
@@ -21,5 +21,9 @@
"replyingTo": "Replying to",
"error": {
"notConnected": "Not connected. Please wait for the connection to be established."
},
"selectAgent": {
"title": "Select an agent",
"description": "Choose an agent to start chatting"
}
}
+4
View File
@@ -21,5 +21,9 @@
"replyingTo": "Đang trả lời",
"error": {
"notConnected": "Chưa kết nối. Vui lòng chờ kết nối được thiết lập."
},
"selectAgent": {
"title": "Chọn agent",
"description": "Chọn agent để bắt đầu trò chuyện"
}
}
+4
View File
@@ -21,5 +21,9 @@
"replyingTo": "回复",
"error": {
"notConnected": "未连接。请等待连接建立。"
},
"selectAgent": {
"title": "选择Agent",
"description": "选择Agent开始对话"
}
}
+10
View File
@@ -173,6 +173,16 @@
.landscape-compact { padding-block: 0.25rem; }
}
/* Wobble animation for active tool calls — gentler than spin */
@keyframes wobble {
0%, 100% { transform: rotate(0deg); }
25% { transform: rotate(-12deg); }
75% { transform: rotate(12deg); }
}
.animate-wobble {
animation: wobble 1s ease-in-out infinite;
}
/* highlight.js: transparent bg, container handles it */
.hljs { background: transparent !important; }
+13 -7
View File
@@ -10,6 +10,7 @@ import { ChatThread } from "./chat-thread";
import { ChatInput, type AttachedFile } from "@/components/chat/chat-input";
import { ChatTopBar } from "@/components/chat/chat-top-bar";
import { DropZone } from "@/components/chat/drop-zone";
import { AgentPickerPrompt } from "@/components/chat/agent-picker-prompt";
import { useChatSessions } from "./hooks/use-chat-sessions";
import { useChatMessages } from "./hooks/use-chat-messages";
import { useChatSend } from "./hooks/use-chat-send";
@@ -31,7 +32,10 @@ export function ChatPage() {
const sessionKey = urlSessionKey ?? "";
// Fallback agent ID used only when URL has no session key
const [agentIdFallback, setAgentIdFallback] = useState("default");
const [agentIdFallback, setAgentIdFallback] = useState("");
// Agent is confirmed when URL has a session (agentId parsed) or user explicitly picked one
const agentConfirmed = !!urlSessionKey || !!agentIdFallback;
// Derive agentId from URL (source of truth), fallback to state when no session
const agentId = useMemo(() => {
@@ -255,7 +259,14 @@ export function ChatPage() {
onToggleTaskPanel={() => setTaskPanelOpen((v) => !v)}
/>
{isOwn ? (
{!isOwn ? (
<div className="mx-3 mb-3 flex items-center gap-2 rounded-xl border bg-muted/50 px-4 py-3 text-sm text-muted-foreground shadow-sm">
<Eye className="h-4 w-4" />
{t("readOnly")}
</div>
) : !agentConfirmed ? (
<AgentPickerPrompt onSelect={handleAgentChange} />
) : (
<ChatInput
onSend={handleSend}
onAbort={handleAbort}
@@ -264,11 +275,6 @@ export function ChatPage() {
files={files}
onFilesChange={setFiles}
/>
) : (
<div className="mx-3 mb-3 flex items-center gap-2 rounded-xl border bg-muted/50 px-4 py-3 text-sm text-muted-foreground shadow-sm">
<Eye className="h-4 w-4" />
{t("readOnly")}
</div>
)}
</DropZone>
</div>
@@ -49,6 +49,13 @@ export function useChatMessages(sessionKey: string, agentId: string) {
if (sessionKey === prevKeyRef.current) return;
const wasEmpty = !prevKeyRef.current;
prevKeyRef.current = sessionKey;
// "" → newKey: part of new-chat send flow. run.started may have already
// captured runIdRef before this effect fires. Don't reset run state —
// there's no prior session state to clean up from an empty session.
if (wasEmpty) return;
// Session-to-session switch: full reset
setStreamText(null);
setThinkingText(null);
setToolStream([]);
@@ -57,11 +64,7 @@ export function useChatMessages(sessionKey: string, agentId: string) {
setBlockReplies([]);
setTeamTasks([]);
runIdRef.current = null;
// Only reset when switching between existing sessions, not on "" → new key
// (the "" → key transition is part of the send flow for new sessions).
if (!wasEmpty) {
expectingRunRef.current = false;
}
expectingRunRef.current = false;
streamRef.current = "";
thinkingRef.current = "";
toolStreamRef.current = [];
@@ -150,11 +153,14 @@ export function useChatMessages(sessionKey: string, agentId: string) {
let cancelled = false;
if (sessionKey) {
loadHistory();
// Restore session running state
ws.call<{ isRunning?: boolean; activity?: RunActivity }>(Methods.CHAT_SESSION_STATUS, { sessionKey })
// Restore session running state (including runId for event filtering)
ws.call<{ isRunning?: boolean; runId?: string; activity?: RunActivity }>(Methods.CHAT_SESSION_STATUS, { sessionKey })
.then((res) => {
if (cancelled) return;
if (res.isRunning) setIsRunning(true);
if (res.isRunning) {
setIsRunning(true);
if (res.runId) runIdRef.current = res.runId;
}
if (res.activity) {
setActivity(res.activity);
activityRef.current = res.activity;