diff --git a/ui/goose2/src/app/AppShell.tsx b/ui/goose2/src/app/AppShell.tsx index afc437c0b3..a7922706c4 100644 --- a/ui/goose2/src/app/AppShell.tsx +++ b/ui/goose2/src/app/AppShell.tsx @@ -13,6 +13,8 @@ import type { ProjectInfo } from "@/features/projects/api/projects"; import { SettingsModal } from "@/features/settings/ui/SettingsModal"; import { useChatStore } from "@/features/chat/stores/chatStore"; import { useChatSessionStore } from "@/features/chat/stores/chatSessionStore"; +import { useAcpStream } from "@/features/chat/hooks/useAcpStream"; +import { isSessionRunning } from "@/features/chat/lib/sessionActivity"; import { useAgentStore } from "@/features/agents/stores/agentStore"; import { useProjectStore } from "@/features/projects/stores/projectStore"; import { findReusableNewChatSession } from "@/features/chat/lib/newChat"; @@ -44,13 +46,13 @@ export function AppShell({ children }: { children?: React.ReactNode }) { const agentStore = useAgentStore(); const projectStore = useProjectStore(); - // Track in-flight message loads to avoid duplicate requests + useAcpStream(true); + const loadingSessionsRef = useRef>(new Set()); const pendingProjectCreatedRef = useRef<((projectId: string) => void) | null>( null, ); - // Load messages from backend for a session if not already in the store const loadSessionMessages = useCallback(async (sessionId: string) => { const { messagesBySession, setMessages } = useChatStore.getState(); const existing = messagesBySession[sessionId]; @@ -82,18 +84,26 @@ export function AppShell({ children }: { children?: React.ReactNode }) { projectStore.fetchProjects(); }, [projectStore.fetchProjects]); - // Derive tab objects from session store for TabBar and Sidebar compatibility const { sessions, openTabIds, activeTabId } = sessionStore; + useEffect(() => { + if (activeView === "chat" && activeTabId) { + useChatStore.getState().markSessionRead(activeTabId); + } + }, [activeTabId, activeView]); + const tabs: Tab[] = useMemo(() => { const sessionMap = new Map(sessions.map((s) => [s.id, s])); return openTabIds.reduce((acc, id) => { const session = sessionMap.get(id); if (session) { + const runtime = chatStore.getSessionRuntime(session.id); const tab: Tab = { id: session.id, title: session.title, sessionId: session.id, // tab ID === session ID in new model + isRunning: isSessionRunning(runtime.chatState), + hasUnread: runtime.hasUnread, }; if (session.agentId) tab.agentId = session.agentId; if (session.projectId) tab.projectId = session.projectId; @@ -101,17 +111,18 @@ export function AppShell({ children }: { children?: React.ReactNode }) { } return acc; }, []); - }, [openTabIds, sessions]); + }, [chatStore, openTabIds, sessions]); const isHome = activeTabId === null && activeView === "home"; const activeTab = tabs.find((t) => t.id === activeTabId) ?? null; - // Derive status bar values from stores const activeSession = activeTabId ? sessionStore.getSession(activeTabId) : undefined; const modelName = activeSession?.modelName; - const tokenCount = chatStore.tokenState.totalTokens; + const tokenCount = activeTabId + ? chatStore.getSessionRuntime(activeTabId).tokenState.totalTokens + : 0; const [pendingInitialMessage, setPendingInitialMessage] = useState< string | undefined @@ -158,7 +169,6 @@ export function AppShell({ children }: { children?: React.ReactNode }) { sessionStore.openTab(session.id); setActiveView("chat"); - // Set the active session in chatStore chatStore.setActiveSession(session.id); return session; @@ -326,7 +336,6 @@ export function AppShell({ children }: { children?: React.ReactNode }) { initialMessage?.slice(0, 40) || "New Chat", selectedProject, ).catch(() => { - // Clear pending message if tab creation fails to avoid stale state setPendingInitialMessage(undefined); setHomeSelectedProvider(undefined); setHomeSelectedPersonaId(undefined); @@ -339,7 +348,6 @@ export function AppShell({ children }: { children?: React.ReactNode }) { const handleTabSelect = useCallback( (id: string) => { - // Open the tab if it's not already open (e.g. clicking a recent session) const { openTabIds: currentOpenTabIds } = useChatSessionStore.getState(); if (!currentOpenTabIds.includes(id)) { sessionStore.openTab(id); @@ -347,9 +355,8 @@ export function AppShell({ children }: { children?: React.ReactNode }) { sessionStore.setActiveTab(id); } setActiveView("chat"); - // Sync chatStore's active session (id === sessionId in new model) chatStore.setActiveSession(id); - // Load historical messages from backend if not already in store + useChatStore.getState().markSessionRead(id); loadSessionMessages(id); }, [sessionStore, chatStore, loadSessionMessages], @@ -381,7 +388,6 @@ export function AppShell({ children }: { children?: React.ReactNode }) { return () => window.removeEventListener("keydown", handler); }, []); - // Derive stored persona for active session const activeSessionPersonaId = activeTab ? sessionStore.getSession(activeTab.sessionId)?.personaId : undefined; @@ -423,7 +429,6 @@ export function AppShell({ children }: { children?: React.ReactNode }) { return (
- {/* Tab bar — full width across the top */} handleNavigate("home")} /> - {/* Main content area — sidebar + content as flex row */}
- {/* Sidebar wrapper — padding creates the floating effect */}
- {/* Content area */}
{children ?? renderContent()}
- {/* Status bar — conditional with animation */}
- {/* Settings modal */} {settingsOpen && setSettingsOpen(false)} />} - {/* Create project dialog */} { diff --git a/ui/goose2/src/features/chat/hooks/__tests__/useAcpStream.test.ts b/ui/goose2/src/features/chat/hooks/__tests__/useAcpStream.test.ts index d8b9c91e31..66ad0903e3 100644 --- a/ui/goose2/src/features/chat/hooks/__tests__/useAcpStream.test.ts +++ b/ui/goose2/src/features/chat/hooks/__tests__/useAcpStream.test.ts @@ -1,44 +1,39 @@ -import { describe, it, expect, beforeEach, vi } from "vitest"; -import { renderHook, act, cleanup } from "@testing-library/react"; -import { useChatStore } from "../../stores/chatStore"; -import { INITIAL_TOKEN_STATE } from "@/shared/types/chat"; +import { act, cleanup, renderHook } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import type { Message } from "@/shared/types/messages"; - -// ── Tauri listen mock ──────────────────────────────────────────────── +import { useChatSessionStore } from "../../stores/chatSessionStore"; +import { useChatStore } from "../../stores/chatStore"; type EventCallback = (event: { payload: Record }) => void; const listeners = new Map(); function emit(eventName: string, payload: Record) { - const cbs = listeners.get(eventName) ?? []; - for (const cb of cbs) { - cb({ payload }); + const callbacks = listeners.get(eventName) ?? []; + for (const callback of callbacks) { + callback({ payload }); } } vi.mock("@tauri-apps/api/event", () => ({ listen: vi.fn( (eventName: string, callback: EventCallback): Promise<() => void> => { - const cbs = listeners.get(eventName) ?? []; - cbs.push(callback); - listeners.set(eventName, cbs); + const callbacks = listeners.get(eventName) ?? []; + callbacks.push(callback); + listeners.set(eventName, callbacks); return Promise.resolve(() => { const current = listeners.get(eventName) ?? []; listeners.set( eventName, - current.filter((cb) => cb !== callback), + current.filter((entry) => entry !== callback), ); }); }, ), })); -// Import hook AFTER mock is set up import { useAcpStream } from "../useAcpStream"; -// ── helpers ────────────────────────────────────────────────────────── - function makeStreamingMessage(overrides: Partial = {}): Message { return { id: "msg-1", @@ -50,16 +45,13 @@ function makeStreamingMessage(overrides: Partial = {}): Message { }; } -function setupStreaming(sessionId: string) { - const msg = makeStreamingMessage(); +function setupStreaming(sessionId: string, messageId = "msg-1") { const store = useChatStore.getState(); - store.addMessage(sessionId, msg); - store.setStreamingMessageId("msg-1"); - store.setChatState("streaming"); + store.addMessage(sessionId, makeStreamingMessage({ id: messageId })); + store.setStreamingMessageId(sessionId, messageId); + store.setChatState(sessionId, "streaming"); } -// ── tests ──────────────────────────────────────────────────────────── - describe("useAcpStream", () => { const sessionId = "test-session"; @@ -68,22 +60,30 @@ describe("useAcpStream", () => { cleanup(); useChatStore.setState({ messagesBySession: {}, - chatState: "idle", - streamingMessageId: null, - tokenState: { ...INITIAL_TOKEN_STATE }, - error: null, - isConnected: true, + sessionStateById: {}, activeSessionId: sessionId, + isConnected: true, + }); + useChatSessionStore.setState({ + sessions: [ + { + id: sessionId, + title: "New Chat", + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + messageCount: 0, + }, + ], + openTabIds: [sessionId], + activeTabId: sessionId, + isLoading: false, }); }); - // ── text events ────────────────────────────────────────────────── - - it("accumulates text chunks from acp:text events", async () => { + it("accumulates text chunks for the targeted session", async () => { setupStreaming(sessionId); - renderHook(() => useAcpStream(sessionId, true)); - // Allow listen promises to resolve + renderHook(() => useAcpStream(true)); await vi.waitFor(() => expect(listeners.get("acp:text")).toBeDefined()); act(() => { @@ -92,38 +92,16 @@ describe("useAcpStream", () => { }); const messages = useChatStore.getState().messagesBySession[sessionId]; - const textContent = messages[0].content.find((c) => c.type === "text"); - expect(textContent).toBeDefined(); - if (textContent && "text" in textContent) { - expect(textContent.text).toBe("Hello world"); + const text = messages[0].content.find((content) => content.type === "text"); + if (text && "text" in text) { + expect(text.text).toBe("Hello world"); } }); - // ── session filtering ──────────────────────────────────────────── - - it("ignores events targeting a different session", async () => { + it("clears runtime state on acp:done for the completed session", async () => { setupStreaming(sessionId); - renderHook(() => useAcpStream(sessionId, true)); - await vi.waitFor(() => expect(listeners.get("acp:text")).toBeDefined()); - - act(() => { - emit("acp:text", { sessionId: "other-session", text: "wrong" }); - }); - - const messages = useChatStore.getState().messagesBySession[sessionId]; - const textContent = messages[0].content.find((c) => c.type === "text"); - if (textContent && "text" in textContent) { - expect(textContent.text).toBe(""); - } - }); - - // ── done handling ──────────────────────────────────────────────── - - it("clears streaming state on acp:done", async () => { - setupStreaming(sessionId); - - renderHook(() => useAcpStream(sessionId, true)); + renderHook(() => useAcpStream(true)); await vi.waitFor(() => expect(listeners.get("acp:done")).toBeDefined()); act(() => { @@ -131,188 +109,101 @@ describe("useAcpStream", () => { emit("acp:done", { sessionId }); }); - const state = useChatStore.getState(); - expect(state.streamingMessageId).toBeNull(); - expect(state.chatState).toBe("idle"); - // Text should be preserved in the message - const messages = state.messagesBySession[sessionId]; - const textContent = messages[0].content.find((c) => c.type === "text"); - if (textContent && "text" in textContent) { - expect(textContent.text).toBe("partial"); - } + const runtime = useChatStore.getState().getSessionRuntime(sessionId); + expect(runtime.chatState).toBe("idle"); + expect(runtime.streamingMessageId).toBeNull(); + expect(runtime.hasUnread).toBe(false); }); - // ── tool_call events ───────────────────────────────────────────── + it("marks a background session unread when it completes", async () => { + const backgroundSessionId = "background-session"; - it("appends tool request on acp:tool_call", async () => { - const msg = makeStreamingMessage({ content: [] }); - useChatStore.getState().addMessage(sessionId, msg); - useChatStore.getState().setStreamingMessageId("msg-1"); - useChatStore.getState().setChatState("streaming"); - - renderHook(() => useAcpStream(sessionId, true)); - await vi.waitFor(() => - expect(listeners.get("acp:tool_call")).toBeDefined(), - ); - - act(() => { - emit("acp:tool_call", { - sessionId, - toolCallId: "tool-1", - title: "read_file", - }); - }); - - const messages = useChatStore.getState().messagesBySession[sessionId]; - expect(messages[0].content).toHaveLength(1); - const toolContent = messages[0].content[0]; - expect(toolContent.type).toBe("toolRequest"); - if (toolContent.type === "toolRequest") { - expect(toolContent.id).toBe("tool-1"); - expect(toolContent.name).toBe("read_file"); - expect(toolContent.status).toBe("executing"); - } - }); - - it("updates the active tool request title on acp:tool_title", async () => { - const msg = makeStreamingMessage({ - content: [ + setupStreaming(backgroundSessionId); + useChatSessionStore.setState((state) => ({ + ...state, + sessions: [ + ...state.sessions, { - type: "toolRequest", - id: "tool-1", - name: "placeholder", - arguments: {}, - status: "executing", + id: backgroundSessionId, + title: "Background Chat", + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + messageCount: 0, }, ], - }); - useChatStore.getState().addMessage(sessionId, msg); - useChatStore.getState().setStreamingMessageId("msg-1"); - useChatStore.getState().setChatState("streaming"); + openTabIds: [...state.openTabIds, backgroundSessionId], + activeTabId: sessionId, + })); - renderHook(() => useAcpStream(sessionId, true)); - await vi.waitFor(() => - expect(listeners.get("acp:tool_title")).toBeDefined(), - ); + renderHook(() => useAcpStream(true)); + await vi.waitFor(() => expect(listeners.get("acp:done")).toBeDefined()); act(() => { - emit("acp:tool_title", { - sessionId, - toolCallId: "tool-1", - title: "read_file", - }); + emit("acp:done", { sessionId: backgroundSessionId }); }); - const messages = useChatStore.getState().messagesBySession[sessionId]; - const toolContent = messages[0].content[0]; - expect(toolContent.type).toBe("toolRequest"); - if (toolContent.type === "toolRequest") { - expect(toolContent.name).toBe("read_file"); - } + expect( + useChatStore.getState().getSessionRuntime(backgroundSessionId).hasUnread, + ).toBe(true); + expect(useChatStore.getState().getSessionRuntime(sessionId).hasUnread).toBe( + false, + ); }); - // ── tool_result events ─────────────────────────────────────────── + it("updates multiple sessions independently", async () => { + const otherSessionId = "other-session"; - it("appends tool response on acp:tool_result", async () => { - const msg = makeStreamingMessage({ - content: [ - { - type: "toolRequest", - id: "tool-1", - name: "read_file", - arguments: {}, - status: "executing", - }, - ], - }); - useChatStore.getState().addMessage(sessionId, msg); - useChatStore.getState().setStreamingMessageId("msg-1"); - useChatStore.getState().setChatState("streaming"); + setupStreaming(sessionId, "msg-1"); + setupStreaming(otherSessionId, "msg-2"); - renderHook(() => useAcpStream(sessionId, true)); - await vi.waitFor(() => - expect(listeners.get("acp:tool_result")).toBeDefined(), - ); + renderHook(() => useAcpStream(true)); + await vi.waitFor(() => expect(listeners.get("acp:text")).toBeDefined()); act(() => { - emit("acp:tool_result", { sessionId, content: "file contents here" }); + emit("acp:text", { sessionId, text: "Alpha" }); + emit("acp:text", { sessionId: otherSessionId, text: "Beta" }); + emit("acp:done", { sessionId: otherSessionId }); }); - const messages = useChatStore.getState().messagesBySession[sessionId]; - expect(messages[0].content).toHaveLength(2); - const toolContent = messages[0].content[1]; - expect(toolContent.type).toBe("toolResponse"); - if (toolContent.type === "toolResponse") { - expect(toolContent.id).toBe("tool-1"); - expect(toolContent.name).toBe("read_file"); - expect(toolContent.result).toBe("file contents here"); - expect(toolContent.isError).toBe(false); - } - }); + const primary = useChatStore.getState().messagesBySession[sessionId]; + const secondary = useChatStore.getState().messagesBySession[otherSessionId]; + const primaryText = primary[0].content.find( + (content) => content.type === "text", + ); + const secondaryText = secondary[0].content.find( + (content) => content.type === "text", + ); - // ── enabled flag ───────────────────────────────────────────────── + if (primaryText && "text" in primaryText) { + expect(primaryText.text).toBe("Alpha"); + } + if (secondaryText && "text" in secondaryText) { + expect(secondaryText.text).toBe("Beta"); + } + + expect(useChatStore.getState().getSessionRuntime(sessionId).chatState).toBe( + "streaming", + ); + expect( + useChatStore.getState().getSessionRuntime(otherSessionId).chatState, + ).toBe("idle"); + }); it("does not register listeners when disabled", () => { - setupStreaming(sessionId); - - renderHook(() => useAcpStream(sessionId, false)); - - // No listeners should be registered + renderHook(() => useAcpStream(false)); expect(listeners.size).toBe(0); }); - // ── cleanup on unmount ─────────────────────────────────────────── - - it("unregisters all listeners on unmount", async () => { - setupStreaming(sessionId); - - const { unmount } = renderHook(() => useAcpStream(sessionId, true)); + it("unregisters listeners on unmount", async () => { + const { unmount } = renderHook(() => useAcpStream(true)); await vi.waitFor(() => expect(listeners.get("acp:text")).toBeDefined()); - // Listeners are registered expect(listeners.get("acp:text")?.length).toBe(1); unmount(); - // Allow unlisten promises to resolve + await vi.waitFor(() => expect(listeners.get("acp:text")?.length ?? 0).toBe(0), ); - - expect(listeners.get("acp:done")?.length ?? 0).toBe(0); - expect(listeners.get("acp:tool_call")?.length ?? 0).toBe(0); - expect(listeners.get("acp:tool_result")?.length ?? 0).toBe(0); - }); - - // ── interleaved content ────────────────────────────────────────── - - it("handles interleaved text and tool events", async () => { - setupStreaming(sessionId); - - renderHook(() => useAcpStream(sessionId, true)); - await vi.waitFor(() => expect(listeners.get("acp:text")).toBeDefined()); - - act(() => { - emit("acp:text", { sessionId, text: "Let me read that." }); - emit("acp:tool_call", { - sessionId, - toolCallId: "t1", - title: "read_file", - }); - emit("acp:tool_result", { sessionId, content: "file data" }); - emit("acp:text", { sessionId, text: " Here is what I found." }); - }); - - const messages = useChatStore.getState().messagesBySession[sessionId]; - expect(messages[0].content).toHaveLength(4); - expect(messages[0].content[0].type).toBe("text"); - expect(messages[0].content[1].type).toBe("toolRequest"); - expect(messages[0].content[2].type).toBe("toolResponse"); - expect(messages[0].content[3].type).toBe("text"); - if (messages[0].content[2].type === "toolResponse") { - expect(messages[0].content[2].id).toBe("t1"); - } - if (messages[0].content[3].type === "text") { - expect(messages[0].content[3].text).toBe(" Here is what I found."); - } }); }); diff --git a/ui/goose2/src/features/chat/hooks/__tests__/useChat.test.ts b/ui/goose2/src/features/chat/hooks/__tests__/useChat.test.ts index a508b0736e..e67ba5c8a0 100644 --- a/ui/goose2/src/features/chat/hooks/__tests__/useChat.test.ts +++ b/ui/goose2/src/features/chat/hooks/__tests__/useChat.test.ts @@ -2,7 +2,6 @@ import { act, renderHook } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { useAgentStore } from "@/features/agents/stores/agentStore"; import { useChatStore } from "../../stores/chatStore"; -import { INITIAL_TOKEN_STATE } from "@/shared/types/chat"; const mockAcpSendMessage = vi.fn(); const mockAcpCancelSession = vi.fn(); @@ -23,17 +22,12 @@ function createDeferredPromise() { } describe("useChat", () => { - const sessionId = "session-1"; - beforeEach(() => { vi.clearAllMocks(); useChatStore.setState({ messagesBySession: {}, + sessionStateById: {}, activeSessionId: null, - chatState: "idle", - streamingMessageId: null, - tokenState: { ...INITIAL_TOKEN_STATE }, - error: null, isConnected: true, }); useAgentStore.setState({ @@ -71,7 +65,7 @@ describe("useChat", () => { mockAcpSendMessage.mockReturnValue(deferred.promise); const { result } = renderHook(() => - useChat(sessionId, undefined, undefined, { + useChat("session-1", undefined, undefined, { id: "persona-a", name: "Persona A", }), @@ -91,7 +85,7 @@ describe("useChat", () => { }); expect(mockAcpSendMessage).toHaveBeenCalledWith( - sessionId, + "session-1", "goose", "Hello", { @@ -101,11 +95,102 @@ describe("useChat", () => { personaName: "Persona B", }, ); - expect(mockAcpCancelSession).toHaveBeenCalledWith(sessionId, "persona-b"); + expect(mockAcpCancelSession).toHaveBeenCalledWith("session-1", "persona-b"); deferred.resolve(); await act(async () => { await sendPromise; }); }); + + it("keeps persona-aware cancellation working after remount", async () => { + const deferred = createDeferredPromise(); + mockAcpSendMessage.mockReturnValue(deferred.promise); + + const firstMount = renderHook(() => + useChat("session-1", undefined, undefined, { + id: "persona-a", + name: "Persona A", + }), + ); + + let sendPromise!: Promise; + await act(async () => { + sendPromise = firstMount.result.current.sendMessage("Hello", { + id: "persona-b", + name: "Persona B", + }); + await Promise.resolve(); + }); + + firstMount.unmount(); + + const secondMount = renderHook(() => + useChat("session-1", undefined, undefined, { + id: "persona-a", + name: "Persona A", + }), + ); + + act(() => { + secondMount.result.current.stopGeneration(); + }); + + expect(mockAcpCancelSession).toHaveBeenCalledWith("session-1", "persona-b"); + + deferred.resolve(); + await act(async () => { + await sendPromise; + }); + }); + + it("allows another session to send while a different session is streaming", async () => { + const deferred = createDeferredPromise(); + mockAcpSendMessage + .mockReturnValueOnce(deferred.promise) + .mockResolvedValueOnce(undefined); + + const firstSession = renderHook(() => useChat("session-1")); + const secondSession = renderHook(() => useChat("session-2")); + + let firstPromise!: Promise; + await act(async () => { + firstPromise = firstSession.result.current.sendMessage("First"); + await Promise.resolve(); + }); + + await act(async () => { + await secondSession.result.current.sendMessage("Second"); + }); + + expect(mockAcpSendMessage).toHaveBeenNthCalledWith( + 1, + "session-1", + "goose", + "First", + { + systemPrompt: undefined, + workingDir: undefined, + personaId: undefined, + personaName: undefined, + }, + ); + expect(mockAcpSendMessage).toHaveBeenNthCalledWith( + 2, + "session-2", + "goose", + "Second", + { + systemPrompt: undefined, + workingDir: undefined, + personaId: undefined, + personaName: undefined, + }, + ); + + deferred.resolve(); + await act(async () => { + await firstPromise; + }); + }); }); diff --git a/ui/goose2/src/features/chat/hooks/useAcpStream.ts b/ui/goose2/src/features/chat/hooks/useAcpStream.ts index caa6e05098..a030d13721 100644 --- a/ui/goose2/src/features/chat/hooks/useAcpStream.ts +++ b/ui/goose2/src/features/chat/hooks/useAcpStream.ts @@ -1,4 +1,4 @@ -import { useEffect, useRef } from "react"; +import { useEffect } from "react"; import { listen, type UnlistenFn } from "@tauri-apps/api/event"; import { useChatStore } from "../stores/chatStore"; import { useChatSessionStore } from "../stores/chatSessionStore"; @@ -79,15 +79,11 @@ function findLatestUnpairedToolRequest( * Hook that listens to Tauri events for ACP streaming responses. * * Subscribes to `acp:text`, `acp:done`, `acp:tool_call`, `acp:tool_title`, - * and `acp:tool_result` events, filtering by `sessionId`. Updates the chat - * store as streaming data arrives. + * and `acp:tool_result` events, updating whichever session the event targets. */ -export function useAcpStream(sessionId: string, enabled: boolean): void { - const sessionIdRef = useRef(sessionId); - sessionIdRef.current = sessionId; - +export function useAcpStream(enabled: boolean): void { useEffect(() => { - if (!enabled || !sessionId) return; + if (!enabled) return; // Guard against duplicate listeners from React StrictMode double-mounting. // Each listener checks this flag before acting; cleanup sets it immediately. @@ -99,7 +95,6 @@ export function useAcpStream(sessionId: string, enabled: boolean): void { unlisteners.push( listen("acp:text", (event) => { if (!active) return; - if (event.payload.sessionId !== sessionIdRef.current) return; useChatStore .getState() .updateStreamingText(event.payload.sessionId, event.payload.text); @@ -110,10 +105,14 @@ export function useAcpStream(sessionId: string, enabled: boolean): void { unlisteners.push( listen("acp:done", (event) => { if (!active) return; - if (event.payload.sessionId !== sessionIdRef.current) return; const store = useChatStore.getState(); - store.setStreamingMessageId(null); - store.setChatState("idle"); + store.setStreamingMessageId(event.payload.sessionId, null); + store.setChatState(event.payload.sessionId, "idle"); + if ( + useChatSessionStore.getState().activeTabId !== event.payload.sessionId + ) { + store.markSessionUnread(event.payload.sessionId); + } // Generate a title from the first user message if the session still // has the default "New Chat" title (i.e. no ACP title was received). @@ -139,7 +138,6 @@ export function useAcpStream(sessionId: string, enabled: boolean): void { unlisteners.push( listen("acp:tool_call", (event) => { if (!active) return; - if (event.payload.sessionId !== sessionIdRef.current) return; const toolRequest: ToolRequestContent = { type: "toolRequest", id: event.payload.toolCallId, @@ -157,11 +155,11 @@ export function useAcpStream(sessionId: string, enabled: boolean): void { unlisteners.push( listen("acp:tool_title", (event) => { if (!active) return; - if (event.payload.sessionId !== sessionIdRef.current) return; const { sessionId: sid, toolCallId, title } = event.payload; const store = useChatStore.getState(); - if (!store.streamingMessageId) return; - store.updateMessage(sid, store.streamingMessageId, (msg) => ({ + const { streamingMessageId } = store.getSessionRuntime(sid); + if (!streamingMessageId) return; + store.updateMessage(sid, streamingMessageId, (msg) => ({ ...msg, content: msg.content.map((c) => c.type === "toolRequest" && c.id === toolCallId @@ -176,11 +174,13 @@ export function useAcpStream(sessionId: string, enabled: boolean): void { unlisteners.push( listen("acp:tool_result", (event) => { if (!active) return; - if (event.payload.sessionId !== sessionIdRef.current) return; const store = useChatStore.getState(); - const streamingMessage = store.streamingMessageId + const { streamingMessageId } = store.getSessionRuntime( + event.payload.sessionId, + ); + const streamingMessage = streamingMessageId ? store.messagesBySession[event.payload.sessionId]?.find( - (message) => message.id === store.streamingMessageId, + (message) => message.id === streamingMessageId, ) : undefined; const toolRequest = streamingMessage @@ -201,7 +201,6 @@ export function useAcpStream(sessionId: string, enabled: boolean): void { unlisteners.push( listen("acp:session_info", (event) => { if (!active) return; - if (event.payload.sessionId !== sessionIdRef.current) return; if (event.payload.title) { useChatSessionStore .getState() @@ -216,7 +215,6 @@ export function useAcpStream(sessionId: string, enabled: boolean): void { unlisteners.push( listen("acp:model_state", (event) => { if (!active) return; - if (event.payload.sessionId !== sessionIdRef.current) return; const modelName = event.payload.currentModelName ?? event.payload.currentModelId; useChatSessionStore @@ -229,8 +227,7 @@ export function useAcpStream(sessionId: string, enabled: boolean): void { unlisteners.push( listen("acp:usage_update", (event) => { if (!active) return; - if (event.payload.sessionId !== sessionIdRef.current) return; - useChatStore.getState().updateTokenState({ + useChatStore.getState().updateTokenState(event.payload.sessionId, { accumulatedTotal: event.payload.used, contextLimit: event.payload.size, }); @@ -245,5 +242,5 @@ export function useAcpStream(sessionId: string, enabled: boolean): void { unlistenPromise.then((unlisten) => unlisten()); } }; - }, [sessionId, enabled]); + }, [enabled]); } diff --git a/ui/goose2/src/features/chat/hooks/useChat.ts b/ui/goose2/src/features/chat/hooks/useChat.ts index 9bb4f63fda..3861a77246 100644 --- a/ui/goose2/src/features/chat/hooks/useChat.ts +++ b/ui/goose2/src/features/chat/hooks/useChat.ts @@ -20,15 +20,23 @@ export function useChat( ) { const store = useChatStore(); const abortRef = useRef(null); - const activeStreamingPersonaIdRef = useRef(null); const messages = store.messagesBySession[sessionId] ?? []; - const chatState = store.chatState; - const tokenState = store.tokenState; - const error = store.error; - const streamingMessageId = store.streamingMessageId; + const { chatState, tokenState, error, streamingMessageId } = + store.getSessionRuntime(sessionId); const isStreaming = streamingMessageId !== null; + const getStreamingPersonaId = useCallback(() => { + if (!streamingMessageId) { + return null; + } + + return ( + messages.find((message) => message.id === streamingMessageId)?.metadata + ?.personaId ?? null + ); + }, [messages, streamingMessageId]); + const resolvePersonaInfo = useCallback( (overridePersonaId?: string, overridePersonaName?: string) => { if (overridePersonaId) { @@ -70,8 +78,8 @@ export function useChat( }; } store.addMessage(sessionId, userMessage); - store.setChatState("thinking"); - store.setError(null); + store.setChatState(sessionId, "thinking"); + store.setError(sessionId, null); // Create placeholder assistant message for streaming const assistantMessage: Message = { @@ -87,11 +95,10 @@ export function useChat( }, }; store.addMessage(sessionId, assistantMessage); - store.setStreamingMessageId(assistantMessage.id); + store.setStreamingMessageId(sessionId, assistantMessage.id); const abort = new AbortController(); abortRef.current = abort; - activeStreamingPersonaIdRef.current = effectivePersonaInfo?.id ?? null; try { const agent = useAgentStore.getState().getActiveAgent(); @@ -100,8 +107,8 @@ export function useChat( systemPromptOverride ?? agent?.systemPrompt ?? undefined; // Send via ACP — response streams back through Tauri events - // which are handled by useAcpStream in ChatView. - store.setChatState("streaming"); + // which are handled by the global useAcpStream listener in AppShell. + store.setChatState(sessionId, "streaming"); await acpSendMessage(sessionId, providerId, text, { systemPrompt, workingDir: workingDirOverride, @@ -111,17 +118,16 @@ export function useChat( // Note: setChatState("idle") is handled by useAcpStream on "acp:done" } catch (err) { if (err instanceof DOMException && err.name === "AbortError") { - store.setChatState("idle"); + store.setChatState(sessionId, "idle"); } else { const errorMessage = err instanceof Error ? err.message : "Unknown error"; - store.setError(errorMessage); - store.setChatState("idle"); - store.setStreamingMessageId(null); + store.setError(sessionId, errorMessage); + store.setChatState(sessionId, "idle"); + store.setStreamingMessageId(sessionId, null); } } finally { abortRef.current = null; - activeStreamingPersonaIdRef.current = null; } }, [ @@ -137,15 +143,14 @@ export function useChat( const stopGeneration = useCallback(() => { abortRef.current?.abort(); - const activePersonaId = activeStreamingPersonaIdRef.current; - store.setChatState("idle"); - store.setStreamingMessageId(null); - activeStreamingPersonaIdRef.current = null; + const activePersonaId = getStreamingPersonaId(); + store.setChatState(sessionId, "idle"); + store.setStreamingMessageId(sessionId, null); // Cancel the backend ACP session to stop orphaned streaming events acpCancelSession(sessionId, activePersonaId ?? undefined).catch(() => { // Best-effort cancellation — ignore errors }); - }, [store, sessionId]); + }, [getStreamingPersonaId, store, sessionId]); const retryLastMessage = useCallback(async () => { const sessionMessages = store.messagesBySession[sessionId] ?? []; @@ -178,10 +183,9 @@ export function useChat( const clearChat = useCallback(() => { abortRef.current?.abort(); - activeStreamingPersonaIdRef.current = null; store.clearMessages(sessionId); - store.setChatState("idle"); - store.setStreamingMessageId(null); + store.setChatState(sessionId, "idle"); + store.setStreamingMessageId(sessionId, null); }, [sessionId, store]); const stopStreaming = stopGeneration; diff --git a/ui/goose2/src/features/chat/lib/sessionActivity.ts b/ui/goose2/src/features/chat/lib/sessionActivity.ts new file mode 100644 index 0000000000..30861268d5 --- /dev/null +++ b/ui/goose2/src/features/chat/lib/sessionActivity.ts @@ -0,0 +1,10 @@ +import type { ChatState } from "@/shared/types/chat"; + +export function isSessionRunning(chatState: ChatState): boolean { + return ( + chatState === "thinking" || + chatState === "streaming" || + chatState === "waiting" || + chatState === "compacting" + ); +} diff --git a/ui/goose2/src/features/chat/stores/__tests__/chatStore.test.ts b/ui/goose2/src/features/chat/stores/__tests__/chatStore.test.ts index 85ed7b7325..e611a39af3 100644 --- a/ui/goose2/src/features/chat/stores/__tests__/chatStore.test.ts +++ b/ui/goose2/src/features/chat/stores/__tests__/chatStore.test.ts @@ -1,9 +1,7 @@ -import { describe, it, expect, beforeEach } from "vitest"; -import { useChatStore } from "../chatStore"; -import type { Message } from "@/shared/types/messages"; +import { beforeEach, describe, expect, it } from "vitest"; import { INITIAL_TOKEN_STATE } from "@/shared/types/chat"; - -// ── helpers ─────────────────────────────────────────────────────────── +import type { Message } from "@/shared/types/messages"; +import { useChatStore } from "../chatStore"; function makeMessage(overrides: Partial = {}): Message { return { @@ -16,273 +14,118 @@ function makeMessage(overrides: Partial = {}): Message { }; } -// ── tests ───────────────────────────────────────────────────────────── +function getRuntime(sessionId: string) { + return useChatStore.getState().getSessionRuntime(sessionId); +} describe("chatStore", () => { beforeEach(() => { - // Reset the store between tests useChatStore.setState({ messagesBySession: {}, + sessionStateById: {}, activeSessionId: null, - chatState: "idle", - streamingMessageId: null, - tokenState: { ...INITIAL_TOKEN_STATE }, - error: null, isConnected: false, }); }); - // ── initial state ───────────────────────────────────────────────── - - it("has correct initial state", () => { + it("starts with empty messages and no active session", () => { const state = useChatStore.getState(); expect(state.messagesBySession).toEqual({}); - expect(state.chatState).toBe("idle"); + expect(state.sessionStateById).toEqual({}); expect(state.activeSessionId).toBeNull(); - expect(state.streamingMessageId).toBeNull(); - expect(state.error).toBeNull(); }); - // ── session management ──────────────────────────────────────────── + it("stores messages per session", () => { + const first = makeMessage({ id: "first" }); + const second = makeMessage({ id: "second" }); - it("setActiveSession updates activeSessionId", () => { - useChatStore.getState().setActiveSession("s1"); - expect(useChatStore.getState().activeSessionId).toBe("s1"); + useChatStore.getState().addMessage("s1", first); + useChatStore.getState().addMessage("s2", second); + + expect(useChatStore.getState().messagesBySession.s1).toEqual([first]); + expect(useChatStore.getState().messagesBySession.s2).toEqual([second]); }); - // ── message management ──────────────────────────────────────────── + it("updates runtime state per session", () => { + const store = useChatStore.getState(); - it("addMessage adds message to the correct session", () => { - const msg = makeMessage(); - useChatStore.getState().addMessage("s1", msg); - expect(useChatStore.getState().messagesBySession.s1).toEqual([msg]); + store.setChatState("s1", "streaming"); + store.setStreamingMessageId("s1", "stream-1"); + store.updateTokenState("s1", { inputTokens: 12, outputTokens: 8 }); + + const runtime = getRuntime("s1"); + expect(runtime.chatState).toBe("streaming"); + expect(runtime.streamingMessageId).toBe("stream-1"); + expect(runtime.tokenState.totalTokens).toBe(20); + + expect(getRuntime("s2").chatState).toBe("idle"); + expect(getRuntime("s2").tokenState).toEqual(INITIAL_TOKEN_STATE); }); - it("addMessage creates session array if it does not exist", () => { - const msg = makeMessage(); - useChatStore.getState().addMessage("new-session", msg); - expect( - useChatStore.getState().messagesBySession["new-session"], - ).toHaveLength(1); - }); - - it("setMessages replaces all messages for a session", () => { - const m1 = makeMessage(); - const m2 = makeMessage(); - useChatStore.getState().addMessage("s1", m1); - useChatStore.getState().setMessages("s1", [m2]); - expect(useChatStore.getState().messagesBySession.s1).toEqual([m2]); - }); - - it("removeMessage removes the correct message", () => { - const m1 = makeMessage({ id: "keep" }); - const m2 = makeMessage({ id: "remove" }); - useChatStore.getState().setMessages("s1", [m1, m2]); - useChatStore.getState().removeMessage("s1", "remove"); - const msgs = useChatStore.getState().messagesBySession.s1; - expect(msgs).toHaveLength(1); - expect(msgs[0].id).toBe("keep"); - }); - - it("clearMessages empties the session", () => { - useChatStore.getState().addMessage("s1", makeMessage()); - useChatStore.getState().clearMessages("s1"); - expect(useChatStore.getState().messagesBySession.s1).toEqual([]); - }); - - it("updateMessage correctly applies updater function", () => { - const msg = makeMessage({ id: "u1" }); - useChatStore.getState().setMessages("s1", [msg]); - useChatStore.getState().updateMessage("s1", "u1", (m) => ({ - ...m, - content: [{ type: "text", text: "updated" }], - })); - const updated = useChatStore.getState().messagesBySession.s1[0]; - expect(updated.content[0]).toEqual({ type: "text", text: "updated" }); - }); - - // ── chat state ──────────────────────────────────────────────────── - - it("setChatState updates state", () => { - useChatStore.getState().setChatState("streaming"); - expect(useChatStore.getState().chatState).toBe("streaming"); - }); - - // ── streaming ───────────────────────────────────────────────────── - - it("setStreamingMessageId updates streaming ID", () => { - useChatStore.getState().setStreamingMessageId("msg-1"); - expect(useChatStore.getState().streamingMessageId).toBe("msg-1"); - }); - - it("updateStreamingText appends streamed chunks to the trailing text block", () => { - const msg = makeMessage({ + it("appends streamed text only within the targeted session", () => { + const streaming = makeMessage({ id: "stream-1", content: [{ type: "text", text: "" }], }); - useChatStore.getState().setMessages("s1", [msg]); - useChatStore.getState().setStreamingMessageId("stream-1"); - useChatStore.getState().updateStreamingText("s1", "new"); - useChatStore.getState().updateStreamingText("s1", " text"); - const updated = useChatStore.getState().messagesBySession.s1[0]; - expect(updated.content[0]).toEqual({ type: "text", text: "new text" }); - }); - it("updateStreamingText starts a new text block after tool content", () => { - const msg = makeMessage({ - id: "stream-inline", - content: [ - { type: "text", text: "Lemme check..." }, - { - type: "toolRequest", - id: "tool-1", - name: "readFile", - arguments: {}, - status: "executing", - }, - ], - }); - useChatStore.getState().setMessages("s1", [msg]); - useChatStore.getState().setStreamingMessageId("stream-inline"); - useChatStore - .getState() - .updateStreamingText("s1", " Results from checking."); + useChatStore.getState().setMessages("s1", [streaming]); + useChatStore.getState().setStreamingMessageId("s1", "stream-1"); + useChatStore.getState().updateStreamingText("s1", "Hello"); + useChatStore.getState().updateStreamingText("s1", " world"); const updated = useChatStore.getState().messagesBySession.s1[0]; - expect(updated.content).toHaveLength(3); - expect(updated.content[2]).toEqual({ - type: "text", - text: " Results from checking.", - }); + expect(updated.content[0]).toEqual({ type: "text", text: "Hello world" }); + expect(getRuntime("s2").streamingMessageId).toBeNull(); }); - it("appendToStreamingMessage adds content to the streaming message", () => { - const msg = makeMessage({ id: "stream-2", content: [] }); - useChatStore.getState().setMessages("s1", [msg]); - useChatStore.getState().setStreamingMessageId("stream-2"); - useChatStore - .getState() - .appendToStreamingMessage("s1", { type: "text", text: "appended" }); - const updated = useChatStore.getState().messagesBySession.s1[0]; - expect(updated.content).toHaveLength(1); - expect(updated.content[0]).toEqual({ type: "text", text: "appended" }); + it("transitions a session to error without affecting another session", () => { + const store = useChatStore.getState(); + + store.setChatState("s1", "streaming"); + store.setChatState("s2", "thinking"); + store.setError("s1", "boom"); + + expect(getRuntime("s1").chatState).toBe("error"); + expect(getRuntime("s1").error).toBe("boom"); + expect(getRuntime("s2").chatState).toBe("thinking"); + expect(getRuntime("s2").error).toBeNull(); }); - // ── token tracking ──────────────────────────────────────────────── + it("tracks unread state per session and clears it idempotently", () => { + const store = useChatStore.getState(); - it("updateTokenState accumulates tokens correctly", () => { - useChatStore - .getState() - .updateTokenState({ inputTokens: 100, outputTokens: 50 }); - const ts = useChatStore.getState().tokenState; - expect(ts.inputTokens).toBe(100); - expect(ts.outputTokens).toBe(50); - expect(ts.totalTokens).toBe(150); - expect(ts.accumulatedInput).toBe(100); - expect(ts.accumulatedOutput).toBe(50); - expect(ts.accumulatedTotal).toBe(150); + store.markSessionUnread("s1"); + expect(getRuntime("s1").hasUnread).toBe(true); + expect(getRuntime("s2").hasUnread).toBe(false); + + store.markSessionRead("s1"); + store.markSessionRead("s1"); + expect(getRuntime("s1").hasUnread).toBe(false); }); - it("resetTokenState clears all token counts", () => { - useChatStore - .getState() - .updateTokenState({ inputTokens: 100, outputTokens: 50 }); - useChatStore.getState().resetTokenState(); - expect(useChatStore.getState().tokenState).toEqual(INITIAL_TOKEN_STATE); - }); - - // ── getActiveMessages ───────────────────────────────────────────── - - it("getActiveMessages returns only userVisible messages", () => { - const visible = makeMessage({ metadata: { userVisible: true } }); - const hidden = makeMessage({ metadata: { userVisible: false } }); - const noMeta = makeMessage({ metadata: undefined }); - useChatStore.getState().setMessages("s1", [visible, hidden, noMeta]); - useChatStore.getState().setActiveSession("s1"); - const active = useChatStore.getState().getActiveMessages(); - expect(active).toHaveLength(1); - expect(active[0].id).toBe(visible.id); - }); - - it("getActiveMessages returns empty array when no active session", () => { - expect(useChatStore.getState().getActiveMessages()).toEqual([]); - }); - - // ── streaming error cleanup ────────────────────────────────────── - - it("setError transitions chatState to 'error'", () => { - useChatStore.getState().setChatState("streaming"); - useChatStore.getState().setError("something broke"); - const state = useChatStore.getState(); - expect(state.chatState).toBe("error"); - expect(state.error).toBe("something broke"); - }); - - it("setError(null) preserves current chatState", () => { - useChatStore.getState().setChatState("streaming"); - useChatStore.getState().setError(null); - expect(useChatStore.getState().chatState).toBe("streaming"); - expect(useChatStore.getState().error).toBeNull(); - }); - - it("error cleanup pattern clears both error and streamingMessageId", () => { - const msg = makeMessage({ id: "s1" }); - useChatStore.getState().setMessages("s1", [msg]); - useChatStore.getState().setStreamingMessageId("s1"); - useChatStore.getState().setChatState("streaming"); - - // Simulate the error cleanup pattern from useChat (Bug 3 fix) - useChatStore.getState().setError("ACP connection lost"); - useChatStore.getState().setStreamingMessageId(null); - - const state = useChatStore.getState(); - expect(state.chatState).toBe("error"); - expect(state.error).toBe("ACP connection lost"); - expect(state.streamingMessageId).toBeNull(); - }); - - it("updateStreamingText creates a text block when none exists", () => { - const msg = makeMessage({ id: "stream-no-text", content: [] }); - useChatStore.getState().setMessages("s1", [msg]); - useChatStore.getState().setStreamingMessageId("stream-no-text"); - useChatStore.getState().updateStreamingText("s1", "created from nothing"); - const updated = useChatStore.getState().messagesBySession.s1[0]; - expect(updated.content).toHaveLength(1); - expect(updated.content[0]).toEqual({ - type: "text", - text: "created from nothing", - }); - }); - - it("updateStreamingText is a no-op when streamingMessageId is null", () => { - const msg = makeMessage({ - id: "m1", - content: [{ type: "text", text: "original" }], - }); - useChatStore.getState().setMessages("s1", [msg]); - // streamingMessageId is null by default - useChatStore.getState().updateStreamingText("s1", "should not appear"); - const updated = useChatStore.getState().messagesBySession.s1[0]; - expect(updated.content[0]).toEqual({ type: "text", text: "original" }); - }); - - it("appendToStreamingMessage is a no-op when streamingMessageId is null", () => { - const msg = makeMessage({ id: "m1", content: [] }); - useChatStore.getState().setMessages("s1", [msg]); - useChatStore - .getState() - .appendToStreamingMessage("s1", { type: "text", text: "nope" }); - const updated = useChatStore.getState().messagesBySession.s1[0]; - expect(updated.content).toHaveLength(0); - }); - - // ── cleanup ─────────────────────────────────────────────────────── - - it("cleanupSession removes all data for the session", () => { + it("clears messages and runtime state for a single session", () => { useChatStore.getState().addMessage("s1", makeMessage()); - useChatStore.getState().setActiveSession("s1"); - useChatStore.getState().cleanupSession("s1"); - expect(useChatStore.getState().messagesBySession.s1).toBeUndefined(); - expect(useChatStore.getState().activeSessionId).toBeNull(); + useChatStore.getState().setChatState("s1", "streaming"); + useChatStore.getState().setStreamingMessageId("s1", "stream-1"); + useChatStore.getState().markSessionUnread("s1"); + useChatStore.getState().clearMessages("s1"); + + expect(useChatStore.getState().messagesBySession.s1).toEqual([]); + expect(getRuntime("s1").chatState).toBe("idle"); + expect(getRuntime("s1").streamingMessageId).toBeNull(); + expect(getRuntime("s1").hasUnread).toBe(false); + }); + + it("removes session data during cleanup", () => { + const store = useChatStore.getState(); + + store.addMessage("s1", makeMessage()); + store.setChatState("s1", "streaming"); + store.setActiveSession("s1"); + store.cleanupSession("s1"); + + expect(store.messagesBySession.s1).toBeUndefined(); + expect(store.sessionStateById.s1).toBeUndefined(); + expect(store.activeSessionId).toBeNull(); }); }); diff --git a/ui/goose2/src/features/chat/stores/chatStore.ts b/ui/goose2/src/features/chat/stores/chatStore.ts index 066774e8e7..9dc1472826 100644 --- a/ui/goose2/src/features/chat/stores/chatStore.ts +++ b/ui/goose2/src/features/chat/stores/chatStore.ts @@ -1,27 +1,32 @@ import { create } from "zustand"; import type { Message, MessageContent } from "@/shared/types/messages"; -import type { ChatState, TokenState } from "@/shared/types/chat"; -import { INITIAL_TOKEN_STATE } from "@/shared/types/chat"; +import type { + ChatState, + SessionChatRuntime, + TokenState, +} from "@/shared/types/chat"; +import { + INITIAL_SESSION_CHAT_RUNTIME, + INITIAL_TOKEN_STATE, +} from "@/shared/types/chat"; + +function createInitialSessionRuntime(): SessionChatRuntime { + return { + ...INITIAL_SESSION_CHAT_RUNTIME, + tokenState: { ...INITIAL_TOKEN_STATE }, + }; +} interface ChatStoreState { // Per-session messages messagesBySession: Record; + // Per-session runtime state + sessionStateById: Record; + // Current session activeSessionId: string | null; - // Chat state - chatState: ChatState; - - // Streaming - streamingMessageId: string | null; - - // Token usage - tokenState: TokenState; - - // Error - error: string | null; - // Connection isConnected: boolean; } @@ -43,9 +48,10 @@ interface ChatStoreActions { // Active session helpers (operate on activeSessionId) getActiveMessages: () => Message[]; + getSessionRuntime: (sessionId: string) => SessionChatRuntime; // Streaming - setStreamingMessageId: (id: string | null) => void; + setStreamingMessageId: (sessionId: string, id: string | null) => void; appendToStreamingMessage: ( sessionId: string, content: MessageContent, @@ -53,13 +59,15 @@ interface ChatStoreActions { updateStreamingText: (sessionId: string, text: string) => void; // State - setChatState: (state: ChatState) => void; - setError: (error: string | null) => void; + setChatState: (sessionId: string, state: ChatState) => void; + setError: (sessionId: string, error: string | null) => void; setConnected: (connected: boolean) => void; + markSessionRead: (sessionId: string) => void; + markSessionUnread: (sessionId: string) => void; // Token tracking - updateTokenState: (state: Partial) => void; - resetTokenState: () => void; + updateTokenState: (sessionId: string, state: Partial) => void; + resetTokenState: (sessionId: string) => void; // Cleanup cleanupSession: (sessionId: string) => void; @@ -70,21 +78,20 @@ export type ChatStore = ChatStoreState & ChatStoreActions; export const useChatStore = create((set, get) => ({ // State messagesBySession: {}, + sessionStateById: {}, activeSessionId: null, - chatState: "idle", - streamingMessageId: null, - tokenState: { ...INITIAL_TOKEN_STATE }, - error: null, isConnected: false, // Session management setActiveSession: (sessionId) => set((state) => ({ activeSessionId: sessionId, - chatState: state.activeSessionId !== sessionId ? "idle" : state.chatState, - streamingMessageId: - state.activeSessionId !== sessionId ? null : state.streamingMessageId, - error: state.activeSessionId !== sessionId ? null : state.error, + sessionStateById: state.sessionStateById[sessionId] + ? state.sessionStateById + : { + ...state.sessionStateById, + [sessionId]: createInitialSessionRuntime(), + }, })), // Message management @@ -136,7 +143,10 @@ export const useChatStore = create((set, get) => ({ ...state.messagesBySession, [sessionId]: [], }, - tokenState: { ...INITIAL_TOKEN_STATE }, + sessionStateById: { + ...state.sessionStateById, + [sessionId]: createInitialSessionRuntime(), + }, })), // Active session helpers @@ -147,12 +157,26 @@ export const useChatStore = create((set, get) => ({ return messages.filter((m) => m.metadata?.userVisible); }, + getSessionRuntime: (sessionId) => + get().sessionStateById[sessionId] ?? createInitialSessionRuntime(), + // Streaming - setStreamingMessageId: (id) => set({ streamingMessageId: id }), + setStreamingMessageId: (sessionId, id) => + set((state) => ({ + sessionStateById: { + ...state.sessionStateById, + [sessionId]: { + ...(state.sessionStateById[sessionId] ?? + createInitialSessionRuntime()), + streamingMessageId: id, + }, + }, + })), appendToStreamingMessage: (sessionId, content) => set((state) => { - const { streamingMessageId } = state; + const streamingMessageId = + state.sessionStateById[sessionId]?.streamingMessageId ?? null; if (!streamingMessageId) return state; const messages = state.messagesBySession[sessionId]; if (!messages) return state; @@ -170,7 +194,8 @@ export const useChatStore = create((set, get) => ({ updateStreamingText: (sessionId, text) => set((state) => { - const { streamingMessageId } = state; + const streamingMessageId = + state.sessionStateById[sessionId]?.streamingMessageId ?? null; if (!streamingMessageId) return state; const messages = state.messagesBySession[sessionId]; if (!messages) return state; @@ -200,17 +225,77 @@ export const useChatStore = create((set, get) => ({ }), // State - setChatState: (chatState) => set({ chatState }), + setChatState: (sessionId, chatState) => + set((state) => ({ + sessionStateById: { + ...state.sessionStateById, + [sessionId]: { + ...(state.sessionStateById[sessionId] ?? + createInitialSessionRuntime()), + chatState, + }, + }, + })), - setError: (error) => - set({ error, chatState: error ? ("error" as const) : get().chatState }), + setError: (sessionId, error) => + set((state) => { + const current = + state.sessionStateById[sessionId] ?? createInitialSessionRuntime(); + return { + sessionStateById: { + ...state.sessionStateById, + [sessionId]: { + ...current, + error, + chatState: error ? ("error" as const) : current.chatState, + }, + }, + }; + }), setConnected: (isConnected) => set({ isConnected }), - // Token tracking - updateTokenState: (partial) => + markSessionRead: (sessionId) => set((state) => { - const current = state.tokenState; + const current = + state.sessionStateById[sessionId] ?? createInitialSessionRuntime(); + if (!current.hasUnread) { + return state; + } + return { + sessionStateById: { + ...state.sessionStateById, + [sessionId]: { + ...current, + hasUnread: false, + }, + }, + }; + }), + + markSessionUnread: (sessionId) => + set((state) => { + const current = + state.sessionStateById[sessionId] ?? createInitialSessionRuntime(); + if (current.hasUnread) { + return state; + } + return { + sessionStateById: { + ...state.sessionStateById, + [sessionId]: { + ...current, + hasUnread: true, + }, + }, + }; + }), + + // Token tracking + updateTokenState: (sessionId, partial) => + set((state) => { + const current = + state.sessionStateById[sessionId]?.tokenState ?? INITIAL_TOKEN_STATE; const inputTokens = partial.inputTokens ?? current.inputTokens; const outputTokens = partial.outputTokens ?? current.outputTokens; const accumulatedInput = @@ -222,30 +307,48 @@ export const useChatStore = create((set, get) => ({ const accumulatedTotal = partial.accumulatedTotal ?? accumulatedInput + accumulatedOutput; return { - tokenState: { - inputTokens, - outputTokens, - totalTokens: inputTokens + outputTokens, - accumulatedInput, - accumulatedOutput, - accumulatedTotal, - contextLimit: partial.contextLimit ?? current.contextLimit, + sessionStateById: { + ...state.sessionStateById, + [sessionId]: { + ...(state.sessionStateById[sessionId] ?? + createInitialSessionRuntime()), + tokenState: { + inputTokens, + outputTokens, + totalTokens: inputTokens + outputTokens, + accumulatedInput, + accumulatedOutput, + accumulatedTotal, + contextLimit: partial.contextLimit ?? current.contextLimit, + }, + }, }, }; }), - resetTokenState: () => set({ tokenState: { ...INITIAL_TOKEN_STATE } }), + resetTokenState: (sessionId) => + set((state) => ({ + sessionStateById: { + ...state.sessionStateById, + [sessionId]: { + ...(state.sessionStateById[sessionId] ?? + createInitialSessionRuntime()), + tokenState: { ...INITIAL_TOKEN_STATE }, + }, + }, + })), // Cleanup cleanupSession: (sessionId) => set((state) => { const { [sessionId]: _, ...rest } = state.messagesBySession; + const { [sessionId]: __, ...remainingSessionState } = + state.sessionStateById; return { messagesBySession: rest, + sessionStateById: remainingSessionState, activeSessionId: state.activeSessionId === sessionId ? null : state.activeSessionId, - streamingMessageId: - state.activeSessionId === sessionId ? null : state.streamingMessageId, }; }), })); diff --git a/ui/goose2/src/features/chat/ui/ChatView.tsx b/ui/goose2/src/features/chat/ui/ChatView.tsx index 8280e969a1..17a02de798 100644 --- a/ui/goose2/src/features/chat/ui/ChatView.tsx +++ b/ui/goose2/src/features/chat/ui/ChatView.tsx @@ -3,7 +3,6 @@ import { MessageTimeline } from "./MessageTimeline"; import { ChatInput } from "./ChatInput"; import { LoadingGoose } from "./LoadingGoose"; import { useChat } from "../hooks/useChat"; -import { useAcpStream } from "../hooks/useAcpStream"; import { useChatStore } from "../stores/chatStore"; import { useAgentStore } from "@/features/agents/stores/agentStore"; import { useProviderSelection } from "@/features/agents/hooks/useProviderSelection"; @@ -208,6 +207,7 @@ export function ChatView({ const { messages, chatState, + tokenState, sendMessage, stopStreaming, streamingMessageId, @@ -219,15 +219,11 @@ export function ChatView({ projectFolders[0]?.path, ); - // Listen for ACP streaming events - useAcpStream(activeSessionId, true); - // Ref for deferred sends after persona switch (Bug 1 fix: avoid stale system prompt) const deferredSend = useRef(null); // Wrap sendMessage to handle @ mentioned persona overrides const chatStore = useChatStore(); - const tokenState = useChatStore((s) => s.tokenState); const handleSend = useCallback( (text: string, personaId?: string) => { if (personaId && personaId !== selectedPersonaId) { diff --git a/ui/goose2/src/features/chat/ui/ToolCallCard.tsx b/ui/goose2/src/features/chat/ui/ToolCallCard.tsx index 080b9b76d0..24d6be6694 100644 --- a/ui/goose2/src/features/chat/ui/ToolCallCard.tsx +++ b/ui/goose2/src/features/chat/ui/ToolCallCard.tsx @@ -20,6 +20,16 @@ const pillColors: Record = { stopped: "bg-background-tertiary text-foreground-secondary border-border", } as Record; +const MAX_TOOL_NAME_LENGTH = 48; + +function formatToolName(name: string): string { + if (name.length <= MAX_TOOL_NAME_LENGTH) { + return name; + } + + return `${name.slice(0, MAX_TOOL_NAME_LENGTH - 1)}…`; +} + function StatusIndicator({ status }: { status: ToolCallStatus }) { switch (status) { case "executing": @@ -65,6 +75,7 @@ export function ToolCallCard({ }: ToolCallCardProps) { const [expanded, setExpanded] = useState(false); const elapsed = useElapsedTime(status); + const displayName = formatToolName(name); const hasContent = Object.keys(args).length > 0 || result != null; @@ -73,15 +84,18 @@ export function ToolCallCard({
diff --git a/ui/goose2/src/features/sidebar/ui/SidebarProjectsSection.tsx b/ui/goose2/src/features/sidebar/ui/SidebarProjectsSection.tsx index bafd574de1..c9ea98c8ed 100644 --- a/ui/goose2/src/features/sidebar/ui/SidebarProjectsSection.tsx +++ b/ui/goose2/src/features/sidebar/ui/SidebarProjectsSection.tsx @@ -9,6 +9,7 @@ import { import { cn } from "@/shared/lib/cn"; import type { AppView } from "@/app/AppShell"; import type { ProjectInfo } from "@/features/projects/api/projects"; +import { SessionActivityIndicator } from "@/shared/ui/SessionActivityIndicator"; import { SidebarChatRow } from "./SidebarChatRow"; const MAX_VISIBLE_CHATS = 3; @@ -22,6 +23,8 @@ interface TabInfo { projectId?: string; isOpenTab?: boolean; updatedAt?: string; + isRunning?: boolean; + hasUnread?: boolean; } interface SidebarProjectsSectionProps { @@ -223,6 +226,8 @@ function ProjectSection({ title={tab.title} isActive={isActive} isOpen={isOpen} + isRunning={tab.isRunning ?? false} + hasUnread={tab.hasUnread ?? false} className="pl-5" onSelect={onSelectTab} onRename={onRenameChat} @@ -409,13 +414,18 @@ export function SidebarProjectsSection({ title={tab.title} onClick={() => onSelectTab?.(tab.id)} className={cn( - "flex items-center justify-center w-7 h-7 rounded-lg transition-all duration-200", + "relative flex items-center justify-center w-7 h-7 rounded-lg transition-all duration-200", activeTabId === tab.id ? "bg-background-tertiary/70 text-foreground" : "text-foreground-secondary hover:text-foreground hover:bg-background-tertiary/50", )} > + ))}
@@ -431,6 +441,8 @@ export function SidebarProjectsSection({ title={tab.title} isActive={isActive} isOpen={isOpen} + isRunning={tab.isRunning ?? false} + hasUnread={tab.hasUnread ?? false} onSelect={onSelectTab} onRename={onRenameChat} onArchive={onArchiveChat} diff --git a/ui/goose2/src/features/sidebar/ui/__tests__/SidebarChatRow.test.tsx b/ui/goose2/src/features/sidebar/ui/__tests__/SidebarChatRow.test.tsx index de68a9f2b6..4e801b104b 100644 --- a/ui/goose2/src/features/sidebar/ui/__tests__/SidebarChatRow.test.tsx +++ b/ui/goose2/src/features/sidebar/ui/__tests__/SidebarChatRow.test.tsx @@ -83,4 +83,32 @@ describe("SidebarChatRow", () => { expect(onRename).not.toHaveBeenCalled(); }); + + it("shows a spinner when the chat is active", () => { + render( + , + ); + + expect(screen.getByLabelText(/chat active/i)).toBeInTheDocument(); + }); + + it("shows an unread dot when the chat has unread output", () => { + render( + , + ); + + expect(screen.getByLabelText(/unread messages/i)).toBeInTheDocument(); + }); }); diff --git a/ui/goose2/src/features/tabs/types.ts b/ui/goose2/src/features/tabs/types.ts index e54e2bb7d6..519a11839c 100644 --- a/ui/goose2/src/features/tabs/types.ts +++ b/ui/goose2/src/features/tabs/types.ts @@ -4,4 +4,6 @@ export interface Tab { sessionId: string; agentId?: string; projectId?: string; + isRunning?: boolean; + hasUnread?: boolean; } diff --git a/ui/goose2/src/features/tabs/ui/TabBar.test.tsx b/ui/goose2/src/features/tabs/ui/TabBar.test.tsx new file mode 100644 index 0000000000..38766c194a --- /dev/null +++ b/ui/goose2/src/features/tabs/ui/TabBar.test.tsx @@ -0,0 +1,49 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { TabBar } from "./TabBar"; + +describe("TabBar", () => { + it("shows a spinner for running chats", () => { + render( + , + ); + + expect(screen.getByLabelText(/chat active/i)).toBeInTheDocument(); + }); + + it("shows an unread indicator for unread chats", () => { + render( + , + ); + + expect(screen.getByLabelText(/unread messages/i)).toBeInTheDocument(); + }); +}); diff --git a/ui/goose2/src/features/tabs/ui/TabBar.tsx b/ui/goose2/src/features/tabs/ui/TabBar.tsx index f77482edb0..5716b9a8ec 100644 --- a/ui/goose2/src/features/tabs/ui/TabBar.tsx +++ b/ui/goose2/src/features/tabs/ui/TabBar.tsx @@ -1,6 +1,7 @@ import { Home, Plus, X } from "lucide-react"; import { cn } from "@/shared/lib/cn"; import type { Tab } from "@/features/tabs/types"; +import { SessionActivityIndicator } from "@/shared/ui/SessionActivityIndicator"; interface TabBarProps { tabs: Tab[]; @@ -58,6 +59,10 @@ export function TabBar({ )} > {tab.title} + {/* biome-ignore lint/a11y/useKeyWithClickEvents: close is a secondary action inside an interactive tab, keyboard users close tabs via other means */} {/* biome-ignore lint/a11y/noStaticElementInteractions: cannot use button inside a tab element, span with click is intentional */} { + it("renders a brand-colored inline spinner for running sessions", () => { + render(); + + expect(screen.getByLabelText(/chat active/i)).toHaveClass("text-brand"); + }); + + it("renders a brand-colored inline dot for unread sessions", () => { + render(); + + expect(screen.getByLabelText(/unread messages/i)).toHaveClass("bg-brand"); + }); + + it("renders an overlay spinner variant for running sessions", () => { + const { container } = render( + , + ); + + expect(screen.getByLabelText(/chat active/i)).toBeInTheDocument(); + expect(container.querySelector(".text-brand")).toBeTruthy(); + }); + + it("renders nothing when the session is idle and read", () => { + const { container } = render(); + + expect(container).toBeEmptyDOMElement(); + }); +}); diff --git a/ui/goose2/src/shared/ui/SessionActivityIndicator.tsx b/ui/goose2/src/shared/ui/SessionActivityIndicator.tsx new file mode 100644 index 0000000000..a18a1b1094 --- /dev/null +++ b/ui/goose2/src/shared/ui/SessionActivityIndicator.tsx @@ -0,0 +1,69 @@ +import { Loader2 } from "lucide-react"; +import { cn } from "@/shared/lib/cn"; + +interface SessionActivityIndicatorProps { + isRunning?: boolean; + hasUnread?: boolean; + variant?: "inline" | "overlay"; + className?: string; +} + +export function SessionActivityIndicator({ + isRunning = false, + hasUnread = false, + variant = "inline", + className, +}: SessionActivityIndicatorProps) { + if (isRunning) { + if (variant === "overlay") { + return ( + + + ); + } + + return ( + + ); + } + + if (!hasUnread) { + return null; + } + + if (variant === "overlay") { + return ( + + ); + } + + return ( + + ); +}