Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/calm-sessions-fold.md
Original file line number Diff line number Diff line change
@@ -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.
28 changes: 25 additions & 3 deletions packages/agent-client/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
<AgentChat
projectId="my-project"
activeSessionId={activeSessionId}
onActiveSessionChange={setActiveSessionId}
sessionListOpen={sessionListOpen}
onSessionListOpenChange={setSessionListOpen}
onTurnComplete={refreshPreview}
/>
```

The package intentionally does not write `localStorage`: account scoping,
cross-device persistence, and storage policy belong to the host application.

## Client config

Expand Down
2 changes: 2 additions & 0 deletions packages/agent-client/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
102 changes: 93 additions & 9 deletions packages/agent-client/src/react/AgentChat.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -26,41 +43,108 @@ 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,
renderEmpty,
noSelectionPlaceholder,
className,
}: AgentChatProps) {
const { classNames } = useAgentChatContext();
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
const [refreshTick, setRefreshTick] = useState(0);
const refreshSessions = useCallback(() => setRefreshTick((value) => value + 1), []);
const { classNames, labels } = useAgentChatContext();
const [uncontrolledActiveSessionId, setUncontrolledActiveSessionId] = useState<string | null>(
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 ? (
<button
type="button"
className={["agent-chat-session-toggle", classNames.sessionToggle].filter(Boolean).join(" ")}
onClick={() => setSessionListOpen(!sessionListOpen)}
aria-expanded={sessionListOpen}
aria-controls={sessionListId}
title={sessionListOpen ? labels.hideSessions : labels.showSessions}
>
<span className="agent-chat-session-toggle-action">
{sessionListOpen ? labels.hideSessions : labels.showSessions}
</span>
<span className="agent-chat-session-toggle-current">{activeSessionLabel}</span>
</button>
) : undefined;

const handleTurnComplete = useCallback(() => {
if (!hideSessionList) void sessions.refresh();
onTurnComplete?.();
}, [hideSessionList, sessions.refresh, onTurnComplete]);

return (
<div className={["agent-client-root", "agent-chat-layout", classNames.root, className].filter(Boolean).join(" ")}>
{!hideSessionList && (
{showSessions && (
<SessionList
id={sessionListId}
projectId={projectId}
activeSessionId={activeSessionId}
refreshTick={refreshTick}
controller={sessions}
onSelectSession={setActiveSessionId}
onDeleteSession={(id) => setActiveSessionId((current) => (current === id ? null : current))}
onDeleteSession={(id) => {
if (activeSessionId === id) setActiveSessionId(null);
}}
/>
)}
{activeSessionId ? (
<ChatPanel
projectId={projectId}
sessionId={activeSessionId}
onTurnComplete={refreshSessions}
onTurnComplete={handleTurnComplete}
headerStart={sessionListControl}
showModelControls={showModelControls}
showHeader={showHeader}
renderMessage={renderMessage}
renderEmpty={renderEmpty}
/>
) : (
<div className="agent-chat-no-selection">
{sessionListControl}
{noSelectionPlaceholder ?? (
<span className="agent-chat-no-selection-text">Select or create a session</span>
)}
Expand Down
26 changes: 16 additions & 10 deletions packages/agent-client/src/react/ChatPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand All @@ -72,6 +74,7 @@ export function ChatPanel({
renderMessage,
renderEmpty,
renderComposerActions,
headerStart,
className,
}: ChatPanelProps) {
const { classNames, labels, costRates } = useAgentChatContext();
Expand Down Expand Up @@ -162,17 +165,20 @@ export function ChatPanel({
<div className={["agent-chat-chat-panel", classNames.chatPanel, className].filter(Boolean).join(" ")}>
{showHeader && (
<div className="agent-chat-header">
<div className="agent-chat-header-status">
<span className="agent-chat-header-title">{labels.agentName}</span>
<span className={isRunning ? "agent-chat-status agent-chat-status-active" : "agent-chat-status"}>
{!state.connected ? "connecting" : isRunning ? state.status : "idle"}
</span>
{extensionStatus && <span className="agent-chat-ext-status">{extensionStatus}</span>}
{settingsError && (
<span className="agent-chat-settings-error" title={settingsError}>
model settings unavailable
<div className="agent-chat-header-start">
{headerStart}
<div className="agent-chat-header-status">
<span className="agent-chat-header-title">{labels.agentName}</span>
<span className={isRunning ? "agent-chat-status agent-chat-status-active" : "agent-chat-status"}>
{!state.connected ? "connecting" : isRunning ? state.status : "idle"}
</span>
)}
{extensionStatus && <span className="agent-chat-ext-status">{extensionStatus}</span>}
{settingsError && (
<span className="agent-chat-settings-error" title={settingsError}>
model settings unavailable
</span>
)}
</div>
</div>
{showModelControls && (
// biome-ignore lint/a11y/useSemanticElements: a fieldset would change the rendered DOM/styling contract; role="group" is valid ARIA here.
Expand Down
84 changes: 28 additions & 56 deletions packages/agent-client/src/react/SessionList.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -13,79 +12,52 @@ 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 ? (
<SessionListView {...props} controller={controller} />
) : (
<ManagedSessionList {...props} refreshTick={refreshTick} />
);
}

/** Sidebar listing a project's sessions with create + delete actions. */
export function SessionList({
projectId,
function ManagedSessionList(props: Omit<SessionListProps, "controller">) {
const controller = useAgentSessions(props.projectId, { refreshTick: props.refreshTick });
return <SessionListView {...props} controller={controller} />;
}

function SessionListView({
activeSessionId,
refreshTick = 0,
onSelectSession,
onDeleteSession,
controller,
id,
className,
}: SessionListProps) {
const { store, client, classNames, labels } = useAgentChatContext();
const [sessions, setSessions] = useState<AgentSessionInfo[]>([]);
const [creating, setCreating] = useState(false);
const [deletingId, setDeletingId] = useState<string | null>(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<SessionListProps, "refreshTick"> & { 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) => {
// Deletion is irreversible (transcripts are not recoverable), so confirm
// 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 (
<div className={["agent-chat-session-list", classNames.sessionList, className].filter(Boolean).join(" ")}>
<div id={id} className={["agent-chat-session-list", classNames.sessionList, className].filter(Boolean).join(" ")}>
<div className="agent-chat-session-header">
<span className="agent-chat-session-title">{labels.sessionsTitle}</span>
<button
Expand Down Expand Up @@ -115,9 +87,9 @@ export function SessionList({
type="button"
className="agent-chat-session-item-select"
onClick={() => onSelectSession(session.id)}
title={labelFor(session)}
title={sessionLabel(session)}
>
<span className="agent-chat-session-item-title">{labelFor(session)}</span>
<span className="agent-chat-session-item-title">{sessionLabel(session)}</span>
<span className="agent-chat-session-item-meta">
{session.id.slice(0, 8)} · {session.messageCount} msg
</span>
Expand Down
Loading
Loading