From 7ba4e7884d52de0578b20311fba7d41237cfc545 Mon Sep 17 00:00:00 2001 From: Andrey Gruzdev Date: Thu, 20 Aug 2026 19:16:12 +0200 Subject: [PATCH] feat(client): add controllable session workspace --- .changeset/calm-sessions-fold.md | 5 + packages/agent-client/README.md | 28 +++- packages/agent-client/src/index.ts | 2 + packages/agent-client/src/react/AgentChat.tsx | 102 +++++++++++++-- packages/agent-client/src/react/ChatPanel.tsx | 26 ++-- .../agent-client/src/react/SessionList.tsx | 84 ++++-------- .../src/react/__tests__/AgentChat.test.tsx | 120 ++++++++++++++++++ packages/agent-client/src/react/context.tsx | 9 ++ .../src/react/useAgentSessions.ts | 98 ++++++++++++++ packages/agent-client/src/styles.css | 53 +++++++- 10 files changed, 448 insertions(+), 79 deletions(-) create mode 100644 .changeset/calm-sessions-fold.md create mode 100644 packages/agent-client/src/react/__tests__/AgentChat.test.tsx create mode 100644 packages/agent-client/src/react/useAgentSessions.ts diff --git a/.changeset/calm-sessions-fold.md b/.changeset/calm-sessions-fold.md new file mode 100644 index 0000000..500e858 --- /dev/null +++ b/.changeset/calm-sessions-fold.md @@ -0,0 +1,5 @@ +--- +"@appx-org/agent-client": minor +--- + +Add reusable session loading controls plus controlled or uncontrolled active-session and foldable-session-list state to AgentChat. diff --git a/packages/agent-client/README.md b/packages/agent-client/README.md index f4cd9d6..0e3fac3 100644 --- a/packages/agent-client/README.md +++ b/packages/agent-client/README.md @@ -87,7 +87,7 @@ Two layers: agent-server's `openapi.json`, never hand-written — and are re-exported via `core/types.ts` alongside the UI-derived types. - **`react/`** — `AgentChatProvider` (DI for client + store + theme), - `useAgentSession` hook, and components: `AgentChat`, `ChatPanel`, + `useAgentSession` / `useAgentSessions` hooks, and components: `AgentChat`, `ChatPanel`, `SessionList`, `ToolCallCard`, `ExtensionRequestPanel`, `Markdown`. ## Regenerating the agent-server types @@ -121,9 +121,31 @@ stable, human-readable names. 2. **`classNames` / `labels`** — pass per-slot class names and string overrides to `AgentChatProvider`. 3. **Render slots** — `ChatPanel` accepts `renderMessage`, `renderEmpty`, - `showHeader`, `showModelControls`. + `headerStart`, `showHeader`, `showModelControls`. 4. **Composition** — for fully bespoke layouts, drop `AgentChat` and compose - `SessionList` + `ChatPanel`, or build directly on `useAgentSession`. + `SessionList` + `ChatPanel`, or build directly on `useAgentSession` and + `useAgentSessions`. + +### Session workspace state + +`AgentChat` keeps selection and session-list visibility uncontrolled by default. +The list starts open and can be folded from the chat header. Hosts that need to +remember workspace state can control it and persist it in their own preferred +store: + +```tsx + +``` + +The package intentionally does not write `localStorage`: account scoping, +cross-device persistence, and storage policy belong to the host application. ## Client config diff --git a/packages/agent-client/src/index.ts b/packages/agent-client/src/index.ts index 60a8552..35a1896 100644 --- a/packages/agent-client/src/index.ts +++ b/packages/agent-client/src/index.ts @@ -92,3 +92,5 @@ export type { UsageBarLabels, UsageBarProps } from "./react/UsageBar.js"; export { UsageBar } from "./react/UsageBar.js"; export type { UseAgentSessionResult } from "./react/useAgentSession.js"; export { useAgentSession } from "./react/useAgentSession.js"; +export type { AgentSessionsController, UseAgentSessionsOptions } from "./react/useAgentSessions.js"; +export { sessionLabel, useAgentSessions } from "./react/useAgentSessions.js"; diff --git a/packages/agent-client/src/react/AgentChat.tsx b/packages/agent-client/src/react/AgentChat.tsx index 8016c28..d3d2fc5 100644 --- a/packages/agent-client/src/react/AgentChat.tsx +++ b/packages/agent-client/src/react/AgentChat.tsx @@ -1,14 +1,31 @@ -import { type ReactNode, useCallback, useState } from "react"; +import { type ReactNode, useCallback, useId, useState } from "react"; import type { UiMessage } from "../core/types.js"; import { ChatPanel } from "./ChatPanel.js"; import { useAgentChatContext } from "./context.js"; import { SessionList } from "./SessionList.js"; +import { sessionLabel, useAgentSessions } from "./useAgentSessions.js"; export interface AgentChatProps { /** The agent-server project to scope sessions to. */ projectId: string; /** Hide the session sidebar (single-session embedding). Default: false. */ hideSessionList?: boolean; + /** Controlled active session id. Use `null` for no selection. */ + activeSessionId?: string | null; + /** Initial active session id when selection is uncontrolled. */ + defaultActiveSessionId?: string | null; + /** Called whenever the active session changes. */ + onActiveSessionChange?: (id: string | null) => void; + /** Allow the built-in session list to be folded. Default: true. */ + collapsibleSessionList?: boolean; + /** Controlled session-list visibility. */ + sessionListOpen?: boolean; + /** Initial visibility when session-list state is uncontrolled. Default: true. */ + defaultSessionListOpen?: boolean; + /** Called whenever session-list visibility changes. */ + onSessionListOpenChange?: (open: boolean) => void; + /** Called once each time a streaming turn settles back to idle. */ + onTurnComplete?: () => void; showModelControls?: boolean; showHeader?: boolean; renderMessage?: (message: UiMessage, index: number, defaultNode: ReactNode) => ReactNode; @@ -26,6 +43,14 @@ export interface AgentChatProps { export function AgentChat({ projectId, hideSessionList = false, + activeSessionId: controlledActiveSessionId, + defaultActiveSessionId = null, + onActiveSessionChange, + collapsibleSessionList = true, + sessionListOpen: controlledSessionListOpen, + defaultSessionListOpen = true, + onSessionListOpenChange, + onTurnComplete, showModelControls, showHeader, renderMessage, @@ -33,27 +58,85 @@ export function AgentChat({ noSelectionPlaceholder, className, }: AgentChatProps) { - const { classNames } = useAgentChatContext(); - const [activeSessionId, setActiveSessionId] = useState(null); - const [refreshTick, setRefreshTick] = useState(0); - const refreshSessions = useCallback(() => setRefreshTick((value) => value + 1), []); + const { classNames, labels } = useAgentChatContext(); + const [uncontrolledActiveSessionId, setUncontrolledActiveSessionId] = useState( + defaultActiveSessionId, + ); + const [uncontrolledSessionListOpen, setUncontrolledSessionListOpen] = useState(defaultSessionListOpen); + const activeSessionId = + controlledActiveSessionId !== undefined ? controlledActiveSessionId : uncontrolledActiveSessionId; + const sessionListOpen = + controlledSessionListOpen !== undefined ? controlledSessionListOpen : uncontrolledSessionListOpen; + const sessions = useAgentSessions(projectId, { enabled: !hideSessionList }); + const sessionListId = useId(); + const activeSession = sessions.sessions.find((session) => session.id === activeSessionId); + const activeSessionLabel = activeSession + ? sessionLabel(activeSession) + : activeSessionId + ? labels.selectedSession + : labels.noSession; + const canUseBuiltInCollapse = collapsibleSessionList && showHeader !== false; + const hostControlsVisibility = controlledSessionListOpen !== undefined; + const showSessions = !hideSessionList && (!(canUseBuiltInCollapse || hostControlsVisibility) || sessionListOpen); + + const setActiveSessionId = useCallback( + (next: string | null) => { + if (next === activeSessionId) return; + if (controlledActiveSessionId === undefined) setUncontrolledActiveSessionId(next); + onActiveSessionChange?.(next); + }, + [activeSessionId, controlledActiveSessionId, onActiveSessionChange], + ); + + const setSessionListOpen = useCallback( + (next: boolean) => { + if (controlledSessionListOpen === undefined) setUncontrolledSessionListOpen(next); + onSessionListOpenChange?.(next); + }, + [controlledSessionListOpen, onSessionListOpenChange], + ); + + const sessionListControl = canUseBuiltInCollapse ? ( + + ) : undefined; + + const handleTurnComplete = useCallback(() => { + if (!hideSessionList) void sessions.refresh(); + onTurnComplete?.(); + }, [hideSessionList, sessions.refresh, onTurnComplete]); return (
- {!hideSessionList && ( + {showSessions && ( setActiveSessionId((current) => (current === id ? null : current))} + onDeleteSession={(id) => { + if (activeSessionId === id) setActiveSessionId(null); + }} /> )} {activeSessionId ? ( ) : (
+ {sessionListControl} {noSelectionPlaceholder ?? ( Select or create a session )} diff --git a/packages/agent-client/src/react/ChatPanel.tsx b/packages/agent-client/src/react/ChatPanel.tsx index fda8348..ca26579 100644 --- a/packages/agent-client/src/react/ChatPanel.tsx +++ b/packages/agent-client/src/react/ChatPanel.tsx @@ -54,6 +54,8 @@ export interface ChatPanelProps { renderEmpty?: () => ReactNode; /** Extra controls rendered in the composer, just before the Send/Stop button. */ renderComposerActions?: () => ReactNode; + /** Content rendered before the built-in status block in the header. */ + headerStart?: ReactNode; className?: string; } @@ -72,6 +74,7 @@ export function ChatPanel({ renderMessage, renderEmpty, renderComposerActions, + headerStart, className, }: ChatPanelProps) { const { classNames, labels, costRates } = useAgentChatContext(); @@ -162,17 +165,20 @@ export function ChatPanel({
{showHeader && (
-
- {labels.agentName} - - {!state.connected ? "connecting" : isRunning ? state.status : "idle"} - - {extensionStatus && {extensionStatus}} - {settingsError && ( - - model settings unavailable +
+ {headerStart} +
+ {labels.agentName} + + {!state.connected ? "connecting" : isRunning ? state.status : "idle"} - )} + {extensionStatus && {extensionStatus}} + {settingsError && ( + + model settings unavailable + + )} +
{showModelControls && ( // biome-ignore lint/a11y/useSemanticElements: a fieldset would change the rendered DOM/styling contract; role="group" is valid ARIA here. diff --git a/packages/agent-client/src/react/SessionList.tsx b/packages/agent-client/src/react/SessionList.tsx index 46031da..6e971b6 100644 --- a/packages/agent-client/src/react/SessionList.tsx +++ b/packages/agent-client/src/react/SessionList.tsx @@ -1,6 +1,5 @@ -import { useCallback, useEffect, useState } from "react"; -import type { AgentSessionInfo } from "../core/types.js"; import { useAgentChatContext } from "./context.js"; +import { type AgentSessionsController, sessionLabel, useAgentSessions } from "./useAgentSessions.js"; export interface SessionListProps { projectId: string; @@ -13,55 +12,40 @@ export interface SessionListProps { * active session or switch to another one when the deleted session was open. */ onDeleteSession?: (id: string) => void; + /** Reuse a controller owned by a parent layout to avoid duplicate requests. */ + controller?: AgentSessionsController; + id?: string; className?: string; } -function labelFor(session: AgentSessionInfo): string { - return session.firstMessage?.trim() || "Untitled"; +/** Sidebar listing a project's sessions with create + delete actions. */ +export function SessionList({ controller, refreshTick, ...props }: SessionListProps) { + return controller ? ( + + ) : ( + + ); } -/** Sidebar listing a project's sessions with create + delete actions. */ -export function SessionList({ - projectId, +function ManagedSessionList(props: Omit) { + const controller = useAgentSessions(props.projectId, { refreshTick: props.refreshTick }); + return ; +} + +function SessionListView({ activeSessionId, - refreshTick = 0, onSelectSession, onDeleteSession, + controller, + id, className, -}: SessionListProps) { - const { store, client, classNames, labels } = useAgentChatContext(); - const [sessions, setSessions] = useState([]); - const [creating, setCreating] = useState(false); - const [deletingId, setDeletingId] = useState(null); - const [error, setError] = useState(""); - - const fetchSessions = useCallback(async () => { - try { - const res = await client.listSessions(projectId); - setSessions(res.sessions); - setError(""); - } catch (err) { - setError(err instanceof Error ? err.message : "Failed to load sessions"); - } - }, [client, projectId]); - - // biome-ignore lint/correctness/useExhaustiveDependencies(refreshTick): `refreshTick` is a caller-owned counter prop whose only job is to force a refetch. - useEffect(() => { - void fetchSessions(); - }, [fetchSessions, refreshTick]); +}: Omit & { controller: AgentSessionsController }) { + const { classNames, labels } = useAgentChatContext(); + const { sessions, creating, deletingId, error, createSession, deleteSession } = controller; const handleCreate = async () => { - setCreating(true); - setError(""); - try { - const session = await client.createSession(projectId); - await fetchSessions(); - onSelectSession(session.id); - } catch (err) { - setError(err instanceof Error ? err.message : "Failed to create session"); - } finally { - setCreating(false); - } + const sessionId = await createSession(); + if (sessionId) onSelectSession(sessionId); }; const handleDelete = async (sessionId: string) => { @@ -69,23 +53,11 @@ export function SessionList({ // before the destructive call. `window.confirm` keeps the SDK dependency-free; // hosts wanting a custom dialog can build their own list against the client. if (typeof window !== "undefined" && !window.confirm(labels.confirmDeleteSession)) return; - setDeletingId(sessionId); - setError(""); - try { - // Go through the store so the live SSE stream + cached state are torn down, - // not just the server-side record. - await store.deleteSession(projectId, sessionId); - await fetchSessions(); - onDeleteSession?.(sessionId); - } catch (err) { - setError(err instanceof Error ? err.message : "Failed to delete session"); - } finally { - setDeletingId(null); - } + if (await deleteSession(sessionId)) onDeleteSession?.(sessionId); }; return ( -
+
{labels.sessionsTitle} +
+ ), +})); + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +function clientWithSessions() { + const client = new AgentClient({ baseUrl: "http://localhost" }); + const listSessions = vi.spyOn(client, "listSessions").mockResolvedValue({ + sessions: [ + { + id: "session-1", + createdAt: "2026-08-20T12:00:00.000Z", + firstMessage: "Build a landing page", + messageCount: 4, + }, + ], + }); + return { client, listSessions }; +} + +describe("AgentChat session workspace", () => { + it("folds sessions without losing the selected session label or duplicating loads", async () => { + const { client, listSessions } = clientWithSessions(); + render( + + + , + ); + + await screen.findByText("Build a landing page"); + expect(document.querySelector(".agent-chat-session-list")).toBeNull(); + expect(listSessions).toHaveBeenCalledTimes(1); + + fireEvent.click(screen.getByTitle("Show sessions")); + expect(document.querySelector(".agent-chat-session-list")).toBeTruthy(); + expect(screen.getByTitle("Hide sessions")).toBeTruthy(); + }); + + it("reports controlled visibility changes without mutating the controlled value", async () => { + const { client } = clientWithSessions(); + const onSessionListOpenChange = vi.fn(); + render( + + + , + ); + + await screen.findByText("Build a landing page"); + fireEvent.click(screen.getByTitle("Show sessions")); + expect(onSessionListOpenChange).toHaveBeenCalledWith(true); + expect(document.querySelector(".agent-chat-session-list")).toBeNull(); + }); + + it("keeps controlled null distinct from the uncontrolled default", async () => { + const { client } = clientWithSessions(); + render( + + + , + ); + + await waitFor(() => expect(screen.getByText("Build a landing page")).toBeTruthy()); + expect(screen.queryByTestId("chat-panel")).toBeNull(); + expect(screen.getByText("Select or create a session")).toBeTruthy(); + }); + + it("reports selection and turn completion to the host", async () => { + const { client, listSessions } = clientWithSessions(); + const onActiveSessionChange = vi.fn(); + const onTurnComplete = vi.fn(); + render( + + + , + ); + + fireEvent.click(await screen.findByTitle("Build a landing page")); + expect(onActiveSessionChange).toHaveBeenCalledWith("session-1"); + fireEvent.click(screen.getByText("complete turn")); + expect(onTurnComplete).toHaveBeenCalledTimes(1); + await waitFor(() => expect(listSessions).toHaveBeenCalledTimes(2)); + }); + + it("does not load sessions when the list is disabled", async () => { + const { client, listSessions } = clientWithSessions(); + render( + + + , + ); + + await waitFor(() => expect(screen.getByText("Select or create a session")).toBeTruthy()); + expect(listSessions).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/agent-client/src/react/context.tsx b/packages/agent-client/src/react/context.tsx index c81866a..d1ae094 100644 --- a/packages/agent-client/src/react/context.tsx +++ b/packages/agent-client/src/react/context.tsx @@ -41,6 +41,7 @@ export function resolveCostRates( export interface AgentChatClassNames { root?: string; sessionList?: string; + sessionToggle?: string; chatPanel?: string; message?: string; userMessage?: string; @@ -63,6 +64,10 @@ export interface AgentChatLabels { stopButton?: string; inputPlaceholder?: string; workingPlaceholder?: string; + showSessions?: string; + hideSessions?: string; + noSession?: string; + selectedSession?: string; usageCost?: string; usageCache?: string; usageContext?: string; @@ -81,6 +86,10 @@ const defaultLabels: Required = { stopButton: "Stop", inputPlaceholder: "Send a message...", workingPlaceholder: "Agent is working...", + showSessions: "Show sessions", + hideSessions: "Hide sessions", + noSession: "No session", + selectedSession: "Selected session", usageCost: "cost", usageCache: "cache", usageContext: "ctx", diff --git a/packages/agent-client/src/react/useAgentSessions.ts b/packages/agent-client/src/react/useAgentSessions.ts new file mode 100644 index 0000000..24680e6 --- /dev/null +++ b/packages/agent-client/src/react/useAgentSessions.ts @@ -0,0 +1,98 @@ +import { useCallback, useEffect, useState } from "react"; +import type { AgentSessionInfo } from "../core/types.js"; +import { useAgentChatContext } from "./context.js"; + +export interface AgentSessionsController { + sessions: AgentSessionInfo[]; + loading: boolean; + creating: boolean; + deletingId: string | null; + error: string; + refresh: () => Promise; + createSession: () => Promise; + deleteSession: (sessionId: string) => Promise; +} + +export function sessionLabel(session: AgentSessionInfo): string { + return session.firstMessage?.trim() || "Untitled"; +} + +export interface UseAgentSessionsOptions { + /** Bump to force a reload (for example after an external mutation). */ + refreshTick?: number; + /** Disable all loading while the surrounding UI does not need session data. */ + enabled?: boolean; +} + +/** Load and mutate a project's sessions without imposing any layout. */ +export function useAgentSessions( + projectId: string, + { refreshTick = 0, enabled = true }: UseAgentSessionsOptions = {}, +): AgentSessionsController { + const { store, client } = useAgentChatContext(); + const [sessions, setSessions] = useState([]); + const [loading, setLoading] = useState(true); + const [creating, setCreating] = useState(false); + const [deletingId, setDeletingId] = useState(null); + const [error, setError] = useState(""); + + const refresh = useCallback(async () => { + if (!enabled) return; + setLoading(true); + try { + const response = await client.listSessions(projectId); + setSessions(response.sessions); + setError(""); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to load sessions"); + } finally { + setLoading(false); + } + }, [client, projectId, enabled]); + + // biome-ignore lint/correctness/useExhaustiveDependencies(refreshTick): caller-owned counter intentionally forces a refetch. + useEffect(() => { + if (!enabled) { + setLoading(false); + return; + } + void refresh(); + }, [enabled, refresh, refreshTick]); + + const createSession = useCallback(async () => { + setCreating(true); + setError(""); + try { + const session = await client.createSession(projectId); + await refresh(); + return session.id; + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to create session"); + return null; + } finally { + setCreating(false); + } + }, [client, projectId, refresh]); + + const deleteSession = useCallback( + async (sessionId: string) => { + setDeletingId(sessionId); + setError(""); + try { + // The store owns live SSE and cached state, so deletion must pass + // through it rather than only removing the server-side transcript. + await store.deleteSession(projectId, sessionId); + await refresh(); + return true; + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to delete session"); + return false; + } finally { + setDeletingId(null); + } + }, + [store, projectId, refresh], + ); + + return { sessions, loading, creating, deletingId, error, refresh, createSession, deleteSession }; +} diff --git a/packages/agent-client/src/styles.css b/packages/agent-client/src/styles.css index e336a8c..1dc31e6 100644 --- a/packages/agent-client/src/styles.css +++ b/packages/agent-client/src/styles.css @@ -59,8 +59,10 @@ .agent-chat-no-selection { flex: 1; display: flex; + flex-direction: column; align-items: center; justify-content: center; + gap: 12px; } .agent-chat-no-selection-text { @@ -218,7 +220,7 @@ .agent-chat-header { display: grid; - grid-template-columns: minmax(116px, 170px) minmax(0, 1fr); + grid-template-columns: minmax(170px, 220px) minmax(0, 1fr); align-items: end; gap: 16px; padding: 10px 20px; @@ -232,6 +234,55 @@ min-width: 0; } +.agent-chat-header-start { + display: flex; + align-items: center; + gap: 12px; + min-width: 0; +} + +.agent-chat-session-toggle { + display: grid; + gap: 2px; + min-width: 0; + max-width: 112px; + padding: 5px 8px; + color: var(--ac-text); + background: var(--ac-bg); + border: 1px solid var(--ac-border); + border-radius: var(--ac-radius); + cursor: pointer; + text-align: left; +} + +.agent-chat-session-toggle:hover { + background: var(--ac-surface-hover); +} + +.agent-chat-session-toggle:focus-visible { + outline: 2px solid var(--ac-accent); + outline-offset: 2px; +} + +.agent-chat-session-toggle-action, +.agent-chat-session-toggle-current { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.agent-chat-session-toggle-action { + color: var(--ac-muted); + font-family: var(--ac-font-mono); + font-size: 9px; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.agent-chat-session-toggle-current { + font-size: 11px; +} + .agent-chat-header-title { font-family: var(--ac-font-mono); font-size: 10px;