mirror of
https://github.com/aaif-goose/goose.git
synced 2026-07-03 14:10:03 +02:00
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:
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user