diff --git a/packages/core/chat/store.ts b/packages/core/chat/store.ts index 22dde4116..e3a260440 100644 --- a/packages/core/chat/store.ts +++ b/packages/core/chat/store.ts @@ -16,6 +16,14 @@ const CHAT_HEIGHT_KEY = "multica:chat:height"; const CHAT_EXPANDED_KEY = "multica:chat:expanded"; /** Focus mode is a personal preference — global across workspaces/sessions. */ const FOCUS_MODE_KEY = "multica:chat:focusMode"; +/** + * Open/closed preference, persisted globally (not per-workspace) — most users + * have one habitual chat-panel preference across workspaces. Missing key = + * new user (or cleared storage); default to OPEN so the chat is discoverable. + * Once the user toggles even once, their explicit choice is respected on + * every subsequent reload. + */ +const OPEN_KEY = "multica:chat:isOpen"; function readDrafts(storage: StorageAdapter, key: string): Record { const raw = storage.getItem(key); @@ -118,8 +126,14 @@ export function createChatStore(options: ChatStoreOptions) { return slug ? `${base}:${slug}` : base; }; + // Resolve initial isOpen from storage. The three-state read (null / + // "true" / "false") is what enables the "new user → open" default while + // still honouring an explicit "I closed it" choice on every reload. + const storedOpen = storage.getItem(OPEN_KEY); + const initialIsOpen = storedOpen === null ? true : storedOpen === "true"; + const store = create((set, get) => ({ - isOpen: false, + isOpen: initialIsOpen, activeSessionId: storage.getItem(wsKey(SESSION_STORAGE_KEY)), selectedAgentId: storage.getItem(wsKey(AGENT_STORAGE_KEY)), showHistory: false, @@ -130,11 +144,13 @@ export function createChatStore(options: ChatStoreOptions) { isExpanded: storage.getItem(wsKey(CHAT_EXPANDED_KEY)) === "true", setOpen: (open) => { logger.debug("setOpen", { from: get().isOpen, to: open }); + storage.setItem(OPEN_KEY, String(open)); set({ isOpen: open }); }, toggle: () => { const next = !get().isOpen; logger.debug("toggle", { to: next }); + storage.setItem(OPEN_KEY, String(next)); set({ isOpen: next }); }, setActiveSession: (id) => { diff --git a/packages/core/realtime/use-realtime-sync.ts b/packages/core/realtime/use-realtime-sync.ts index 34f19f95b..6735cc143 100644 --- a/packages/core/realtime/use-realtime-sync.ts +++ b/packages/core/realtime/use-realtime-sync.ts @@ -51,9 +51,13 @@ import type { SubscriberAddedPayload, SubscriberRemovedPayload, TaskMessagePayload, + TaskQueuedPayload, + TaskDispatchPayload, TaskCompletedPayload, TaskFailedPayload, + TaskCancelledPayload, ChatDonePayload, + ChatPendingTask, InvitationCreatedPayload, } from "../types"; @@ -525,6 +529,64 @@ export function useRealtimeSync( invalidateSessionLists(); }); + // Chat task lifecycle writethrough: keep `chatKeys.pendingTask(sessionId)` + // synchronized with the server state machine via setQueryData rather than + // invalidate-refetch. Same pattern as task:message — the WS payload + // carries everything we need, and an HTTP roundtrip just to read what we + // already know would add latency to every stage transition. + // + // task:queued is emitted by EnqueueChatTask. The optimistic seed in + // chat-window.tsx may have already populated the cache with a temporary + // id; this handler upgrades it to the real task_id (and reaffirms status + // when reconnect replays the event for an already-running task). + const unsubTaskQueued = ws.on("task:queued", (p) => { + const payload = p as TaskQueuedPayload; + if (!payload.chat_session_id) return; + qc.setQueryData( + chatKeys.pendingTask(payload.chat_session_id), + (old) => ({ + ...(old ?? {}), + task_id: payload.task_id, + status: "queued", + }), + ); + invalidatePendingAggregate(); + }); + + // task:dispatch fires when the daemon claims the queued task. The daemon + // immediately follows with StartTask, so dispatched→running is sub-second. + // We collapse that window by writing "running" directly — the pill jumps + // from "Queued" straight to "Thinking", skipping a meaningless "Starting" + // frame. Stage decision in TaskStatusPill maps "running" + empty + // taskMessages → "Thinking · Ns". + const unsubTaskDispatch = ws.on("task:dispatch", (p) => { + const payload = p as TaskDispatchPayload; + if (!payload.chat_session_id) return; + qc.setQueryData( + chatKeys.pendingTask(payload.chat_session_id), + (old) => { + if (!old || old.task_id !== payload.task_id) return old; + return { ...old, status: "running" }; + }, + ); + }); + + // task:cancelled reaches us when: + // 1. handleStop already cleared the cache locally (this is a no-op confirm) + // 2. another tab / admin / system cancels — this is the only path that + // drops the pending pill in those cases. Without it the pill spins + // forever in the second-tab scenario. + const unsubTaskCancelled = ws.on("task:cancelled", (p) => { + const payload = p as TaskCancelledPayload; + if (!payload.chat_session_id) return; + chatWsLogger.info("task:cancelled (global, chat)", { + task_id: payload.task_id, + chat_session_id: payload.chat_session_id, + }); + qc.setQueryData(chatKeys.pendingTask(payload.chat_session_id), {}); + invalidatePendingAggregate(); + }); + const unsubTaskCompleted = ws.on("task:completed", (p) => { const payload = p as TaskCompletedPayload; if (!payload.chat_session_id) return; // issue tasks handled elsewhere @@ -545,8 +607,14 @@ export function useRealtimeSync( task_id: payload.task_id, chat_session_id: payload.chat_session_id, }); - // No new message; just flip the pending signal. + // FailTask writes a failure chat_message (mirroring CompleteTask's + // success message), so this path mirrors the task:completed handler: + // clear the pending signal AND invalidate the messages list so the + // failure bubble shows up without requiring a page refresh. Pre-#1823 + // this branch only flipped pending — the comment "No new message" + // was true then, but FailTask now persists a row. qc.setQueryData(chatKeys.pendingTask(payload.chat_session_id), {}); + qc.invalidateQueries({ queryKey: chatKeys.messages(payload.chat_session_id) }); qc.invalidateQueries({ queryKey: chatKeys.pendingTask(payload.chat_session_id) }); invalidatePendingAggregate(); }); @@ -584,6 +652,9 @@ export function useRealtimeSync( unsubTaskMessage(); unsubChatMessage(); unsubChatDone(); + unsubTaskQueued(); + unsubTaskDispatch(); + unsubTaskCancelled(); unsubTaskCompleted(); unsubTaskFailed(); unsubChatSessionRead(); diff --git a/packages/core/types/chat.ts b/packages/core/types/chat.ts index 528191d77..bda3530eb 100644 --- a/packages/core/types/chat.ts +++ b/packages/core/types/chat.ts @@ -28,18 +28,48 @@ export interface ChatMessage { content: string; task_id: string | null; created_at: string; + /** + * When set, this is an assistant message synthesized by the server's + * FailTask fallback (mirrors the issue path's failure system comment). + * `content` carries the raw daemon-reported errMsg; the front-end maps + * `failure_reason` (an enum like "agent_error" / "connection_error" / + * "timeout") to a user-facing label and renders a destructive bubble. + * Null on success messages and on user messages. + */ + failure_reason?: string | null; + /** + * Wall-clock duration from `task.created_at` (user hit send) to terminal + * state (completed/failed). Set by the server on assistant messages + * synthesized by CompleteTask/FailTask. UI renders it as "Replied in + * 38s" / "Failed after 12s" beneath the bubble. Null on user messages + * and on legacy assistant messages predating migration 063. + */ + elapsed_ms?: number | null; } export interface SendChatMessageResponse { message_id: string; task_id: string; + /** + * Server-authoritative task creation time. Optimistic StatusPill seed + * uses this as its anchor so the timer starts from the real `0s` — + * without it the front-end falls back to its local clock and the + * timer "snaps backwards" later when WS events update the cache. + */ + created_at: string; } /** * Response from GET /api/chat/sessions/{id}/pending-task. - * Both fields are absent when the session has no in-flight task. + * All fields are absent when the session has no in-flight task. + * + * `created_at` is the server-authoritative anchor for the chat StatusPill's + * elapsed-seconds timer — the optimistic seed in chat-window.tsx fills in + * task_id/status only, then this query catches up with the real created_at + * so the timer survives refresh / reopen without "resetting to 0s". */ export interface ChatPendingTask { task_id?: string; status?: string; + created_at?: string; } diff --git a/packages/core/types/events.ts b/packages/core/types/events.ts index ebb117a2c..93ed6664c 100644 --- a/packages/core/types/events.ts +++ b/packages/core/types/events.ts @@ -196,6 +196,22 @@ export interface TaskMessagePayload { output?: string; } +export interface TaskQueuedPayload { + task_id: string; + agent_id: string; + issue_id: string; + chat_session_id?: string; + status: string; +} + +export interface TaskDispatchPayload { + task_id: string; + agent_id: string; + issue_id: string; + runtime_id: string; + chat_session_id?: string; +} + export interface TaskCompletedPayload { task_id: string; agent_id: string; diff --git a/packages/ui/components/common/unicode-spinner.tsx b/packages/ui/components/common/unicode-spinner.tsx new file mode 100644 index 000000000..0de03075d --- /dev/null +++ b/packages/ui/components/common/unicode-spinner.tsx @@ -0,0 +1,47 @@ +"use client"; + +import { useEffect, useState } from "react"; +import spinners, { type BrailleSpinnerName } from "unicode-animations"; + +interface Props { + name?: BrailleSpinnerName; + className?: string; + /** Stop advancing frames without unmounting (e.g., when an outer state freezes). */ + paused?: boolean; +} + +// Inline-rendered braille spinner. Each frame is a unicode string from the +// `unicode-animations` package; we tick frames on the spinner's own `interval` +// and render the current one inside a fixed-width monospace span so different +// frames never reflow neighbouring text. Width-jitter is the main reason this +// component exists rather than dropping the raw strings into Tailwind classes. +export function UnicodeSpinner({ name = "braille", className, paused }: Props) { + const spec = spinners[name]; + const [frame, setFrame] = useState(0); + + useEffect(() => { + if (paused) return; + setFrame(0); + const timer = setInterval( + () => setFrame((f) => (f + 1) % spec.frames.length), + spec.interval, + ); + return () => clearInterval(timer); + }, [name, paused, spec]); + + return ( + + ); +} diff --git a/packages/ui/package.json b/packages/ui/package.json index 85e985e35..a04088d90 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -48,6 +48,7 @@ "sonner": "^2.0.7", "tailwind-merge": "catalog:", "tw-animate-css": "^1.4.0", + "unicode-animations": "catalog:", "vaul": "^1.1.2" }, "peerDependencies": { diff --git a/packages/ui/styles/base.css b/packages/ui/styles/base.css index e0ce8612e..eb9b54ee8 100644 --- a/packages/ui/styles/base.css +++ b/packages/ui/styles/base.css @@ -83,6 +83,37 @@ animation: chat-impulse 1.6s ease-in-out infinite; } +/* ChatGPT-style "thinking" shimmer for inline text — a soft light sweep + * runs across the glyphs, signalling "the agent is doing something" without + * a separate spinner. Pure CSS: linear-gradient clipped to the text shape, + * the gradient slid across via background-position. Uses the same muted → + * foreground tokens chat copy normally uses, so the effect adapts to light + * and dark mode without per-mode overrides. + * + * Apply to a wrapping the label only — not the whole pill, since + * the timer counter and Cancel button shouldn't shimmer. */ +@keyframes chat-text-shimmer { + 0% { background-position: 200% 0; } + 100% { background-position: -200% 0; } +} + +.animate-chat-text-shimmer { + background-image: linear-gradient( + 90deg, + var(--muted-foreground) 0%, + var(--muted-foreground) 35%, + var(--foreground) 50%, + var(--muted-foreground) 65%, + var(--muted-foreground) 100% + ); + background-size: 200% 100%; + background-clip: text; + -webkit-background-clip: text; + color: transparent; + -webkit-text-fill-color: transparent; + animation: chat-text-shimmer 2.5s linear infinite; +} + /* Sidebar: open triggers (dropdown/popover) get active background */ [data-sidebar="menu-button"][data-popup-open] { background-color: var(--sidebar-accent); diff --git a/packages/views/chat/components/chat-input.tsx b/packages/views/chat/components/chat-input.tsx index d5f9e5c0c..429ba465c 100644 --- a/packages/views/chat/components/chat-input.tsx +++ b/packages/views/chat/components/chat-input.tsx @@ -69,6 +69,14 @@ export function ChatInput({ logger.info("input.send", { contentLength: content.length, draftKey: keyAtSend }); onSend(content); editorRef.current?.clearContent(); + // Drop focus so the caret doesn't keep blinking under the StatusPill / + // streaming reply that's about to take over the user's attention. The + // input is also `disabled` once isRunning flips, and a focused-but- + // disabled editor reads as a stale cursor. We deliberately don't auto- + // refocus on completion — that would interrupt the user if they're + // selecting text from the assistant reply; one click to refocus is + // a fair price for not stealing focus mid-action. + editorRef.current?.blur(); clearInputDraft(keyAtSend); setIsEmpty(true); }; diff --git a/packages/views/chat/components/chat-message-list.tsx b/packages/views/chat/components/chat-message-list.tsx index 5652b1972..addec4824 100644 --- a/packages/views/chat/components/chat-message-list.tsx +++ b/packages/views/chat/components/chat-message-list.tsx @@ -9,36 +9,49 @@ import { CollapsibleContent, CollapsibleTrigger, } from "@multica/ui/components/ui/collapsible"; -import { Loader2, ChevronRight, ChevronDown, Brain, AlertCircle } from "lucide-react"; +import { ChevronRight, ChevronDown, Brain, AlertCircle, AlertTriangle } from "lucide-react"; import { useScrollFade } from "@multica/ui/hooks/use-scroll-fade"; import { useAutoScroll } from "@multica/ui/hooks/use-auto-scroll"; import { taskMessagesOptions } from "@multica/core/chat/queries"; import { Markdown } from "@multica/views/common/markdown"; -import type { ChatMessage, TaskMessagePayload } from "@multica/core/types"; +import type { AgentAvailability } from "@multica/core/agents"; +import type { ChatMessage, ChatPendingTask, TaskMessagePayload, TaskFailureReason } from "@multica/core/types"; import type { ChatTimelineItem } from "@multica/core/chat"; +import { failureReasonLabel } from "../../agents/components/tabs/task-failure"; +import { TaskStatusPill } from "./task-status-pill"; +import { formatElapsedMs } from "../lib/format"; // ─── Public component ──────────────────────────────────────────────────── interface ChatMessageListProps { messages: ChatMessage[]; - /** When set, streams the live timeline for this task from task-messages cache. */ - pendingTaskId: string | null; - isWaiting: boolean; + /** + * Server-authoritative pending-task snapshot. `null` / undefined means + * no in-flight task — list renders without StatusPill. + */ + pendingTask: ChatPendingTask | null | undefined; + /** Resolved presence; pass `undefined` while loading to keep the pill copy neutral. */ + availability: AgentAvailability | undefined; + /** Cancel handler exposed by the StatusPill once the task crosses the long-run threshold. */ + onCancel?: () => void; } export function ChatMessageList({ messages, - pendingTaskId, - isWaiting, + pendingTask, + availability, + onCancel, }: ChatMessageListProps) { const scrollRef = useRef(null); const fadeStyle = useScrollFade(scrollRef); useAutoScroll(scrollRef); + const pendingTaskId = pendingTask?.task_id ?? null; + // Once the assistant message for this pending task has landed in the // messages list, AssistantMessage owns its rendering — suppress the live - // timeline to avoid rendering the same content in two places during the - // invalidate → refetch window. + // timeline (and pill) to avoid rendering the same content in two places + // during the invalidate → refetch window. const pendingAlreadyPersisted = !!pendingTaskId && messages.some( (m) => m.role === "assistant" && m.task_id === pendingTaskId, ); @@ -52,6 +65,7 @@ export function ChatMessageList({ }); const liveTimeline: ChatTimelineItem[] = (liveTaskMessages ?? []).map(toTimelineItem); const hasLive = showLiveTimeline && liveTimeline.length > 0; + const showStatusPill = !!pendingTaskId && !pendingAlreadyPersisted && !!pendingTask; return (
@@ -68,8 +82,13 @@ export function ChatMessageList({
)} - {isWaiting && !hasLive && !pendingAlreadyPersisted && ( - + {showStatusPill && pendingTask && ( + )} @@ -153,6 +172,21 @@ function AssistantMessage({ const timeline: ChatTimelineItem[] = (taskMessages ?? []).map(toTimelineItem); + // Failure bubble path: when the server's FailTask wrote a failure + // chat_message (failure_reason set), render a destructive bubble with the + // human-readable reason label + collapsible raw errMsg + the same timeline + // so the user can see exactly where the run broke. + if (message.failure_reason) { + return ( + + ); + } + return (
{timeline.length > 0 ? ( @@ -162,6 +196,86 @@ function AssistantMessage({ {message.content}
)} + {message.elapsed_ms != null && ( + + )} + + ); +} + +// Persisted "Replied in 38s" / "Failed after 12s" line under the assistant +// bubble. Reads `elapsed_ms` straight off the chat_message — server computes +// it once at task completion, so this caption is identical across reloads +// and devices. Skipped silently when null (legacy messages predating +// migration 063 + user messages). +function ElapsedCaption({ + verb, + elapsedMs, + className, +}: { + verb: string; + elapsedMs: number; + className?: string; +}) { + return ( +
+ {verb} {formatElapsedMs(elapsedMs)} +
+ ); +} + +function FailureBubble({ + reason, + rawError, + timeline, + elapsedMs, +}: { + reason: string; + rawError: string; + timeline: ChatTimelineItem[]; + elapsedMs?: number | null; +}) { + const [open, setOpen] = useState(false); + // Map the back-end enum to copy via the shared label table; an unknown + // reason (e.g. a future enum value the front-end doesn't ship yet) + // falls back to a generic "Task failed" so we never render a bare slug. + const label = + failureReasonLabel[reason as TaskFailureReason] ?? "Task failed"; + + return ( +
+ {/* Failure read as an inline, low-key note — not a destructive + * alert. Intentionally borderless / no background tint: a chat + * failure is informational ("this didn't work"), not a system + * error. The icon + muted destructive text are signal enough, + * the rest stays in the normal reply rhythm. */} +
+ +
+
{label}
+ {rawError.trim() && ( + + + {open ? ( + + ) : ( + + )} + Show details + + +
+                  {rawError}
+                
+
+
+ )} +
+
+ {timeline.length > 0 && } + {elapsedMs != null && ( + + )}
); } diff --git a/packages/views/chat/components/chat-window.tsx b/packages/views/chat/components/chat-window.tsx index 81ba095a0..c41e9b362 100644 --- a/packages/views/chat/components/chat-window.tsx +++ b/packages/views/chat/components/chat-window.tsx @@ -2,8 +2,7 @@ import React, { useCallback, useEffect, useMemo, useRef } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { Minus, Maximize2, Minimize2, ChevronDown, Bot, Plus, Check } from "lucide-react"; -import { Avatar, AvatarFallback, AvatarImage } from "@multica/ui/components/ui/avatar"; +import { Minus, Maximize2, Minimize2, ChevronDown, Plus, Check } from "lucide-react"; import { Button } from "@multica/ui/components/ui/button"; import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/ui/tooltip"; import { @@ -20,11 +19,15 @@ import { useAuthStore } from "@multica/core/auth"; import { agentListOptions, memberListOptions } from "@multica/core/workspace/queries"; import { canAssignAgent } from "@multica/views/issues/components"; import { api } from "@multica/core/api"; +import { useAgentPresenceDetail } from "@multica/core/agents"; +import { ActorAvatar } from "../../common/actor-avatar"; +import { OfflineBanner } from "./offline-banner"; import { chatSessionsOptions, allChatSessionsOptions, chatMessagesOptions, pendingChatTaskOptions, + pendingChatTasksOptions, chatKeys, } from "@multica/core/chat/queries"; import { useCreateChatSession, useMarkChatSessionRead } from "@multica/core/chat/mutations"; @@ -40,7 +43,7 @@ import { import { ChatResizeHandles } from "./chat-resize-handles"; import { useChatResize } from "./use-chat-resize"; import { createLogger } from "@multica/core/logger"; -import type { Agent, ChatMessage, ChatSession } from "@multica/core/types"; +import type { Agent, ChatMessage, ChatPendingTask, ChatSession } from "@multica/core/types"; const uiLogger = createLogger("chat.ui"); const apiLogger = createLogger("chat.api"); @@ -100,6 +103,15 @@ export function ChatWindow() { availableAgents[0] ?? null; + // Presence drives both the avatar status dot (via ActorAvatar) and the + // OfflineBanner / TaskStatusPill availability copy. `useAgentPresenceDetail` + // returns "loading" while queries are still resolving — pass `undefined` + // downstream so banners and pill copy stay silent during loading rather + // than flash speculative offline text. + const presenceDetail = useAgentPresenceDetail(wsId, activeAgent?.id); + const availability = + presenceDetail === "loading" ? undefined : presenceDetail.availability; + // Mount / unmount logging. ChatWindow lives in DashboardLayout, so this // fires on layout mount (login / workspace switch / fresh page load). useEffect(() => { @@ -119,28 +131,11 @@ export function ChatWindow() { // eslint-disable-next-line react-hooks/exhaustive-deps -- once per mount }, []); - // Auto-restore most recent active session from server (only once on mount) - const didRestoreRef = useRef(false); - useEffect(() => { - if (didRestoreRef.current) return; - didRestoreRef.current = true; - if (activeSessionId || sessions.length === 0) { - uiLogger.debug("restore session skipped", { - reason: activeSessionId ? "already has session" : "no sessions", - activeSessionId, - sessionCount: sessions.length, - }); - return; - } - const latest = sessions.find((s) => s.status === "active"); - if (latest) { - uiLogger.info("restore session on mount", { sessionId: latest.id }); - setActiveSession(latest.id); - } else { - uiLogger.debug("restore session: no active session found"); - } - // eslint-disable-next-line react-hooks/exhaustive-deps -- run once when sessions load - }, [sessions]); + // Open intent is fully driven by `activeSessionId` in storage — no mount + // restore, no self-heal. Adding either reintroduces a "two signals + // describing one fact" race (the previous self-heal mis-cleared the + // freshly-created session because allSessions was still stale during the + // post-create invalidate-refetch window). // WS events are handled globally in useRealtimeSync — the query cache // stays current even when this window is closed. See packages/core/realtime/. @@ -197,19 +192,34 @@ export function ChatWindow() { setActiveSession(sessionId); } - // Optimistic: show user message immediately. + // Optimistic burst — everything that gives the user "I sent a message + // and the agent is now working" feedback fires BEFORE the HTTP roundtrip. + // Pre-#status-pill the pending-task seed lived after `await + // sendChatMessage` and the pill blinked in a few hundred ms after the + // user's message — small but visible "did it actually send?" gap. + const sentAt = new Date().toISOString(); const optimistic: ChatMessage = { id: `optimistic-${Date.now()}`, chat_session_id: sessionId, role: "user", content: finalContent, task_id: null, - created_at: new Date().toISOString(), + created_at: sentAt, }; qc.setQueryData( chatKeys.messages(sessionId), (old) => (old ? [...old, optimistic] : [optimistic]), ); + // Seed the pending-task with a temporary id so the StatusPill mounts + // and starts ticking the instant the user clicks send. Real task_id + // and server-authoritative created_at land below; until then the pill + // is anchored to the local clock (drift is the request RTT, ~50–200ms, + // which doesn't change the rendered "Ns" value). + qc.setQueryData(chatKeys.pendingTask(sessionId), { + task_id: `optimistic-${optimistic.id}`, + status: "queued", + created_at: sentAt, + }); apiLogger.debug("sendChatMessage.optimistic", { sessionId, optimisticId: optimistic.id }); const result = await api.sendChatMessage(sessionId, finalContent); @@ -218,11 +228,13 @@ export function ChatWindow() { messageId: result.message_id, taskId: result.task_id, }); - // Seed pending-task optimistically so the spinner shows instantly — - // the WS chat:message handler will invalidate + refetch to confirm. - qc.setQueryData(chatKeys.pendingTask(sessionId), { + // Replace the temporary task_id with the server's real one (so the WS + // task: handlers can match against it) and snap the anchor to the + // server's created_at — keeping the elapsed-seconds reading stable. + qc.setQueryData(chatKeys.pendingTask(sessionId), { task_id: result.task_id, status: "queued", + created_at: result.created_at, }); qc.invalidateQueries({ queryKey: chatKeys.messages(sessionId) }); }, @@ -236,24 +248,30 @@ export function ChatWindow() { ], ); - const handleStop = useCallback(async () => { - if (!pendingTaskId) { + const handleStop = useCallback(() => { + if (!pendingTaskId || !activeSessionId) { apiLogger.debug("cancelTask skipped: no pending task"); return; } + // Optimistic clear — pill disappears + input unlocks the moment the + // user clicks Stop, instead of after the HTTP roundtrip. WS + // task:cancelled will confirm later (no-op if cache is already empty); + // if the cancel POST fails because the task already finished, the + // assistant message arrives via task:completed → chat:done and renders + // normally. Either way the UI is in sync with reality without latency. apiLogger.info("cancelTask.start", { taskId: pendingTaskId, sessionId: activeSessionId }); - try { - await api.cancelTaskById(pendingTaskId); - apiLogger.info("cancelTask.success", { taskId: pendingTaskId }); - } catch (err) { - // Task may already be completed - apiLogger.warn("cancelTask.error (task may have already finished)", { taskId: pendingTaskId, err }); - } - if (activeSessionId) { - // Clear pending immediately; WS task:cancelled will confirm. - qc.setQueryData(chatKeys.pendingTask(activeSessionId), {}); - qc.invalidateQueries({ queryKey: chatKeys.messages(activeSessionId) }); - } + qc.setQueryData(chatKeys.pendingTask(activeSessionId), {}); + qc.invalidateQueries({ queryKey: chatKeys.messages(activeSessionId) }); + // Fire-and-forget — UI is already in its post-cancel state. We log the + // outcome but never block on it. + api.cancelTaskById(pendingTaskId).then( + () => apiLogger.info("cancelTask.success", { taskId: pendingTaskId }), + (err) => + apiLogger.warn("cancelTask.error (task may have already finished)", { + taskId: pendingTaskId, + err, + }), + ); }, [pendingTaskId, activeSessionId, qc]); const handleSelectAgent = useCallback( @@ -402,8 +420,9 @@ export function ChatWindow() { ) : hasMessages ? ( ) : ( )} + {/* Presence banner sits above the input card (not inside topSlot) so + * the "offline / unstable" hint reads as a global session signal, + * not an attachment to the message being composed. ContextAnchorCard + * stays in topSlot because that's per-message context. */} + + {/* Input — disabled for archived sessions */} - + {activeAgent.name} @@ -520,7 +551,13 @@ function AgentMenuItem({ onClick={() => onSelect(agent)} className="flex min-w-0 items-center gap-2" > - + {agent.name} {isCurrent && } @@ -545,16 +582,60 @@ function SessionDropdown({ activeSessionId: string | null; onSelectSession: (session: ChatSession) => void; }) { + const wsId = useWorkspaceId(); const agentById = useMemo(() => new Map(agents.map((a) => [a.id, a])), [agents]); const activeSession = sessions.find((s) => s.id === activeSessionId); const title = activeSession?.title?.trim() || "New chat"; const triggerAgent = activeSession ? agentById.get(activeSession.agent_id) ?? null : null; + // Aggregate "which sessions have an in-flight task right now". Reuses + // the same workspace-scoped query the FAB consumes, so toggling the chat + // window doesn't fire a second request — TanStack dedupes by key. + const { data: pending } = useQuery(pendingChatTasksOptions(wsId)); + const inFlightSessionIds = useMemo( + () => new Set((pending?.tasks ?? []).map((t) => t.chat_session_id)), + [pending], + ); + + // Cross-session aggregate signal for the closed-dropdown trigger. + // "Active" here means there's something interesting happening in a + // session OTHER than the one the user is currently looking at — the + // user already sees their own session's state via the StatusPill / + // unread auto-mark, so highlighting it on the trigger would be noise. + // Same priority rule as the row pips: running > unread. + const otherSessionRunning = sessions.some( + (s) => s.id !== activeSessionId && inFlightSessionIds.has(s.id), + ); + const otherSessionUnread = sessions.some( + (s) => s.id !== activeSessionId && s.has_unread, + ); + return ( - {triggerAgent && } + {triggerAgent && ( + + )} {title} + {otherSessionRunning ? ( + + ) : otherSessionUnread ? ( + + ) : null} @@ -566,6 +647,7 @@ function SessionDropdown({ sessions.map((session) => { const isCurrent = session.id === activeSessionId; const agent = agentById.get(session.agent_id) ?? null; + const isRunning = inFlightSessionIds.has(session.id); return ( {agent ? ( - + ) : ( )} {session.title?.trim() || "New chat"} - {session.has_unread && ( - - )} + {/* Right-edge status pip: in-flight wins over unread because + * "still working" is more actionable than "has reply" — and + * the two rarely coexist in practice (the unread flag fires + * on chat_message write, by which point the task has just + * finished). Same pip shape as unread for visual rhythm, + * amber + pulse to read as activity. */} + {isRunning ? ( + + ) : session.has_unread ? ( + + ) : null} {isCurrent && } ); @@ -593,17 +697,6 @@ function SessionDropdown({ ); } -function AgentAvatarSmall({ agent }: { agent: Agent }) { - return ( - - {agent.avatar_url && } - - - - - ); -} - /** * Three starter prompts shown on the empty state. Tapping one sends it * immediately — ChatGPT-style — because the point is showing users what diff --git a/packages/views/chat/components/offline-banner.tsx b/packages/views/chat/components/offline-banner.tsx new file mode 100644 index 000000000..bec9644f6 --- /dev/null +++ b/packages/views/chat/components/offline-banner.tsx @@ -0,0 +1,54 @@ +"use client"; + +import { AlertCircle, WifiOff } from "lucide-react"; +import type { AgentAvailability } from "@multica/core/agents"; + +interface Props { + /** Display name shown in the banner copy. */ + agentName?: string; + /** + * Resolved presence availability. Pass `undefined` (or "loading") to + * suppress the banner — we only surface known offline / unstable states, + * never speculative copy. + */ + availability: AgentAvailability | undefined; +} + +// Inline notice rendered above the chat input when the active agent isn't +// reachable. Hides on `online`, `undefined`, or while presence is loading — +// users get the silent default behaviour and only see copy when there's a +// real-world implication for the message they're about to send. +// +// Sits outside the input card (sibling of ChatInput) so the hint reads as +// a session-level signal rather than per-message context. The outer wrapper +// (`px-5`) and the inner container (`mx-auto max-w-4xl`) mirror ChatInput's +// own layout so the banner's edges line up with the input box on every +// viewport size — without `max-w-4xl` the banner stretches wider than the +// input on large screens and looks "loose". +export function OfflineBanner({ agentName, availability }: Props) { + if (availability !== "offline" && availability !== "unstable") return null; + + const name = agentName?.trim() || "the agent"; + if (availability === "unstable") { + return ( +
+
+ + + {name}'s connection is unstable — replies may be delayed. + +
+
+ ); + } + return ( +
+
+ + + {name} is offline — your message will be delivered when they're back. + +
+
+ ); +} diff --git a/packages/views/chat/components/task-status-pill.tsx b/packages/views/chat/components/task-status-pill.tsx new file mode 100644 index 000000000..b95adbaff --- /dev/null +++ b/packages/views/chat/components/task-status-pill.tsx @@ -0,0 +1,209 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { X } from "lucide-react"; +import { cn } from "@multica/ui/lib/utils"; +import { UnicodeSpinner } from "@multica/ui/components/common/unicode-spinner"; +import type { BrailleSpinnerName } from "unicode-animations"; +import type { AgentAvailability } from "@multica/core/agents"; +import type { ChatPendingTask, TaskMessagePayload } from "@multica/core/types"; +import { formatElapsedSecs } from "../lib/format"; + +interface Props { + /** Server-authoritative pending-task snapshot (`created_at` anchors the timer). */ + pendingTask: ChatPendingTask; + /** Live task-message stream — the latest non-error entry decides the running-stage label. */ + taskMessages: readonly TaskMessagePayload[]; + /** Resolved presence; pass `undefined` to suppress availability hints. */ + availability: AgentAvailability | undefined; + /** When set, `onCancel` is exposed once the task crosses the long-run threshold. */ + onCancel?: () => void; +} + +interface Stage { + /** Standalone label, capitalised so it reads as a complete short phrase + * ("Searching the web · 14s") without needing a subject. Matches the + * ChatGPT / Cursor / Claude style — the agent identity is already on + * the chat header, so we don't repeat it inline. */ + label: string; + /** null = static (offline / unstable spinning would feel anxious). */ + spinner: BrailleSpinnerName | null; + /** Stage represents a stable holding state (offline / waiting). When true, + * the label is rendered without the shimmer animation — shimmer implies + * "the agent is actively doing something", which a holding state isn't. */ + static?: boolean; +} + +// Tool → label. Short, action-flavoured phrases — the daemon-reported tool +// slug is meaningful but ugly ("ToolUse: read"); these are the user-facing +// translations. Unknown tools fall back to "Working" rather than leaking +// the raw slug. +const TOOL_STAGES: Record = { + bash: { label: "Running a command", spinner: "helix" }, + exec: { label: "Running a command", spinner: "helix" }, + read: { label: "Reading files", spinner: "scan" }, + glob: { label: "Reading files", spinner: "scan" }, + grep: { label: "Searching the code", spinner: "scan" }, + write: { label: "Making edits", spinner: "cascade" }, + edit: { label: "Making edits", spinner: "cascade" }, + multi_edit: { label: "Making edits", spinner: "cascade" }, + multiedit: { label: "Making edits", spinner: "cascade" }, + web_search: { label: "Searching the web", spinner: "orbit" }, + websearch: { label: "Searching the web", spinner: "orbit" }, +}; + +const STAGE_FALLBACK: Stage = { label: "Working", spinner: "helix" }; + +// During the first-token gap (status=running but no task_message yet) +// the agent could be loading the model, opening an API session, or +// actually reasoning. Rotating the label by elapsed seconds — instead +// of pinning a single "Thinking..." — makes the wait feel progressive +// without claiming what the model is literally doing. Boundaries are +// tiered (each label implies "this is taking a bit longer") rather +// than randomised, which would jitter on every render. +function pickThinkingLabel(elapsedSecs: number): string { + if (elapsedSecs < 5) return "Thinking"; + if (elapsedSecs < 15) return "Reasoning"; + if (elapsedSecs < 30) return "Working through it"; + return "Taking a closer look"; +} + +// Pure stage decision. Two-tier signal: presence + status drive the +// queued/wait copy, then taskMessages drive the running-state label. +// Errors deliberately don't flip the pill — the timeline already renders +// the error inline, and overwriting the label would mask whatever the +// agent does next. +function pickStage( + status: string | undefined, + taskMessages: readonly TaskMessagePayload[], + availability: AgentAvailability | undefined, + elapsedSecs: number, +): Stage { + if ( + (status === "queued" || status === "dispatched") && + availability === "offline" + ) { + return { label: "Offline", spinner: null, static: true }; + } + if ( + (status === "queued" || status === "dispatched") && + availability === "unstable" + ) { + return { label: "Reconnecting", spinner: "pulse" }; + } + if (status === "queued") return { label: "Queued", spinner: "pulse" }; + if (status === "dispatched") return { label: "Starting up", spinner: "breathe" }; + + // running: latest meaningful message decides the label. We deliberately + // skip both `error` rows (rendered inline by the timeline; flipping the + // pill would mask the next real action) and `tool_result` rows + // (tool_result is the completion event for a tool_use, not a new stage — + // treating it as one made the pill flicker bash → Thinking → grep → + // Thinking → web_search on every tool boundary, where reality is just + // bash → grep → web_search). + let latest: TaskMessagePayload | null = null; + for (let i = taskMessages.length - 1; i >= 0; i--) { + const m = taskMessages[i]; + if (m && m.type !== "error" && m.type !== "tool_result") { + latest = m; + break; + } + } + + // No task_message yet — first-token delay. Rotate the thinking label + // by elapsed so the user perceives progressive waiting rather than + // a stuck "Thinking..." loop. + if (!latest) { + return { label: pickThinkingLabel(elapsedSecs), spinner: "breathe" }; + } + + if (latest.type === "thinking") { + return { label: pickThinkingLabel(elapsedSecs), spinner: "breathe" }; + } + if (latest.type === "text") { + return { label: "Typing", spinner: "braille" }; + } + if (latest.type === "tool_use") { + const tool = (latest.tool ?? "").toLowerCase(); + return TOOL_STAGES[tool] ?? STAGE_FALLBACK; + } + return { label: pickThinkingLabel(elapsedSecs), spinner: "breathe" }; +} + +const WARNING_THRESHOLD_S = 60; +const CANCEL_THRESHOLD_S = 300; + +export function TaskStatusPill({ + pendingTask, + taskMessages, + availability, + onCancel, +}: Props) { + // Anchor: locked on first render. Once set we never reassign — otherwise + // the timer would visibly snap backwards when an optimistic-seeded + // `Date.now()` anchor is later replaced by a server-side created_at that + // happened a few hundred ms earlier. Monotonic elapsed > strict accuracy. + const anchorRef = useRef(null); + if (anchorRef.current === null) { + if (pendingTask.created_at) { + const t = Date.parse(pendingTask.created_at); + anchorRef.current = Number.isFinite(t) ? t : Date.now(); + } else { + anchorRef.current = Date.now(); + } + } + const anchor = anchorRef.current; + + const [now, setNow] = useState(() => Date.now()); + useEffect(() => { + const timer = setInterval(() => setNow(Date.now()), 1000); + return () => clearInterval(timer); + }, []); + + // Effective status — defense-in-depth derive on top of the cache. If any + // task_message has streamed in, the daemon has by definition started + // running; we trust that observation over a stale cache. Catches WS gaps, + // reconnect windows, or out-of-order delivery where the cache hasn't been + // writethrough'd yet. + const status = taskMessages.length > 0 ? "running" : pendingTask.status; + const elapsedSecs = Math.max(0, Math.floor((now - anchor) / 1000)); + const stage = pickStage(status, taskMessages, availability, elapsedSecs); + const isWarning = elapsedSecs >= WARNING_THRESHOLD_S; + const showCancel = !!onCancel && elapsedSecs >= CANCEL_THRESHOLD_S; + + // Shimmer the label whenever the agent is actively doing something — + // skipped for `static` stages (offline holding) and `isWarning` (the + // amber colour is the signal we want, shimmer would mute it under the + // gradient mask). + const animateLabel = !stage.static && !isWarning; + + return ( +
+ {stage.spinner && ( + + )} + + + {stage.label} + + · {formatElapsedSecs(elapsedSecs)} + + {showCancel && ( + + )} +
+ ); +} diff --git a/packages/views/chat/lib/format.ts b/packages/views/chat/lib/format.ts new file mode 100644 index 000000000..d67d7506e --- /dev/null +++ b/packages/views/chat/lib/format.ts @@ -0,0 +1,19 @@ +/** + * Format an elapsed seconds value as `Ns` (under a minute) or `Nm Ms` + * (over a minute). Drops the seconds part when the remainder is 0 to + * keep round-minute readings short ("3m" rather than "3m 0s"). Shared + * by the live StatusPill timer and the persistent assistant-message + * timing line — keeping them in lockstep avoids visible drift between + * "Working · 38s" mid-flight and a final "Replied in 39s" caption. + */ +export function formatElapsedSecs(secs: number): string { + if (secs < 60) return `${secs}s`; + const m = Math.floor(secs / 60); + const s = secs % 60; + return s ? `${m}m ${s}s` : `${m}m`; +} + +/** Convenience: same formatting, but the input is milliseconds (server-stored elapsed_ms). */ +export function formatElapsedMs(ms: number): string { + return formatElapsedSecs(Math.max(0, Math.round(ms / 1000))); +} diff --git a/packages/views/editor/content-editor.tsx b/packages/views/editor/content-editor.tsx index a7340ff19..91080c01d 100644 --- a/packages/views/editor/content-editor.tsx +++ b/packages/views/editor/content-editor.tsx @@ -94,6 +94,10 @@ interface ContentEditorRef { getMarkdown: () => string; clearContent: () => void; focus: () => void; + /** Drop focus from the editor — used by chat after send so the caret + * stops competing with the StatusPill / streaming reply for the user's + * attention. */ + blur: () => void; uploadFile: (file: File) => void; /** True when file uploads are still in progress. */ hasActiveUploads: () => boolean; @@ -233,6 +237,9 @@ const ContentEditor = forwardRef( focus: () => { editor?.commands.focus(); }, + blur: () => { + editor?.commands.blur(); + }, uploadFile: (file: File) => { if (!editor || !onUploadFileRef.current) return; const endPos = editor.state.doc.content.size; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6b36eb77d..9b6e32d96 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -69,6 +69,9 @@ catalogs: typescript: specifier: ^5.9.3 version: 5.9.3 + unicode-animations: + specifier: ^1.0.3 + version: 1.0.3 vitest: specifier: ^4.1.0 version: 4.1.0 @@ -609,6 +612,9 @@ importers: tw-animate-css: specifier: ^1.4.0 version: 1.4.0 + unicode-animations: + specifier: 'catalog:' + version: 1.0.3 vaul: specifier: ^1.1.2 version: 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -7097,6 +7103,10 @@ packages: resolution: {integrity: sha512-3IWdCpjgxp15CbJnsi/Y9TCDE7HWVN19j1hmzVhoAkY/+CJx449tVxT5wZc1Gwg8J+P0LWvzlBzxYRnHJ+1i7Q==} engines: {node: '>=20.18.1'} + unicode-animations@1.0.3: + resolution: {integrity: sha512-+klB2oWwcYZjYWhwP4Pr8UZffWDFVx6jKeIahE6z0QYyM2dwDeDPyn5nevCYbyotxvtT9lh21cVURO1RX0+YMg==} + hasBin: true + unicorn-magic@0.3.0: resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==} engines: {node: '>=18'} @@ -14814,6 +14824,8 @@ snapshots: undici@7.24.5: {} + unicode-animations@1.0.3: {} + unicorn-magic@0.3.0: {} unified@11.0.5: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index b15fc06df..a3327bef3 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -32,6 +32,9 @@ catalog: # Icons lucide-react: "^1.0.1" + # Loading animations (chat StatusPill) + unicode-animations: "^1.0.3" + # Product analytics posthog-js: "^1.176.1" diff --git a/server/internal/handler/chat.go b/server/internal/handler/chat.go index 292f968af..df9cf0c8d 100644 --- a/server/internal/handler/chat.go +++ b/server/internal/handler/chat.go @@ -206,6 +206,12 @@ type SendChatMessageRequest struct { type SendChatMessageResponse struct { MessageID string `json:"message_id"` TaskID string `json:"task_id"` + // CreatedAt anchors the chat StatusPill timer the instant the user + // hits send. Without it the front-end falls back to its local clock + // and the timer "snaps backwards" later when WS events deliver the + // real created_at. Returning it here means the pill renders 0s from + // the start with a stable anchor. + CreatedAt string `json:"created_at"` } func (h *Handler) SendChatMessage(w http.ResponseWriter, r *http.Request) { @@ -273,6 +279,7 @@ func (h *Handler) SendChatMessage(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusCreated, SendChatMessageResponse{ MessageID: uuidToString(msg.ID), TaskID: uuidToString(task.ID), + CreatedAt: timestampToString(task.CreatedAt), }) } @@ -304,9 +311,14 @@ func (h *Handler) ListChatMessages(w http.ResponseWriter, r *http.Request) { // PendingChatTaskResponse is returned by GetPendingChatTask — either the // current in-flight task's id/status, or an empty object when none is active. +// CreatedAt is the anchor the frontend uses to time the chat StatusPill +// (elapsed seconds = now - CreatedAt). It must come from the server because +// optimistic seeds don't have a real task created_at and the timer needs to +// survive refresh / reopen. type PendingChatTaskResponse struct { - TaskID string `json:"task_id,omitempty"` - Status string `json:"status,omitempty"` + TaskID string `json:"task_id,omitempty"` + Status string `json:"status,omitempty"` + CreatedAt string `json:"created_at,omitempty"` } // MarkChatSessionRead clears the session's unread_since (→ has_unread=false) @@ -403,8 +415,9 @@ func (h *Handler) GetPendingChatTask(w http.ResponseWriter, r *http.Request) { } writeJSON(w, http.StatusOK, PendingChatTaskResponse{ - TaskID: uuidToString(task.ID), - Status: task.Status, + TaskID: uuidToString(task.ID), + Status: task.Status, + CreatedAt: timestampToString(task.CreatedAt), }) } @@ -491,6 +504,12 @@ type ChatMessageResponse struct { Content string `json:"content"` TaskID *string `json:"task_id"` CreatedAt string `json:"created_at"` + // FailureReason flags an assistant row synthesized by FailTask's chat + // fallback. Front-end uses it to switch to the destructive bubble. + FailureReason *string `json:"failure_reason"` + // ElapsedMs is the wall-clock duration from task creation to terminal + // state. Drives "Replied in 38s" / "Failed after 12s" captions. + ElapsedMs *int64 `json:"elapsed_ms"` } func chatSessionToResponse(s db.ChatSession) ChatSessionResponse { @@ -514,5 +533,7 @@ func chatMessageToResponse(m db.ChatMessage) ChatMessageResponse { Content: m.Content, TaskID: uuidToPtr(m.TaskID), CreatedAt: timestampToString(m.CreatedAt), + FailureReason: textToPtr(m.FailureReason), + ElapsedMs: int8ToPtr(m.ElapsedMs), } } diff --git a/server/internal/handler/handler.go b/server/internal/handler/handler.go index a43b6cf85..178beb176 100644 --- a/server/internal/handler/handler.go +++ b/server/internal/handler/handler.go @@ -138,6 +138,7 @@ func strToText(s string) pgtype.Text { return util.StrToText(s) } func timestampToString(t pgtype.Timestamptz) string { return util.TimestampToString(t) } func timestampToPtr(t pgtype.Timestamptz) *string { return util.TimestampToPtr(t) } func uuidToPtr(u pgtype.UUID) *string { return util.UUIDToPtr(u) } +func int8ToPtr(v pgtype.Int8) *int64 { return util.Int8ToPtr(v) } // parseUUIDOrBadRequest validates a UUID string sourced from user input // (URL params, request body, headers). On invalid input it writes a 400 diff --git a/server/internal/service/task.go b/server/internal/service/task.go index 704ae7818..4752e46b8 100644 --- a/server/internal/service/task.go +++ b/server/internal/service/task.go @@ -656,6 +656,7 @@ func (s *TaskService) CompleteTask(ctx context.Context, taskID pgtype.UUID, resu Role: "assistant", Content: redact.Text(body), TaskID: task.ID, + ElapsedMs: computeChatElapsedMs(task), }); err != nil { slog.Error("failed to save assistant chat message", "task_id", util.UUIDToString(task.ID), "error", err) } else { @@ -758,6 +759,31 @@ func (s *TaskService) FailTask(ctx context.Context, taskID pgtype.UUID, errMsg, s.createAgentComment(ctx, task.IssueID, task.AgentID, redact.Text(errMsg), "system", task.TriggerCommentID) } + // Mirror the issue fallback for chat tasks: write an assistant + // chat_message tagged with the daemon-reported failure_reason so the + // conversation history shows what happened. Skip when auto-retry is + // pending (the new attempt will write its own outcome) — same guard as + // the issue path above. + if task.ChatSessionID.Valid && retried == nil { + if _, err := s.Queries.CreateChatMessage(ctx, db.CreateChatMessageParams{ + ChatSessionID: task.ChatSessionID, + Role: "assistant", + Content: redact.Text(errMsg), + TaskID: pgtype.UUID{Bytes: task.ID.Bytes, Valid: true}, + FailureReason: pgtype.Text{String: failureReason, Valid: failureReason != ""}, + ElapsedMs: computeChatElapsedMs(task), + }); err != nil { + slog.Error("failed to save failure chat message", + "task_id", util.UUIDToString(task.ID), + "chat_session_id", util.UUIDToString(task.ChatSessionID), + "error", err) + } else if err := s.Queries.SetUnreadSinceIfNull(ctx, task.ChatSessionID); err != nil { + slog.Warn("failed to set unread_since on failure", + "chat_session_id", util.UUIDToString(task.ChatSessionID), + "error", err) + } + } + // Quick-create tasks: push a failure inbox notification to the // requester so they can either retry or fall back to the advanced form // without losing their original prompt. Skipped when an auto-retry is @@ -1085,6 +1111,23 @@ type AgentSkillFileData struct { Content string `json:"content"` } +// computeChatElapsedMs returns the wall-clock duration from task creation +// (user hit send) to terminal state (completed/failed). Stored on the +// assistant chat_message so the UI can render "Replied in 38s" / +// "Failed after 12s". Uses created_at — not started_at — because users +// experience total wait time, including queue + dispatch, not just the +// daemon's actual run time. +func computeChatElapsedMs(task db.AgentTaskQueue) pgtype.Int8 { + if !task.CompletedAt.Valid || !task.CreatedAt.Valid { + return pgtype.Int8{} + } + ms := task.CompletedAt.Time.Sub(task.CreatedAt.Time).Milliseconds() + if ms < 0 { + ms = 0 + } + return pgtype.Int8{Int64: ms, Valid: true} +} + func priorityToInt(p string) int32 { switch p { case "urgent": @@ -1119,6 +1162,12 @@ func (s *TaskService) broadcastTaskDispatch(ctx context.Context, task db.AgentTa payload["runtime_id"] = util.UUIDToString(task.RuntimeID) payload["issue_id"] = util.UUIDToString(task.IssueID) payload["agent_id"] = util.UUIDToString(task.AgentID) + // chat_session_id is the routing key the chat window uses to writethrough + // `chatKeys.pendingTask` to status="running" the moment the daemon claims + // the task. Without it the pill stays stuck at "Queued" until completion. + if task.ChatSessionID.Valid { + payload["chat_session_id"] = util.UUIDToString(task.ChatSessionID) + } workspaceID := s.ResolveTaskWorkspaceID(ctx, task) if workspaceID == "" { diff --git a/server/internal/util/pgx.go b/server/internal/util/pgx.go index 683d3d081..f32796d9d 100644 --- a/server/internal/util/pgx.go +++ b/server/internal/util/pgx.go @@ -99,3 +99,10 @@ func UUIDToPtr(u pgtype.UUID) *string { s := UUIDToString(u) return &s } + +func Int8ToPtr(v pgtype.Int8) *int64 { + if !v.Valid { + return nil + } + return &v.Int64 +} diff --git a/server/migrations/062_chat_message_failure_reason.down.sql b/server/migrations/062_chat_message_failure_reason.down.sql new file mode 100644 index 000000000..f4b51711a --- /dev/null +++ b/server/migrations/062_chat_message_failure_reason.down.sql @@ -0,0 +1 @@ +ALTER TABLE chat_message DROP COLUMN failure_reason; diff --git a/server/migrations/062_chat_message_failure_reason.up.sql b/server/migrations/062_chat_message_failure_reason.up.sql new file mode 100644 index 000000000..a41f8a0d4 --- /dev/null +++ b/server/migrations/062_chat_message_failure_reason.up.sql @@ -0,0 +1,6 @@ +-- Mirror the issue path's "fallback comment on failure" with a failure_reason +-- column on chat_message. When FailTask runs on a chat task, server writes +-- an assistant chat_message tagged with the daemon-reported reason so the +-- conversation history shows what happened (instead of the previous black +-- hole where a failed task left no trace in the user-visible thread). +ALTER TABLE chat_message ADD COLUMN failure_reason TEXT; diff --git a/server/migrations/063_chat_message_elapsed.down.sql b/server/migrations/063_chat_message_elapsed.down.sql new file mode 100644 index 000000000..2849b5040 --- /dev/null +++ b/server/migrations/063_chat_message_elapsed.down.sql @@ -0,0 +1 @@ +ALTER TABLE chat_message DROP COLUMN elapsed_ms; diff --git a/server/migrations/063_chat_message_elapsed.up.sql b/server/migrations/063_chat_message_elapsed.up.sql new file mode 100644 index 000000000..85e0db842 --- /dev/null +++ b/server/migrations/063_chat_message_elapsed.up.sql @@ -0,0 +1,6 @@ +-- Capture per-task wall-clock duration (queue → done) on assistant chat +-- messages so the UI can render "Replied in 38s" / "Failed after 12s" +-- under each reply. BIGINT to avoid any int32 overflow concerns even +-- though chat tasks are short — keeps the column reusable for longer +-- workloads later. +ALTER TABLE chat_message ADD COLUMN elapsed_ms BIGINT; diff --git a/server/pkg/db/generated/chat.sql.go b/server/pkg/db/generated/chat.sql.go index acf11d1af..a0c0f08e5 100644 --- a/server/pkg/db/generated/chat.sql.go +++ b/server/pkg/db/generated/chat.sql.go @@ -22,9 +22,9 @@ func (q *Queries) ArchiveChatSession(ctx context.Context, id pgtype.UUID) error } const createChatMessage = `-- name: CreateChatMessage :one -INSERT INTO chat_message (chat_session_id, role, content, task_id) -VALUES ($1, $2, $3, $4) -RETURNING id, chat_session_id, role, content, task_id, created_at +INSERT INTO chat_message (chat_session_id, role, content, task_id, failure_reason, elapsed_ms) +VALUES ($1, $2, $3, $4, $5, $6) +RETURNING id, chat_session_id, role, content, task_id, created_at, failure_reason, elapsed_ms ` type CreateChatMessageParams struct { @@ -32,6 +32,8 @@ type CreateChatMessageParams struct { Role string `json:"role"` Content string `json:"content"` TaskID pgtype.UUID `json:"task_id"` + FailureReason pgtype.Text `json:"failure_reason"` + ElapsedMs pgtype.Int8 `json:"elapsed_ms"` } func (q *Queries) CreateChatMessage(ctx context.Context, arg CreateChatMessageParams) (ChatMessage, error) { @@ -40,6 +42,8 @@ func (q *Queries) CreateChatMessage(ctx context.Context, arg CreateChatMessagePa arg.Role, arg.Content, arg.TaskID, + arg.FailureReason, + arg.ElapsedMs, ) var i ChatMessage err := row.Scan( @@ -49,6 +53,8 @@ func (q *Queries) CreateChatMessage(ctx context.Context, arg CreateChatMessagePa &i.Content, &i.TaskID, &i.CreatedAt, + &i.FailureReason, + &i.ElapsedMs, ) return i, err } @@ -141,7 +147,7 @@ func (q *Queries) CreateChatTask(ctx context.Context, arg CreateChatTaskParams) } const getChatMessage = `-- name: GetChatMessage :one -SELECT id, chat_session_id, role, content, task_id, created_at FROM chat_message +SELECT id, chat_session_id, role, content, task_id, created_at, failure_reason, elapsed_ms FROM chat_message WHERE id = $1 ` @@ -155,6 +161,8 @@ func (q *Queries) GetChatMessage(ctx context.Context, id pgtype.UUID) (ChatMessa &i.Content, &i.TaskID, &i.CreatedAt, + &i.FailureReason, + &i.ElapsedMs, ) return i, err } @@ -239,23 +247,27 @@ func (q *Queries) GetLastChatTaskSession(ctx context.Context, chatSessionID pgty } const getPendingChatTask = `-- name: GetPendingChatTask :one -SELECT id, status FROM agent_task_queue +SELECT id, status, created_at FROM agent_task_queue WHERE chat_session_id = $1 AND status IN ('queued', 'dispatched', 'running') ORDER BY created_at DESC LIMIT 1 ` type GetPendingChatTaskRow struct { - ID pgtype.UUID `json:"id"` - Status string `json:"status"` + ID pgtype.UUID `json:"id"` + Status string `json:"status"` + CreatedAt pgtype.Timestamptz `json:"created_at"` } // Returns the most recent in-flight task for a chat session, if any. // Used by the frontend to recover pending state after refresh / reopen. +// created_at is the anchor for the chat StatusPill timer (it computes +// elapsed = now - task.created_at), so the pill survives refresh / reopen +// without "resetting to 0s". func (q *Queries) GetPendingChatTask(ctx context.Context, chatSessionID pgtype.UUID) (GetPendingChatTaskRow, error) { row := q.db.QueryRow(ctx, getPendingChatTask, chatSessionID) var i GetPendingChatTaskRow - err := row.Scan(&i.ID, &i.Status) + err := row.Scan(&i.ID, &i.Status, &i.CreatedAt) return i, err } @@ -321,7 +333,7 @@ func (q *Queries) ListAllChatSessionsByCreator(ctx context.Context, arg ListAllC } const listChatMessages = `-- name: ListChatMessages :many -SELECT id, chat_session_id, role, content, task_id, created_at FROM chat_message +SELECT id, chat_session_id, role, content, task_id, created_at, failure_reason, elapsed_ms FROM chat_message WHERE chat_session_id = $1 ORDER BY created_at ASC ` @@ -342,6 +354,8 @@ func (q *Queries) ListChatMessages(ctx context.Context, chatSessionID pgtype.UUI &i.Content, &i.TaskID, &i.CreatedAt, + &i.FailureReason, + &i.ElapsedMs, ); err != nil { return nil, err } diff --git a/server/pkg/db/generated/models.go b/server/pkg/db/generated/models.go index 6866fc5a2..3d311213b 100644 --- a/server/pkg/db/generated/models.go +++ b/server/pkg/db/generated/models.go @@ -161,6 +161,8 @@ type ChatMessage struct { Content string `json:"content"` TaskID pgtype.UUID `json:"task_id"` CreatedAt pgtype.Timestamptz `json:"created_at"` + FailureReason pgtype.Text `json:"failure_reason"` + ElapsedMs pgtype.Int8 `json:"elapsed_ms"` } type ChatSession struct { diff --git a/server/pkg/db/queries/chat.sql b/server/pkg/db/queries/chat.sql index d27b99a9c..3bb5744cc 100644 --- a/server/pkg/db/queries/chat.sql +++ b/server/pkg/db/queries/chat.sql @@ -54,8 +54,8 @@ UPDATE chat_session SET updated_at = now() WHERE id = $1; -- name: CreateChatMessage :one -INSERT INTO chat_message (chat_session_id, role, content, task_id) -VALUES ($1, $2, $3, sqlc.narg(task_id)) +INSERT INTO chat_message (chat_session_id, role, content, task_id, failure_reason, elapsed_ms) +VALUES ($1, $2, $3, sqlc.narg(task_id), sqlc.narg(failure_reason), sqlc.narg(elapsed_ms)) RETURNING *; -- name: ListChatMessages :many @@ -88,7 +88,10 @@ LIMIT 1; -- name: GetPendingChatTask :one -- Returns the most recent in-flight task for a chat session, if any. -- Used by the frontend to recover pending state after refresh / reopen. -SELECT id, status FROM agent_task_queue +-- created_at is the anchor for the chat StatusPill timer (it computes +-- elapsed = now - task.created_at), so the pill survives refresh / reopen +-- without "resetting to 0s". +SELECT id, status, created_at FROM agent_task_queue WHERE chat_session_id = $1 AND status IN ('queued', 'dispatched', 'running') ORDER BY created_at DESC LIMIT 1;