mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-03 05:20:01 +00:00
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:
1 parent
066c61f09a
commit
c388364d2c
14 files changed
+214
-87
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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`} />;
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -21,5 +21,9 @@
|
||||
"replyingTo": "回复",
|
||||
"error": {
|
||||
"notConnected": "未连接。请等待连接建立。"
|
||||
},
|
||||
"selectAgent": {
|
||||
"title": "选择Agent",
|
||||
"description": "选择Agent开始对话"
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user