track chat activity and unread state per session (#62)

* feat: isolate chat session runtime state

* fix: initialize per-session chat runtime
This commit is contained in:
Wes
2026-04-01 13:30:36 -07:00
committed by GitHub
parent 1af6e66c00
commit 64eaaa7b4a
21 changed files with 767 additions and 586 deletions
+18 -19
View File
@@ -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<Set<string>>(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<Tab[]>((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 (
<div className="flex h-screen w-screen flex-col overflow-hidden bg-background text-foreground">
{/* Tab bar — full width across the top */}
<TabBar
tabs={tabs}
activeTabId={activeTabId}
@@ -433,9 +438,7 @@ export function AppShell({ children }: { children?: React.ReactNode }) {
onHomeClick={() => handleNavigate("home")}
/>
{/* Main content area — sidebar + content as flex row */}
<div className="flex flex-1 min-h-0 overflow-hidden">
{/* Sidebar wrapper — padding creates the floating effect */}
<div
className="flex-shrink-0 h-full py-3 pl-3"
style={{
@@ -466,13 +469,11 @@ export function AppShell({ children }: { children?: React.ReactNode }) {
/>
</div>
{/* Content area */}
<main className="min-h-0 min-w-0 flex-1">
{children ?? renderContent()}
</main>
</div>
{/* Status bar — conditional with animation */}
<div
className={`overflow-hidden transition-all duration-300 ease-in-out ${
isHome ? "max-h-0 opacity-0" : "max-h-8 opacity-100"
@@ -485,10 +486,8 @@ export function AppShell({ children }: { children?: React.ReactNode }) {
/>
</div>
{/* Settings modal */}
{settingsOpen && <SettingsModal onClose={() => setSettingsOpen(false)} />}
{/* Create project dialog */}
<CreateProjectDialog
isOpen={createProjectOpen}
onClose={() => {
@@ -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<string, unknown> }) => void;
const listeners = new Map<string, EventCallback[]>();
function emit(eventName: string, payload: Record<string, unknown>) {
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> = {}): Message {
return {
id: "msg-1",
@@ -50,16 +45,13 @@ function makeStreamingMessage(overrides: Partial<Message> = {}): 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.");
}
});
});
@@ -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<void>;
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<void>;
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;
});
});
});
@@ -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<AcpTextPayload>("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<AcpDonePayload>("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<AcpToolCallPayload>("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<AcpToolTitlePayload>("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<AcpToolResultPayload>("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<AcpSessionInfoPayload>("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<AcpModelStatePayload>("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<AcpUsageUpdatePayload>("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]);
}
+28 -24
View File
@@ -20,15 +20,23 @@ export function useChat(
) {
const store = useChatStore();
const abortRef = useRef<AbortController | null>(null);
const activeStreamingPersonaIdRef = useRef<string | null>(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;
@@ -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"
);
}
@@ -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> = {}): Message {
return {
@@ -16,273 +14,118 @@ function makeMessage(overrides: Partial<Message> = {}): 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();
});
});
+151 -48
View File
@@ -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<string, Message[]>;
// Per-session runtime state
sessionStateById: Record<string, SessionChatRuntime>;
// 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<TokenState>) => void;
resetTokenState: () => void;
updateTokenState: (sessionId: string, state: Partial<TokenState>) => void;
resetTokenState: (sessionId: string) => void;
// Cleanup
cleanupSession: (sessionId: string) => void;
@@ -70,21 +78,20 @@ export type ChatStore = ChatStoreState & ChatStoreActions;
export const useChatStore = create<ChatStore>((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<ChatStore>((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<ChatStore>((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<ChatStore>((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<ChatStore>((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<ChatStore>((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,
};
}),
}));
+1 -5
View File
@@ -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<string | null>(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) {
@@ -20,6 +20,16 @@ const pillColors: Record<ToolCallStatus, string> = {
stopped: "bg-background-tertiary text-foreground-secondary border-border",
} as Record<string, string>;
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({
<button
type="button"
onClick={() => hasContent && setExpanded(!expanded)}
title={name}
className={cn(
"inline-flex items-center gap-1.5 px-2.5 py-1 rounded-md text-xs border transition-all duration-150",
"inline-flex max-w-full items-center justify-start gap-1.5 rounded-md border px-2.5 py-1 text-left text-xs transition-all duration-150",
hasContent && "cursor-pointer",
!hasContent && "cursor-default",
pillColors[status] ?? pillColors.pending,
)}
>
<Wrench className="w-3 h-3 shrink-0" />
<span className="text-xs font-medium">{name}</span>
<span className="max-w-[12rem] truncate text-left text-xs font-medium sm:max-w-[18rem] md:max-w-[22rem]">
{displayName}
</span>
<StatusIndicator status={status} />
{status === "executing" && elapsed >= 3 && (
<span className="text-[10px] tabular-nums text-foreground-tertiary">
@@ -133,4 +133,16 @@ describe("ToolCallCard", () => {
expect(screen.getByText("Error")).toBeInTheDocument();
expect(screen.getByText("something went wrong")).toBeInTheDocument();
});
it("truncates long tool names and preserves the full name in the tooltip", () => {
const longName =
"veryLongToolNameThatShouldNotWrapAcrossMultipleLinesInTheChatUI";
render(<ToolCallCard name={longName} arguments={{}} status="pending" />);
expect(
screen.getByText("veryLongToolNameThatShouldNotWrapAcrossMultiple…"),
).toBeInTheDocument();
expect(screen.getByRole("button")).toHaveAttribute("title", longName);
});
});
@@ -12,7 +12,9 @@ import { cn } from "@/shared/lib/cn";
import { GooseIcon } from "@/shared/ui/icons/GooseIcon";
import type { AppView } from "@/app/AppShell";
import type { ProjectInfo } from "@/features/projects/api/projects";
import { useChatStore } from "@/features/chat/stores/chatStore";
import { useChatSessionStore } from "@/features/chat/stores/chatSessionStore";
import { isSessionRunning } from "@/features/chat/lib/sessionActivity";
import { SidebarProjectsSection } from "./SidebarProjectsSection";
interface SidebarProps {
@@ -83,6 +85,7 @@ export function Sidebar({
});
// Read all sessions and open tab IDs from the store
const chatStore = useChatStore();
const { sessions, openTabIds } = useChatSessionStore();
const openTabIdSet = useMemo(() => new Set(openTabIds), [openTabIds]);
@@ -111,10 +114,13 @@ export function Sidebar({
projectId?: string;
isOpenTab: boolean;
updatedAt: string;
isRunning: boolean;
hasUnread: boolean;
};
const byProject: Record<string, SessionItem[]> = {};
const standalone: SessionItem[] = [];
for (const session of sessions) {
const runtime = chatStore.getSessionRuntime(session.id);
const item: SessionItem = {
id: session.id,
title: session.title,
@@ -122,6 +128,8 @@ export function Sidebar({
projectId: session.projectId ?? undefined,
isOpenTab: openTabIdSet.has(session.id),
updatedAt: session.updatedAt,
isRunning: isSessionRunning(runtime.chatState),
hasUnread: runtime.hasUnread,
};
if (session.projectId) {
if (!byProject[session.projectId]) byProject[session.projectId] = [];
@@ -137,7 +145,7 @@ export function Sidebar({
);
const limitedStandalone = standalone.slice(0, MAX_RECENTS);
return { byProject, standalone: limitedStandalone };
}, [sessions, openTabIdSet]);
}, [chatStore, sessions, openTabIdSet]);
// Auto-expand the project containing the active tab
useEffect(() => {
@@ -1,6 +1,7 @@
import { useEffect, useRef, useState } from "react";
import { MoreHorizontal, Pencil, Trash2 } from "lucide-react";
import { cn } from "@/shared/lib/cn";
import { SessionActivityIndicator } from "@/shared/ui/SessionActivityIndicator";
const OPEN_CHAT_ROW_CLASS = "text-foreground group-hover:text-foreground";
const INACTIVE_CHAT_ROW_CLASS = "text-foreground group-hover:text-foreground";
@@ -11,6 +12,8 @@ interface SidebarChatRowProps {
title: string;
isActive: boolean;
isOpen: boolean;
isRunning?: boolean;
hasUnread?: boolean;
className?: string;
onSelect?: (id: string) => void;
onRename?: (id: string, nextTitle: string) => void;
@@ -22,6 +25,8 @@ export function SidebarChatRow({
title,
isActive,
isOpen,
isRunning = false,
hasUnread = false,
className,
onSelect,
onRename,
@@ -132,6 +137,7 @@ export function SidebarChatRow({
)}
>
<span className="flex-1 min-w-0 truncate">{title}</span>
<SessionActivityIndicator isRunning={isRunning} hasUnread={hasUnread} />
</button>
<div ref={menuRef} className="relative shrink-0">
@@ -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",
)}
>
<MessageSquare className="w-4 h-4" />
<SessionActivityIndicator
isRunning={tab.isRunning}
hasUnread={tab.hasUnread}
variant="overlay"
/>
</button>
))}
</div>
@@ -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}
@@ -83,4 +83,32 @@ describe("SidebarChatRow", () => {
expect(onRename).not.toHaveBeenCalled();
});
it("shows a spinner when the chat is active", () => {
render(
<SidebarChatRow
id="session-1"
title="Busy Chat"
isActive={false}
isOpen
isRunning
/>,
);
expect(screen.getByLabelText(/chat active/i)).toBeInTheDocument();
});
it("shows an unread dot when the chat has unread output", () => {
render(
<SidebarChatRow
id="session-1"
title="Unread Chat"
isActive={false}
isOpen
hasUnread
/>,
);
expect(screen.getByLabelText(/unread messages/i)).toBeInTheDocument();
});
});
+2
View File
@@ -4,4 +4,6 @@ export interface Tab {
sessionId: string;
agentId?: string;
projectId?: string;
isRunning?: boolean;
hasUnread?: boolean;
}
@@ -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(
<TabBar
tabs={[
{
id: "session-1",
title: "Busy Chat",
sessionId: "session-1",
isRunning: true,
},
]}
activeTabId="session-1"
onTabSelect={vi.fn()}
onTabClose={vi.fn()}
onNewTab={vi.fn()}
onHomeClick={vi.fn()}
/>,
);
expect(screen.getByLabelText(/chat active/i)).toBeInTheDocument();
});
it("shows an unread indicator for unread chats", () => {
render(
<TabBar
tabs={[
{
id: "session-1",
title: "Unread Chat",
sessionId: "session-1",
hasUnread: true,
},
]}
activeTabId={null}
onTabSelect={vi.fn()}
onTabClose={vi.fn()}
onNewTab={vi.fn()}
onHomeClick={vi.fn()}
/>,
);
expect(screen.getByLabelText(/unread messages/i)).toBeInTheDocument();
});
});
@@ -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({
)}
>
<span className="truncate">{tab.title}</span>
<SessionActivityIndicator
isRunning={tab.isRunning}
hasUnread={tab.hasUnread}
/>
{/* 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 */}
<span
+20 -4
View File
@@ -31,6 +31,22 @@ export const INITIAL_TOKEN_STATE: TokenState = {
contextLimit: 0,
};
export interface SessionChatRuntime {
chatState: ChatState;
tokenState: TokenState;
streamingMessageId: string | null;
error: string | null;
hasUnread: boolean;
}
export const INITIAL_SESSION_CHAT_RUNTIME: SessionChatRuntime = {
chatState: "idle",
tokenState: INITIAL_TOKEN_STATE,
streamingMessageId: null,
error: null,
hasUnread: false,
};
// Session
export interface Session {
id: string;
@@ -98,8 +114,8 @@ export interface ChatContext {
sessionId: string;
agent: Agent;
messages: Message[];
chatState: ChatState;
tokenState: TokenState;
streamingMessageId: string | null;
error: string | null;
chatState: SessionChatRuntime["chatState"];
tokenState: SessionChatRuntime["tokenState"];
streamingMessageId: SessionChatRuntime["streamingMessageId"];
error: SessionChatRuntime["error"];
}
@@ -0,0 +1,32 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { SessionActivityIndicator } from "./SessionActivityIndicator";
describe("SessionActivityIndicator", () => {
it("renders a brand-colored inline spinner for running sessions", () => {
render(<SessionActivityIndicator isRunning />);
expect(screen.getByLabelText(/chat active/i)).toHaveClass("text-brand");
});
it("renders a brand-colored inline dot for unread sessions", () => {
render(<SessionActivityIndicator hasUnread />);
expect(screen.getByLabelText(/unread messages/i)).toHaveClass("bg-brand");
});
it("renders an overlay spinner variant for running sessions", () => {
const { container } = render(
<SessionActivityIndicator isRunning variant="overlay" />,
);
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(<SessionActivityIndicator />);
expect(container).toBeEmptyDOMElement();
});
});
@@ -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 (
<span
role="status"
aria-label="Chat active"
className={cn(
"absolute -right-1 -top-1 flex h-3.5 w-3.5 items-center justify-center rounded-full border border-background bg-background shadow-sm",
className,
)}
>
<Loader2
aria-hidden="true"
className="h-2.5 w-2.5 animate-spin text-brand"
/>
</span>
);
}
return (
<Loader2
role="status"
aria-label="Chat active"
className={cn("h-3 w-3 shrink-0 animate-spin text-brand", className)}
/>
);
}
if (!hasUnread) {
return null;
}
if (variant === "overlay") {
return (
<span
role="status"
aria-label="Unread messages"
className={cn(
"absolute -right-0.5 -top-0.5 h-2.5 w-2.5 shrink-0 rounded-full border border-background bg-brand",
className,
)}
/>
);
}
return (
<span
role="status"
aria-label="Unread messages"
className={cn("h-2 w-2 shrink-0 rounded-full bg-brand", className)}
/>
);
}