From f94d006aca508db17e26252cf695c91b196138b1 Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Tue, 8 Sep 2026 15:48:26 +0200 Subject: [PATCH 1/5] feat(appkit-ui): add initialThreadId seam to useAgentChat Additive, non-breaking option to resume an existing thread: seeds threadId so the first send() continues that thread instead of creating a new one; changing it re-seeds (switch conversations) without clobbering a server-assigned id. This is the internal seam useAgentThread uses to resume persisted threads. Signed-off-by: MarioCadenas --- .../hooks/__tests__/use-agent-chat.test.ts | 43 +++++++++++++++++++ .../src/react/hooks/use-agent-chat.ts | 25 ++++++++++- 2 files changed, 66 insertions(+), 2 deletions(-) diff --git a/packages/appkit-ui/src/react/hooks/__tests__/use-agent-chat.test.ts b/packages/appkit-ui/src/react/hooks/__tests__/use-agent-chat.test.ts index b6bdca773..1abbbe3bf 100644 --- a/packages/appkit-ui/src/react/hooks/__tests__/use-agent-chat.test.ts +++ b/packages/appkit-ui/src/react/hooks/__tests__/use-agent-chat.test.ts @@ -250,6 +250,49 @@ describe("useAgentChat", () => { }); }); + test("initialThreadId seeds threadId and is forwarded on the first send()", async () => { + const { result } = renderHook(() => + useAgentChat({ agent: "helper", initialThreadId: "t-resumed" }), + ); + + // Seeded synchronously — no send/metadata needed. + expect(result.current.threadId).toBe("t-resumed"); + + act(() => { + void result.current.send("continue please"); + }); + await waitFor(() => expect(mockConnectSSE).toHaveBeenCalled()); + + // The very first turn continues the resumed thread instead of creating one. + expect(capturedCallbacks.payload).toEqual({ + message: "continue please", + agent: "helper", + threadId: "t-resumed", + }); + }); + + test("changing initialThreadId re-seeds the thread (switch conversations)", async () => { + const { result, rerender } = renderHook( + ({ id }: { id?: string }) => + useAgentChat({ agent: "helper", initialThreadId: id }), + { initialProps: { id: "t-1" } }, + ); + expect(result.current.threadId).toBe("t-1"); + + rerender({ id: "t-2" }); + expect(result.current.threadId).toBe("t-2"); + + act(() => { + void result.current.send("hi"); + }); + await waitFor(() => expect(mockConnectSSE).toHaveBeenCalled()); + expect(capturedCallbacks.payload).toEqual({ + message: "hi", + agent: "helper", + threadId: "t-2", + }); + }); + test("onEvent is invoked for every parsed event", async () => { const onEvent = vi.fn(); const { result } = renderHook(() => diff --git a/packages/appkit-ui/src/react/hooks/use-agent-chat.ts b/packages/appkit-ui/src/react/hooks/use-agent-chat.ts index be9fa0bdb..aec24ae0a 100644 --- a/packages/appkit-ui/src/react/hooks/use-agent-chat.ts +++ b/packages/appkit-ui/src/react/hooks/use-agent-chat.ts @@ -53,6 +53,14 @@ export interface UseAgentChatOptions { * server mounts under a non-default base path or when proxying. */ endpoint?: string; + /** + * Resume an existing thread: seeds `threadId` so the first `send()` + * continues that thread instead of creating a new one. Changing it + * re-seeds (switch conversations). Without it, a thread id is only + * assigned by the server on the first turn. Used by `useAgentThread` + * to resume persisted threads; also handy on its own. + */ + initialThreadId?: string; /** * Called for every parsed SSE event before any state update. Use this * to drive tool-call rows, approval cards, inspectors, or anything @@ -164,19 +172,22 @@ function resolveSkill( export function useAgentChat({ agent, endpoint = "/api/agents/chat", + initialThreadId, onEvent, skills, }: UseAgentChatOptions): UseAgentChatResult { const [content, setContent] = useState(""); const [events, setEvents] = useState([]); - const [threadId, setThreadId] = useState(null); + const [threadId, setThreadId] = useState( + initialThreadId ?? null, + ); const [isStreaming, setIsStreaming] = useState(false); const [error, setError] = useState(null); // Refs avoid the standard "stale closure" problem with `send` and // `onEvent`: `send` is a stable callback that reads the latest // threadId/onEvent without re-mounting connectSSE on every render. - const threadIdRef = useRef(null); + const threadIdRef = useRef(initialThreadId ?? null); const contentRef = useRef(""); const onEventRef = useRef(onEvent); onEventRef.current = onEvent; @@ -293,6 +304,16 @@ export function useAgentChat({ [agent, endpoint], ); + // Re-seed the thread id when the caller switches to a different existing + // thread (e.g. useAgentThread resuming a persisted one), so the next send() + // continues it. A server-assigned id (from metadata) is never clobbered: + // that path leaves `initialThreadId` unchanged, so this effect doesn't fire. + useEffect(() => { + const next = initialThreadId ?? null; + threadIdRef.current = next; + setThreadId(next); + }, [initialThreadId]); + // Abort any in-flight stream when the component unmounts. useEffect(() => { return () => { From da9d2f87cf063df70baca608739f2dbadbdd2027 Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Tue, 8 Sep 2026 16:00:43 +0200 Subject: [PATCH 2/5] feat(appkit-ui): useAgentThreads + useAgentThread hooks (beta) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sibling hooks for agent chat history, exported from @databricks/appkit-ui/react/beta: - useAgentThreads: lists thread summaries (GET {basePath}/threads), with optimistic deleteThread/renameThread; plain-fetch, revives Dates. Owns the list, not the active conversation. - useAgentThread(threadId?): owns one conversation's transcript — loads history (GET /threads/:id), streams turns via useAgentChat (using its initialThreadId seam to resume), commits each completed turn, surfaces the server-assigned id on a new thread, optional URL persistence (default off). Signed-off-by: MarioCadenas --- packages/appkit-ui/src/react/beta.ts | 15 ++ .../hooks/__tests__/use-agent-thread.test.ts | 166 +++++++++++++ .../hooks/__tests__/use-agent-threads.test.ts | 154 ++++++++++++ .../src/react/hooks/use-agent-thread.ts | 224 ++++++++++++++++++ .../src/react/hooks/use-agent-threads.ts | 152 ++++++++++++ 5 files changed, 711 insertions(+) create mode 100644 packages/appkit-ui/src/react/hooks/__tests__/use-agent-thread.test.ts create mode 100644 packages/appkit-ui/src/react/hooks/__tests__/use-agent-threads.test.ts create mode 100644 packages/appkit-ui/src/react/hooks/use-agent-thread.ts create mode 100644 packages/appkit-ui/src/react/hooks/use-agent-threads.ts diff --git a/packages/appkit-ui/src/react/beta.ts b/packages/appkit-ui/src/react/beta.ts index 992a79635..dea67d837 100644 --- a/packages/appkit-ui/src/react/beta.ts +++ b/packages/appkit-ui/src/react/beta.ts @@ -1,6 +1,21 @@ // Beta React components -- APIs may change between minor releases. // Import from '@databricks/appkit-ui/react' once graduated to stable. +// Agent thread history — hooks + components for a persistent chat sidebar. +// Tracks the `agents` plugin (beta) and its thread endpoints. +export type { ThreadSummary } from "shared"; +export { + type UseAgentThreadsOptions, + type UseAgentThreadsResult, + useAgentThreads, +} from "./hooks/use-agent-threads"; +export { + type AgentThreadMessage, + type UseAgentThreadOptions, + type UseAgentThreadResult, + useAgentThread, +} from "./hooks/use-agent-thread"; + // AI Search hook + types. Tracks the `aiSearch` plugin, which ships at beta // from '@databricks/appkit/beta'. export type { diff --git a/packages/appkit-ui/src/react/hooks/__tests__/use-agent-thread.test.ts b/packages/appkit-ui/src/react/hooks/__tests__/use-agent-thread.test.ts new file mode 100644 index 000000000..3618b6383 --- /dev/null +++ b/packages/appkit-ui/src/react/hooks/__tests__/use-agent-thread.test.ts @@ -0,0 +1,166 @@ +import { act, renderHook, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +// --- connectSSE harness (drives useAgentChat's streaming) --- +let capturedOnMessage: ((msg: { data: string }) => Promise) | undefined; +let resolveStream: (() => void) | null = null; + +const mockConnectSSE = vi.fn(); + +vi.mock("@/js", () => ({ + connectSSE: (...args: unknown[]) => mockConnectSSE(...args), +})); + +import { useAgentThread } from "../use-agent-thread"; + +async function emit(data: string) { + await capturedOnMessage?.({ data }); +} + +function okJson(body: unknown, status = 200) { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); +} + +beforeEach(() => { + // Re-establish the impl each test — restoreAllMocks() in afterEach would + // otherwise strip it, making connectSSE resolve to undefined instantly. + mockConnectSSE.mockImplementation( + (opts: { onMessage?: typeof capturedOnMessage }) => { + capturedOnMessage = opts.onMessage; + return new Promise((resolve) => { + resolveStream = resolve; + }); + }, + ); +}); + +afterEach(() => { + capturedOnMessage = undefined; + resolveStream = null; + vi.restoreAllMocks(); + vi.clearAllMocks(); +}); + +describe("useAgentThread", () => { + test("resumes a thread: loads history and keeps only user/assistant", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue( + okJson({ + id: "t1", + messages: [ + { id: "m1", role: "user", content: "hi" }, + { id: "m2", role: "assistant", content: "hello" }, + { id: "m3", role: "system", content: "system prompt" }, + { id: "m4", role: "tool", content: "{}" }, + ], + }), + ); + + const { result } = renderHook(() => + useAgentThread("t1", { agent: "helper" }), + ); + + await waitFor(() => expect(result.current.messages).toHaveLength(2)); + expect(result.current.messages.map((m) => m.role)).toEqual([ + "user", + "assistant", + ]); + expect(result.current.messages[1].content).toBe("hello"); + expect(globalThis.fetch).toHaveBeenCalledWith( + "/api/agents/threads/t1", + expect.objectContaining({ signal: expect.anything() }), + ); + }); + + test("new thread: send appends the user turn, streams, and commits the assistant turn", async () => { + const { result } = renderHook(() => + useAgentThread(undefined, { agent: "helper" }), + ); + expect(result.current.messages).toEqual([]); + + // Send a turn — user message appears immediately. + await act(async () => { + void result.current.send("what is the weather?"); + }); + await waitFor(() => expect(mockConnectSSE).toHaveBeenCalled()); + expect(result.current.messages).toEqual([ + expect.objectContaining({ + role: "user", + content: "what is the weather?", + }), + ]); + + // Server assigns a thread id, then streams the answer. + await act(async () => { + await emit( + JSON.stringify({ + type: "appkit.metadata", + data: { threadId: "srv-1" }, + }), + ); + await emit( + JSON.stringify({ type: "response.output_text.delta", delta: "Sunny" }), + ); + }); + + expect(result.current.threadId).toBe("srv-1"); + // Live streaming bubble is visible mid-turn. + expect(result.current.messages.at(-1)).toEqual( + expect.objectContaining({ role: "assistant", content: "Sunny" }), + ); + expect(result.current.isStreaming).toBe(true); + + // End the stream → the assistant turn is committed (survives isStreaming=false). + await act(async () => { + resolveStream?.(); + await new Promise((r) => setTimeout(r, 0)); + }); + + await waitFor(() => expect(result.current.isStreaming).toBe(false)); + expect(result.current.messages).toEqual([ + expect.objectContaining({ + role: "user", + content: "what is the weather?", + }), + expect.objectContaining({ role: "assistant", content: "Sunny" }), + ]); + }); + + test("forwards the resumed threadId so the first send continues the thread", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue( + okJson({ id: "t9", messages: [] }), + ); + const { result } = renderHook(() => + useAgentThread("t9", { agent: "helper" }), + ); + await waitFor(() => expect(globalThis.fetch).toHaveBeenCalled()); + + await act(async () => { + void result.current.send("continue"); + }); + await waitFor(() => expect(mockConnectSSE).toHaveBeenCalled()); + + const payload = mockConnectSSE.mock.calls[0][0].payload; + expect(payload).toEqual({ + message: "continue", + agent: "helper", + threadId: "t9", + }); + }); + + test("reset clears the transcript", async () => { + const { result } = renderHook(() => + useAgentThread(undefined, { agent: "helper" }), + ); + await act(async () => { + void result.current.send("hi"); + }); + await waitFor(() => expect(result.current.messages).toHaveLength(1)); + + act(() => result.current.reset()); + expect(result.current.messages).toEqual([]); + expect(result.current.threadId).toBeNull(); + }); +}); diff --git a/packages/appkit-ui/src/react/hooks/__tests__/use-agent-threads.test.ts b/packages/appkit-ui/src/react/hooks/__tests__/use-agent-threads.test.ts new file mode 100644 index 000000000..dc42327ba --- /dev/null +++ b/packages/appkit-ui/src/react/hooks/__tests__/use-agent-threads.test.ts @@ -0,0 +1,154 @@ +import { act, renderHook, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, test, vi } from "vitest"; + +import { useAgentThreads } from "../use-agent-threads"; + +const ISO = "2026-05-05T10:00:00.000Z"; + +function okJson(body: unknown, status = 200) { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); +} + +/** Route fetch by method + path so multi-call tests get fresh responses. */ +function routeFetch() { + return vi.spyOn(globalThis, "fetch").mockImplementation((input, init) => { + const url = String(input); + const method = init?.method ?? "GET"; + if (method === "GET" && url.endsWith("/threads")) { + return Promise.resolve( + okJson({ + threads: [ + { + id: "t1", + title: "Weather", + messageCount: 2, + createdAt: ISO, + updatedAt: ISO, + }, + { + id: "t2", + title: "", + messageCount: 0, + createdAt: ISO, + updatedAt: ISO, + }, + ], + }), + ); + } + // DELETE / PATCH on /threads/:id + return Promise.resolve(okJson({ ok: true })); + }); +} + +afterEach(() => vi.restoreAllMocks()); + +describe("useAgentThreads", () => { + test("loads summaries on mount and revives dates", async () => { + const fetchSpy = routeFetch(); + const { result } = renderHook(() => useAgentThreads()); + + await waitFor(() => expect(result.current.threads).toHaveLength(2)); + expect(fetchSpy).toHaveBeenCalledWith( + "/api/agents/threads", + expect.objectContaining({ signal: expect.anything() }), + ); + expect(result.current.threads[0].updatedAt).toBeInstanceOf(Date); + expect(result.current.threads[0].updatedAt.toISOString()).toBe(ISO); + expect(result.current.loading).toBe(false); + expect(result.current.error).toBeNull(); + }); + + test("basePath option is honored", async () => { + const fetchSpy = routeFetch(); + const { result } = renderHook(() => + useAgentThreads({ basePath: "/custom/agents" }), + ); + await waitFor(() => expect(result.current.threads).toHaveLength(2)); + expect(fetchSpy).toHaveBeenCalledWith( + "/custom/agents/threads", + expect.anything(), + ); + }); + + test("deleteThread removes optimistically and calls DELETE", async () => { + const fetchSpy = routeFetch(); + const { result } = renderHook(() => useAgentThreads()); + await waitFor(() => expect(result.current.threads).toHaveLength(2)); + + await act(async () => { + await result.current.deleteThread("t1"); + }); + + expect(result.current.threads.map((t) => t.id)).toEqual(["t2"]); + expect(fetchSpy).toHaveBeenCalledWith( + "/api/agents/threads/t1", + expect.objectContaining({ method: "DELETE" }), + ); + }); + + test("renameThread updates the title optimistically and PATCHes", async () => { + const fetchSpy = routeFetch(); + const { result } = renderHook(() => useAgentThreads()); + await waitFor(() => expect(result.current.threads).toHaveLength(2)); + + await act(async () => { + await result.current.renameThread("t1", "Renamed"); + }); + + expect(result.current.threads.find((t) => t.id === "t1")?.title).toBe( + "Renamed", + ); + expect(fetchSpy).toHaveBeenCalledWith( + "/api/agents/threads/t1", + expect.objectContaining({ + method: "PATCH", + body: JSON.stringify({ title: "Renamed" }), + }), + ); + }); + + test("rolls back a failed delete and surfaces an error", async () => { + vi.spyOn(globalThis, "fetch").mockImplementation((_input, init) => { + const method = init?.method ?? "GET"; + if (method === "GET") { + return Promise.resolve( + okJson({ + threads: [ + { + id: "t1", + title: "x", + messageCount: 1, + createdAt: ISO, + updatedAt: ISO, + }, + ], + }), + ); + } + return Promise.resolve(okJson({ error: "boom" }, 500)); + }); + const { result } = renderHook(() => useAgentThreads()); + await waitFor(() => expect(result.current.threads).toHaveLength(1)); + + await act(async () => { + await result.current.deleteThread("t1"); + }); + + // Rolled back to the server state (refetch), and an error recorded. + await waitFor(() => expect(result.current.threads).toHaveLength(1)); + expect(result.current.error).toBeTruthy(); + }); + + test("surfaces a load error on a non-ok response", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue( + okJson({ error: "nope" }, 500), + ); + const { result } = renderHook(() => useAgentThreads()); + await waitFor(() => expect(result.current.error).toBeTruthy()); + expect(result.current.threads).toEqual([]); + }); +}); diff --git a/packages/appkit-ui/src/react/hooks/use-agent-thread.ts b/packages/appkit-ui/src/react/hooks/use-agent-thread.ts new file mode 100644 index 000000000..5f6981271 --- /dev/null +++ b/packages/appkit-ui/src/react/hooks/use-agent-thread.ts @@ -0,0 +1,224 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +import { useAgentChat } from "./use-agent-chat"; + +const DEFAULT_BASE_PATH = "/api/agents"; +const STREAMING_ID = "__streaming__"; + +/** A message rendered in a thread transcript. */ +export interface AgentThreadMessage { + id: string; + role: "user" | "assistant"; + content: string; +} + +/** Wire shape of a persisted message from `GET {basePath}/threads/:id`. */ +interface WireMessage { + id: string; + role: string; + content: string; +} + +export interface UseAgentThreadOptions { + /** Agent to route turns to (registered with the `agents()` plugin). */ + agent: string; + /** Base path the agents plugin is mounted under. Default `"/api/agents"`. */ + basePath?: string; + /** + * Mirror the active thread id in the URL query so a reload resumes it. + * Default `false` — AppKit apps usually own their router. When `true`, the + * hook reads the param on mount (if no `threadId` was passed) and writes it + * on change; an explicit `threadId` argument always wins. + */ + persistInUrl?: boolean; + /** Query param used when `persistInUrl` is on. Default `"threadId"`. */ + urlParamName?: string; +} + +export interface UseAgentThreadResult { + /** The transcript: loaded history + completed turns + the live streaming turn. */ + messages: AgentThreadMessage[]; + /** Active thread id (the resumed id, or the server-assigned id after the first send). */ + threadId: string | null; + /** True while loading a thread's history. */ + loading: boolean; + /** True while an assistant turn is streaming. */ + isStreaming: boolean; + /** History-load or streaming error message, or null. */ + error: string | null; + /** Send a user turn; continues this thread (or creates one on the first send). */ + send: (text: string) => Promise; + /** Clear the transcript and start a fresh thread. */ + reset: () => void; +} + +function readUrlThreadId(param: string): string | undefined { + if (typeof window === "undefined") return undefined; + return new URLSearchParams(window.location.search).get(param) ?? undefined; +} + +/** + * Owns one agent conversation's transcript: loads history for an existing + * thread, streams new turns (built on {@link useAgentChat}), and appends each + * completed turn. The "active thread" counterpart to {@link useAgentThreads} + * (the list) — compose them at the page level. + * + * Pass a `threadId` to resume a persisted thread, or omit it for a fresh one + * whose id the server assigns on the first `send()` (surfaced via + * {@link UseAgentThreadResult.threadId}). + * + * @example + * ```tsx + * const { messages, send, isStreaming } = useAgentThread(activeId, { agent: "helper" }); + * ``` + */ +export function useAgentThread( + threadId?: string, + options: UseAgentThreadOptions = { agent: "" }, +): UseAgentThreadResult { + const { agent, persistInUrl = false } = options; + const basePath = options.basePath ?? DEFAULT_BASE_PATH; + const urlParam = options.urlParamName ?? "threadId"; + + // The id to resume: explicit arg wins, else the URL (when persisting), else none. + const [resumeId, setResumeId] = useState(() => + threadId !== undefined + ? threadId + : persistInUrl + ? readUrlThreadId(urlParam) + : undefined, + ); + // An explicit threadId argument always wins and re-seeds on change. + useEffect(() => { + if (threadId !== undefined) setResumeId(threadId); + }, [threadId]); + + const [committed, setCommitted] = useState([]); + const [loading, setLoading] = useState(false); + const [historyError, setHistoryError] = useState(null); + + const { + content, + threadId: activeThreadId, + isStreaming, + error: streamError, + send: chatSend, + reset: chatReset, + } = useAgentChat({ + agent, + endpoint: `${basePath}/chat`, + initialThreadId: resumeId, + }); + + // Load history whenever the resume target changes (a fresh id, or a switch). + useEffect(() => { + if (!resumeId) { + setCommitted([]); + return; + } + const ac = new AbortController(); + setLoading(true); + setHistoryError(null); + fetch(`${basePath}/threads/${encodeURIComponent(resumeId)}`, { + signal: ac.signal, + }) + .then(async (res) => { + if (!res.ok) { + const body = await res.json().catch(() => null); + throw new Error(body?.error || `HTTP ${res.status}`); + } + return res.json() as Promise<{ messages?: WireMessage[] }>; + }) + .then((thread) => { + if (ac.signal.aborted) return; + setCommitted( + (thread.messages ?? []) + .filter((m) => m.role === "user" || m.role === "assistant") + .map((m) => ({ + id: m.id, + role: m.role as AgentThreadMessage["role"], + content: m.content, + })), + ); + setLoading(false); + }) + .catch((err: Error) => { + if (ac.signal.aborted) return; + setHistoryError(err.message || "Failed to load thread"); + setLoading(false); + }); + return () => ac.abort(); + }, [resumeId, basePath]); + + // Commit the assistant turn once streaming ends (true -> false transition). + const wasStreaming = useRef(false); + useEffect(() => { + if (wasStreaming.current && !isStreaming && content) { + setCommitted((prev) => [ + ...prev, + { id: `a-${prev.length}-${Date.now()}`, role: "assistant", content }, + ]); + } + wasStreaming.current = isStreaming; + }, [isStreaming, content]); + + // Reflect the active thread id in the URL when persisting. + useEffect(() => { + if (!persistInUrl || typeof window === "undefined") return; + const params = new URLSearchParams(window.location.search); + if (activeThreadId) params.set(urlParam, activeThreadId); + else params.delete(urlParam); + const qs = params.toString(); + window.history.replaceState( + null, + "", + `${window.location.pathname}${qs ? `?${qs}` : ""}${window.location.hash}`, + ); + }, [persistInUrl, urlParam, activeThreadId]); + + const send = useCallback( + async (text: string) => { + const trimmed = text.trim(); + if (!trimmed) return; + setCommitted((prev) => [ + ...prev, + { + id: `u-${prev.length}-${Date.now()}`, + role: "user", + content: trimmed, + }, + ]); + await chatSend(trimmed); + }, + [chatSend], + ); + + const reset = useCallback(() => { + setCommitted([]); + setHistoryError(null); + setResumeId(undefined); + chatReset(); + }, [chatReset]); + + // Show the in-progress assistant turn as a live bubble once it has text. + // Before the first token, `isStreaming` alone signals "thinking" — no empty + // bubble. On completion the turn is committed above, so this reconciles + // seamlessly. + const messages = useMemo( + () => + isStreaming && content + ? [...committed, { id: STREAMING_ID, role: "assistant", content }] + : committed, + [committed, isStreaming, content], + ); + + return { + messages, + threadId: activeThreadId, + loading, + isStreaming, + error: historyError ?? streamError, + send, + reset, + }; +} diff --git a/packages/appkit-ui/src/react/hooks/use-agent-threads.ts b/packages/appkit-ui/src/react/hooks/use-agent-threads.ts new file mode 100644 index 000000000..19b5cbdcc --- /dev/null +++ b/packages/appkit-ui/src/react/hooks/use-agent-threads.ts @@ -0,0 +1,152 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import type { ThreadSummary } from "shared"; + +const DEFAULT_BASE_PATH = "/api/agents"; + +/** Wire shape of a summary — dates arrive as ISO strings over JSON. */ +interface WireThreadSummary { + id: string; + title: string; + messageCount: number; + createdAt: string; + updatedAt: string; +} + +function reviveSummary(s: WireThreadSummary): ThreadSummary { + return { + id: s.id, + title: s.title, + messageCount: s.messageCount, + createdAt: new Date(s.createdAt), + updatedAt: new Date(s.updatedAt), + }; +} + +export interface UseAgentThreadsOptions { + /** + * Base path the agents plugin is mounted under. Default `"/api/agents"`. + * The hook calls `GET/DELETE/PATCH {basePath}/threads[/:id]`. + */ + basePath?: string; +} + +export interface UseAgentThreadsResult { + /** Summaries for the current user, most-recently-updated first. */ + threads: ThreadSummary[]; + /** True while the initial load or a `refetch()` is in flight. */ + loading: boolean; + /** Last error message, or null. */ + error: string | null; + /** Re-fetch the list (call after a turn completes to reflect new/updated threads). */ + refetch: () => Promise; + /** Delete a thread. Removes it optimistically; reconciles from the server on failure. */ + deleteThread: (threadId: string) => Promise; + /** Rename a thread. Updates the title optimistically; reconciles on failure. */ + renameThread: (threadId: string, title: string) => Promise; +} + +/** + * Lists the current user's agent threads for a history sidebar. Reads the cheap + * summary projection from `GET {basePath}/threads` (no message bodies) and + * offers delete/rename. Sibling to {@link useAgentChat}: it owns the list, not + * the active conversation — compose the two at the page level. + * + * Threads are scoped to the request's user server-side (via `x-forwarded-user` + * / the dev fallback), so the hook sends no user identity itself. + * + * @example + * ```tsx + * const { threads, deleteThread, refetch } = useAgentThreads(); + * // wraps this; or render `threads` yourself. + * ``` + */ +export function useAgentThreads( + options: UseAgentThreadsOptions = {}, +): UseAgentThreadsResult { + const basePath = options.basePath ?? DEFAULT_BASE_PATH; + + const [threads, setThreads] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const abortRef = useRef(null); + + const refetch = useCallback(async () => { + abortRef.current?.abort(); + const ac = new AbortController(); + abortRef.current = ac; + setLoading(true); + setError(null); + try { + const res = await fetch(`${basePath}/threads`, { signal: ac.signal }); + if (!res.ok) { + const body = await res.json().catch(() => null); + throw new Error(body?.error || `HTTP ${res.status}`); + } + const data = (await res.json()) as { threads?: WireThreadSummary[] }; + if (ac.signal.aborted) return; + setThreads((data.threads ?? []).map(reviveSummary)); + } catch (err) { + if (ac.signal.aborted) return; + setError(err instanceof Error ? err.message : "Failed to load threads"); + } finally { + if (!ac.signal.aborted) setLoading(false); + } + }, [basePath]); + + const deleteThread = useCallback( + async (threadId: string) => { + // Optimistic: drop it now, reconcile from the server if the call fails. + const prev = threads; + setThreads((list) => list.filter((t) => t.id !== threadId)); + try { + const res = await fetch( + `${basePath}/threads/${encodeURIComponent(threadId)}`, + { method: "DELETE" }, + ); + if (!res.ok && res.status !== 404) { + throw new Error(`HTTP ${res.status}`); + } + } catch (err) { + setThreads(prev); // rollback already reconciles to the known-good list + setError( + err instanceof Error ? err.message : "Failed to delete thread", + ); + } + }, + [basePath, threads], + ); + + const renameThread = useCallback( + async (threadId: string, title: string) => { + const prev = threads; + setThreads((list) => + list.map((t) => (t.id === threadId ? { ...t, title } : t)), + ); + try { + const res = await fetch( + `${basePath}/threads/${encodeURIComponent(threadId)}`, + { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ title }), + }, + ); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + } catch (err) { + setThreads(prev); // rollback already reconciles to the known-good list + setError( + err instanceof Error ? err.message : "Failed to rename thread", + ); + } + }, + [basePath, threads], + ); + + // Initial load + reload when the base path changes. + useEffect(() => { + void refetch(); + return () => abortRef.current?.abort(); + }, [refetch]); + + return { threads, loading, error, refetch, deleteThread, renameThread }; +} From 58ae3983dc1130fc4ff2b0eb4bee411714ac33a8 Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Tue, 8 Sep 2026 16:05:23 +0200 Subject: [PATCH 3/5] feat(appkit-ui): add thread history sidebar + transcript components (beta) Drop-in components over the two hooks, exported from @databricks/appkit-ui/react/beta: - ThreadList: history sidebar (owns useAgentThreads); controlled selection via activeThreadId/onSelect; per-row dropdown with inline Rename and alert-dialog-confirmed Delete; built on item/scroll-area/dropdown-menu. - AgentThread: one conversation's transcript + composer (owns useAgentThread); resolves the default agent from the plugin client config; onThreadCreated / onTurnComplete callbacks let the page refresh the list (no shared provider). Plain-text messages for v1. Includes a light ThreadList render test. Signed-off-by: MarioCadenas --- .../agent/__tests__/thread-list.test.tsx | 61 +++++ .../src/react/agent/agent-thread.tsx | 184 ++++++++++++++ packages/appkit-ui/src/react/agent/index.ts | 2 + .../appkit-ui/src/react/agent/thread-list.tsx | 229 ++++++++++++++++++ packages/appkit-ui/src/react/beta.ts | 6 + 5 files changed, 482 insertions(+) create mode 100644 packages/appkit-ui/src/react/agent/__tests__/thread-list.test.tsx create mode 100644 packages/appkit-ui/src/react/agent/agent-thread.tsx create mode 100644 packages/appkit-ui/src/react/agent/index.ts create mode 100644 packages/appkit-ui/src/react/agent/thread-list.tsx diff --git a/packages/appkit-ui/src/react/agent/__tests__/thread-list.test.tsx b/packages/appkit-ui/src/react/agent/__tests__/thread-list.test.tsx new file mode 100644 index 000000000..c848e3458 --- /dev/null +++ b/packages/appkit-ui/src/react/agent/__tests__/thread-list.test.tsx @@ -0,0 +1,61 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, test, vi } from "vitest"; + +import { ThreadList } from "../thread-list"; + +const ISO = "2026-06-06T12:00:00.000Z"; + +function okJson(body: unknown) { + return new Response(JSON.stringify(body), { + status: 200, + headers: { "content-type": "application/json" }, + }); +} + +afterEach(() => vi.restoreAllMocks()); + +describe("", () => { + test("renders titles (with derived-empty fallback) and selects on click", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue( + okJson({ + threads: [ + { + id: "t1", + title: "Weather in Paris", + messageCount: 2, + createdAt: ISO, + updatedAt: ISO, + }, + { + id: "t2", + title: "", + messageCount: 0, + createdAt: ISO, + updatedAt: ISO, + }, + ], + }), + ); + const onSelect = vi.fn(); + render(); + + await waitFor(() => expect(screen.getByText("Weather in Paris"))); + // Empty title falls back to a placeholder label. + expect(screen.getByText("New conversation")).toBeTruthy(); + + fireEvent.click(screen.getByText("Weather in Paris")); + expect(onSelect).toHaveBeenCalledWith("t1"); + }); + + test('renders a "New" button only when onNewThread is provided', async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue(okJson({ threads: [] })); + const onNewThread = vi.fn(); + const { rerender } = render(); + await waitFor(() => expect(screen.getByText("No conversations yet"))); + expect(screen.queryByText("+ New")).toBeNull(); + + rerender(); + fireEvent.click(screen.getByText("+ New")); + expect(onNewThread).toHaveBeenCalled(); + }); +}); diff --git a/packages/appkit-ui/src/react/agent/agent-thread.tsx b/packages/appkit-ui/src/react/agent/agent-thread.tsx new file mode 100644 index 000000000..e1eec024b --- /dev/null +++ b/packages/appkit-ui/src/react/agent/agent-thread.tsx @@ -0,0 +1,184 @@ +import { type KeyboardEvent, useEffect, useRef, useState } from "react"; + +import { useAgentThread } from "../hooks/use-agent-thread"; +import { usePluginClientConfig } from "../hooks/use-plugin-config"; +import { cn } from "../lib/utils"; +import { Button } from "../ui/button"; +import { ScrollArea } from "../ui/scroll-area"; +import { Spinner } from "../ui/spinner"; + +interface AgentsClientConfig { + agents?: string[]; + defaultAgent?: string | null; +} + +export interface AgentThreadProps { + /** Thread to open. Omit for a new conversation (id assigned on first send). */ + threadId?: string; + /** Agent to route turns to. Defaults to the plugin's default agent. */ + agent?: string; + /** Base path the agents plugin is mounted under. Default `"/api/agents"`. */ + basePath?: string; + /** Composer placeholder. */ + placeholder?: string; + /** Mirror the active thread id in the URL (default off). */ + persistInUrl?: boolean; + /** Fired when a brand-new thread gets its server-assigned id (uncontrolled use). */ + onThreadCreated?: (threadId: string) => void; + /** Fired when an assistant turn finishes streaming (e.g. to refresh a list). */ + onTurnComplete?: () => void; + /** Root container class. */ + className?: string; +} + +/** + * A single agent conversation: transcript + composer. Owns + * {@link useAgentThread} (history load + streaming). Pair with + * {@link ThreadList} — the page holds the active thread id and passes it here. + * + * @example + * ```tsx + * + * ``` + */ +export function AgentThread({ + threadId, + agent, + basePath, + placeholder = "Ask a question...", + persistInUrl, + onThreadCreated, + onTurnComplete, + className, +}: AgentThreadProps) { + const config = usePluginClientConfig("agents"); + const resolvedAgent = + agent ?? config.defaultAgent ?? config.agents?.[0] ?? ""; + + const { + messages, + threadId: activeThreadId, + loading, + isStreaming, + error, + send, + } = useAgentThread(threadId, { + agent: resolvedAgent, + basePath, + persistInUrl, + }); + + const [input, setInput] = useState(""); + const endRef = useRef(null); + + // Notify the parent when an uncontrolled conversation is first created. + const prevActive = useRef(null); + useEffect(() => { + if (!threadId && !prevActive.current && activeThreadId) { + onThreadCreated?.(activeThreadId); + } + prevActive.current = activeThreadId; + }, [activeThreadId, threadId, onThreadCreated]); + + // Notify the parent when a turn finishes (true -> false). + const wasStreaming = useRef(false); + useEffect(() => { + if (wasStreaming.current && !isStreaming) onTurnComplete?.(); + wasStreaming.current = isStreaming; + }, [isStreaming, onTurnComplete]); + + useEffect(() => { + endRef.current?.scrollIntoView({ behavior: "smooth" }); + }, [messages]); + + const submit = () => { + const trimmed = input.trim(); + if (!trimmed || isStreaming) return; + setInput(""); + void send(trimmed); + }; + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + submit(); + } + }; + + // "Thinking" once a turn is in flight but before the first token lands. + const awaitingFirstToken = + isStreaming && messages[messages.length - 1]?.role !== "assistant"; + + return ( +
+ +
+ {loading && ( +
+ +
+ )} + {!loading && messages.length === 0 && ( +

+ Send a message to start a conversation +

+ )} + {messages.map((m) => ( +
+
+ {m.content} +
+
+ ))} + {awaitingFirstToken && ( +
+
+ + Thinking... + +
+
+ )} +
+
+ + + {error && ( +
+ {error} +
+ )} + +
+