diff --git a/packages/ui/src/components/chat/multi-task-chat.tsx b/packages/ui/src/components/chat/multi-task-chat.tsx index 38fa1a6..3d85cd8 100644 --- a/packages/ui/src/components/chat/multi-task-chat.tsx +++ b/packages/ui/src/components/chat/multi-task-chat.tsx @@ -10,6 +10,7 @@ import { addToTaskQueue, getSoloState, setActiveTaskId, toggleAutonomous, toggle import { getLogger } from "@/lib/logger"; import { clearCompactionSuggestion, getCompactionSuggestion } from "@/stores/session-compaction"; import { emitSessionSidebarRequest } from "@/lib/session-sidebar-events"; +import { detectAgentWorkingState, getAgentStatusMessage } from "@/lib/agent-status-detection"; import { Command, Plus, @@ -216,7 +217,36 @@ export default function MultiTaskChat(props: MultiTaskChatProps) { const store = messageStore(); const lastMsg = store.getMessage(ids[ids.length - 1]); - return lastMsg?.role === "assistant" && (lastMsg.status === "streaming" || lastMsg.status === "sending"); + + // Basic check: streaming or sending status + if (lastMsg?.role === "assistant" && (lastMsg.status === "streaming" || lastMsg.status === "sending")) { + return true; + } + + // Enhanced check: semantic detection for "standby", "processing" messages + // This catches Ollama models that output status messages and pause + if (lastMsg?.role === "assistant") { + const workingState = detectAgentWorkingState(lastMsg); + return workingState.isWorking; + } + + return false; + }); + + // Get dynamic status message for display + const agentStatusMessage = createMemo(() => { + const ids = filteredMessageIds(); + if (ids.length === 0) return "THINKING"; + + const store = messageStore(); + const lastMsg = store.getMessage(ids[ids.length - 1]); + + if (!lastMsg || lastMsg.role !== "assistant") { + return "THINKING"; + } + + const statusMsg = getAgentStatusMessage(lastMsg); + return statusMsg?.toUpperCase() || "THINKING"; }); // Auto-scroll during streaming - DISABLED for performance testing @@ -539,7 +569,7 @@ export default function MultiTaskChat(props: MultiTaskChatProps) {
- Streaming + {agentStatusMessage()} {formatTokenTotal(tokenStats().used)}
@@ -846,7 +876,7 @@ export default function MultiTaskChat(props: MultiTaskChatProps) {
- {isAgentThinking() ? "THINKING" : "SENDING"} + {isSending() ? "SENDING" : agentStatusMessage()}
diff --git a/packages/ui/src/lib/agent-status-detection.ts b/packages/ui/src/lib/agent-status-detection.ts new file mode 100644 index 0000000..27e82f9 --- /dev/null +++ b/packages/ui/src/lib/agent-status-detection.ts @@ -0,0 +1,249 @@ +/** + * Agent Status Detection Module + * + * Provides intelligent detection of when an agent is still "working" even after + * streaming has technically completed. This handles cases where: + * 1. Agent outputs "standby", "processing", "working" messages + * 2. Agent is in multi-step reasoning mode + * 3. Ollama models pause between thinking and output phases + */ + +import type { MessageRecord } from "../stores/message-v2/types" + +// Keywords that indicate the agent is still processing +const WORKING_KEYWORDS = [ + "standby", + "stand by", + "processing", + "please wait", + "working on", + "analyzing", + "thinking", + "computing", + "calculating", + "evaluating", + "generating", + "preparing", + "loading", + "fetching", + "retrieving", + "in progress", + "one moment", + "hold on", + "just a sec", + "give me a moment", + "let me", + "i'll", + "i will", + "checking", + "scanning", + "searching", + "looking", + "finding" +] as const + +// Keywords that indicate the agent has finished +const COMPLETION_KEYWORDS = [ + "here is", + "here's", + "here are", + "done", + "complete", + "finished", + "result", + "solution", + "answer", + "output", + "summary", + "conclusion", + "final", + "successfully", + "implemented", + "fixed", + "resolved", + "created", + "updated" +] as const + +// Patterns that strongly indicate agent is still working +const WORKING_PATTERNS = [ + /stand\s*by/i, + /processing\s*(complete\s*)?data/i, + /please\s+wait/i, + /working\s+on/i, + /analyzing/i, + /\bwait\b/i, + /\bone\s+moment\b/i, + /\bhold\s+on\b/i, + /\.\.\.\s*$/, // Ends with ellipsis + /…\s*$/, // Ends with unicode ellipsis +] as const + +/** + * Extracts text content from a message's parts + */ +function extractMessageText(message: MessageRecord): string { + const textParts: string[] = [] + + for (const partId of message.partIds) { + const part = message.parts[partId] + if (part?.data) { + const data = part.data as Record + if (data.type === "text" && typeof data.text === "string") { + textParts.push(data.text) + } + } + } + + return textParts.join("\n") +} + +/** + * Gets the last N characters of a message for keyword detection + */ +function getRecentContent(message: MessageRecord, charLimit = 500): string { + const fullText = extractMessageText(message) + if (fullText.length <= charLimit) { + return fullText.toLowerCase() + } + return fullText.slice(-charLimit).toLowerCase() +} + +/** + * Checks if the message content indicates the agent is still working + */ +export function detectAgentWorkingState(message: MessageRecord | null | undefined): { + isWorking: boolean + reason?: string + confidence: "high" | "medium" | "low" +} { + if (!message) { + return { isWorking: false, confidence: "high" } + } + + // If message status is streaming or sending, definitely working + if (message.status === "streaming" || message.status === "sending") { + return { isWorking: true, reason: "Active streaming", confidence: "high" } + } + + // Get recent content to analyze + const recentContent = getRecentContent(message) + + if (!recentContent) { + return { isWorking: false, confidence: "high" } + } + + // Check for working patterns with high confidence + for (const pattern of WORKING_PATTERNS) { + if (pattern.test(recentContent)) { + return { + isWorking: true, + reason: `Pattern match: ${pattern.source}`, + confidence: "high" + } + } + } + + // Check if recent content ends with working keywords + const lastLine = recentContent.split("\n").pop()?.trim() || "" + + for (const keyword of WORKING_KEYWORDS) { + if (lastLine.includes(keyword)) { + // Check if there's also a completion keyword nearby + const hasCompletionNearby = COMPLETION_KEYWORDS.some(ck => + recentContent.slice(-200).includes(ck) + ) + + if (!hasCompletionNearby) { + return { + isWorking: true, + reason: `Working keyword: "${keyword}"`, + confidence: "medium" + } + } + } + } + + // Check message age - if very recent and short, might still be working + const now = Date.now() + const messageAge = now - message.updatedAt + const contentLength = extractMessageText(message).length + + // If message was updated very recently (< 2s) and content is short + if (messageAge < 2000 && contentLength < 100) { + return { + isWorking: true, + reason: "Recently updated with short content", + confidence: "low" + } + } + + return { isWorking: false, confidence: "high" } +} + +/** + * Check if the last assistant message indicates agent is still conceptually working + */ +export function isAgentConceptuallyThinking( + messages: MessageRecord[], + lastAssistantMessage: MessageRecord | null | undefined +): boolean { + if (!lastAssistantMessage) { + return false + } + + // Check if message status indicates active work + if (lastAssistantMessage.status === "streaming" || + lastAssistantMessage.status === "sending") { + return true + } + + // Use semantic detection + const workingState = detectAgentWorkingState(lastAssistantMessage) + return workingState.isWorking +} + +/** + * Get a user-friendly status message for the current agent state + */ +export function getAgentStatusMessage( + message: MessageRecord | null | undefined +): string | null { + if (!message) { + return null + } + + const workingState = detectAgentWorkingState(message) + + if (!workingState.isWorking) { + return null + } + + if (message.status === "streaming") { + return "Streaming..." + } + + if (message.status === "sending") { + return "Sending..." + } + + // Based on reason + if (workingState.reason?.includes("standby") || + workingState.reason?.includes("stand by")) { + return "Agent processing..." + } + + if (workingState.reason?.includes("processing")) { + return "Processing..." + } + + if (workingState.reason?.includes("analyzing")) { + return "Analyzing..." + } + + if (workingState.reason?.includes("ellipsis")) { + return "Thinking..." + } + + return "Working..." +} diff --git a/packages/ui/src/stores/session-status.ts b/packages/ui/src/stores/session-status.ts index 770779c..108d399 100644 --- a/packages/ui/src/stores/session-status.ts +++ b/packages/ui/src/stores/session-status.ts @@ -4,6 +4,7 @@ import type { MessageRecord } from "./message-v2/types" import { sessions } from "./sessions" import { getSessionCompactionState } from "./session-compaction" import { messageStoreBus } from "./message-v2/bus" +import { detectAgentWorkingState } from "../lib/agent-status-detection" function getSession(instanceId: string, sessionId: string): Session | null { const instanceSessions = sessions().get(instanceId) @@ -159,6 +160,15 @@ export function getSessionStatus(instanceId: string, sessionId: string): Session return "working" } + // Enhanced: Check if last assistant message content suggests agent is still working + // This catches Ollama models that output "standby", "processing" messages and pause + if (lastRecord && lastRecord.role === "assistant") { + const workingState = detectAgentWorkingState(lastRecord) + if (workingState.isWorking && workingState.confidence !== "low") { + return "working" + } + } + return "idle" }