diff --git a/apps/dev-playground/client/src/lib/nav.ts b/apps/dev-playground/client/src/lib/nav.ts index dacb87b44..cb77d20a5 100644 --- a/apps/dev-playground/client/src/lib/nav.ts +++ b/apps/dev-playground/client/src/lib/nav.ts @@ -115,6 +115,13 @@ export const NAV_GROUPS: ReadonlyArray = [ "Chat agent over Databricks Model Serving with tools auto-discovered from AppKit plugins.", icon: BotIcon, }, + { + to: "/agent-history", + label: "Agent History", + description: + "Persistent chat history with + — resume, rename, delete across restarts.", + icon: LayersIcon, + }, { to: "/genie", label: "Genie", diff --git a/apps/dev-playground/client/src/routeTree.gen.ts b/apps/dev-playground/client/src/routeTree.gen.ts index 5d9e2009f..7a021159f 100644 --- a/apps/dev-playground/client/src/routeTree.gen.ts +++ b/apps/dev-playground/client/src/routeTree.gen.ts @@ -28,6 +28,7 @@ import { Route as ChartInferenceRouteRouteImport } from './routes/chart-inferenc import { Route as ArrowAnalyticsRouteRouteImport } from './routes/arrow-analytics.route' import { Route as AnalyticsRouteRouteImport } from './routes/analytics.route' import { Route as AiSearchRouteRouteImport } from './routes/ai-search.route' +import { Route as AgentHistoryRouteRouteImport } from './routes/agent-history.route' import { Route as AgentRouteRouteImport } from './routes/agent.route' import { Route as IndexRouteImport } from './routes/index' @@ -126,6 +127,11 @@ const AiSearchRouteRoute = AiSearchRouteRouteImport.update({ path: '/ai-search', getParentRoute: () => rootRouteImport, } as any) +const AgentHistoryRouteRoute = AgentHistoryRouteRouteImport.update({ + id: '/agent-history', + path: '/agent-history', + getParentRoute: () => rootRouteImport, +} as any) const AgentRouteRoute = AgentRouteRouteImport.update({ id: '/agent', path: '/agent', @@ -140,6 +146,7 @@ const IndexRoute = IndexRouteImport.update({ export interface FileRoutesByFullPath { '/': typeof IndexRoute '/agent': typeof AgentRouteRoute + '/agent-history': typeof AgentHistoryRouteRoute '/ai-search': typeof AiSearchRouteRoute '/analytics': typeof AnalyticsRouteRoute '/arrow-analytics': typeof ArrowAnalyticsRouteRoute @@ -163,6 +170,7 @@ export interface FileRoutesByFullPath { export interface FileRoutesByTo { '/': typeof IndexRoute '/agent': typeof AgentRouteRoute + '/agent-history': typeof AgentHistoryRouteRoute '/ai-search': typeof AiSearchRouteRoute '/analytics': typeof AnalyticsRouteRoute '/arrow-analytics': typeof ArrowAnalyticsRouteRoute @@ -187,6 +195,7 @@ export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute '/agent': typeof AgentRouteRoute + '/agent-history': typeof AgentHistoryRouteRoute '/ai-search': typeof AiSearchRouteRoute '/analytics': typeof AnalyticsRouteRoute '/arrow-analytics': typeof ArrowAnalyticsRouteRoute @@ -212,6 +221,7 @@ export interface FileRouteTypes { fullPaths: | '/' | '/agent' + | '/agent-history' | '/ai-search' | '/analytics' | '/arrow-analytics' @@ -235,6 +245,7 @@ export interface FileRouteTypes { to: | '/' | '/agent' + | '/agent-history' | '/ai-search' | '/analytics' | '/arrow-analytics' @@ -258,6 +269,7 @@ export interface FileRouteTypes { | '__root__' | '/' | '/agent' + | '/agent-history' | '/ai-search' | '/analytics' | '/arrow-analytics' @@ -282,6 +294,7 @@ export interface FileRouteTypes { export interface RootRouteChildren { IndexRoute: typeof IndexRoute AgentRouteRoute: typeof AgentRouteRoute + AgentHistoryRouteRoute: typeof AgentHistoryRouteRoute AiSearchRouteRoute: typeof AiSearchRouteRoute AnalyticsRouteRoute: typeof AnalyticsRouteRoute ArrowAnalyticsRouteRoute: typeof ArrowAnalyticsRouteRoute @@ -438,6 +451,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AiSearchRouteRouteImport parentRoute: typeof rootRouteImport } + '/agent-history': { + id: '/agent-history' + path: '/agent-history' + fullPath: '/agent-history' + preLoaderRoute: typeof AgentHistoryRouteRouteImport + parentRoute: typeof rootRouteImport + } '/agent': { id: '/agent' path: '/agent' @@ -458,6 +478,7 @@ declare module '@tanstack/react-router' { const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, AgentRouteRoute: AgentRouteRoute, + AgentHistoryRouteRoute: AgentHistoryRouteRoute, AiSearchRouteRoute: AiSearchRouteRoute, AnalyticsRouteRoute: AnalyticsRouteRoute, ArrowAnalyticsRouteRoute: ArrowAnalyticsRouteRoute, diff --git a/apps/dev-playground/client/src/routes/agent-history.route.tsx b/apps/dev-playground/client/src/routes/agent-history.route.tsx new file mode 100644 index 000000000..3a75a111f --- /dev/null +++ b/apps/dev-playground/client/src/routes/agent-history.route.tsx @@ -0,0 +1,60 @@ +import { AgentThread, ThreadList } from "@databricks/appkit-ui/react/beta"; +import { createFileRoute } from "@tanstack/react-router"; +import { useCallback, useState } from "react"; + +export const Route = createFileRoute("/agent-history")({ + component: AgentHistoryRoute, +}); + +/** + * Persistent chat history with the beta appkit-ui components. `` + * (left) owns the list; `` (right) owns the active conversation. + * The page holds the active thread id and wires the two together — clicking a + * thread opens it, a new/finished turn refreshes the list. Threads persist when + * the agents plugin uses LakebaseThreadStore (see server/index.ts). + */ +function AgentHistoryRoute() { + // `undefined` = a fresh conversation; a string = an opened thread. Switching + // is driven purely by this prop — no remount — so 's per-thread + // transcript cache survives and re-opening a thread doesn't refetch. + const [activeThreadId, setActiveThreadId] = useState(); + // Bumping this tells to refetch (new/updated thread). + const [listSignal, setListSignal] = useState(0); + + const newConversation = useCallback(() => setActiveThreadId(undefined), []); + const refreshList = useCallback(() => setListSignal((n) => n + 1), []); + + return ( +
+
+
+

Agent History

+

+ <ThreadList> + <AgentThread>{" "} + from @databricks/appkit-ui/react/beta. Threads persist + across restarts when the agent uses LakebaseThreadStore + . +

+
+ +
+
+ +
+
+ +
+
+
+
+ ); +} diff --git a/docs/docs/plugins/agents.md b/docs/docs/plugins/agents.md index 37b36c0e0..1b01b852f 100644 --- a/docs/docs/plugins/agents.md +++ b/docs/docs/plugins/agents.md @@ -528,6 +528,46 @@ interface ThreadStore { For the exact exported symbols, run `npx @databricks/appkit docs` and open the `appkit` API reference. +### Chat-history UI (`@databricks/appkit-ui`) + +The persistence above powers a history sidebar on the client. `@databricks/appkit-ui/react/beta` ships two hooks and two drop-in components (beta), built on the thread endpoints (`GET /threads` summaries, `GET /threads/:id`, `PATCH`/`DELETE /threads/:id`): + +| Export | Kind | What it does | +| --- | --- | --- | +| `useAgentThreads()` | hook | Lists thread summaries; `deleteThread` / `renameThread` (optimistic); `refetch`. Owns the list, not the active chat. | +| `useAgentThread(threadId?)` | hook | One conversation's transcript — loads history, streams turns (on `useAgentChat`), resumes an existing thread or creates a new one. | +| `` | component | History sidebar over `useAgentThreads`: controlled selection (`activeThreadId` / `onSelect`), per-row rename + delete. | +| `` | component | Transcript + composer over `useAgentThread`; `onThreadCreated` / `onTurnComplete` let the page refresh the list. | + +They're **sibling** pieces (the list and the active conversation are separate lifecycles) — compose them at the page level; the page holds the active thread id: + +```tsx +import { AgentThread, ThreadList } from "@databricks/appkit-ui/react/beta"; + +function AgentHistory() { + const [active, setActive] = useState(); + const [refresh, setRefresh] = useState(0); + return ( +
+ setActive(undefined)} + refetchSignal={refresh} + /> + setRefresh((n) => n + 1)} + /> +
+ ); +} +``` + +The same components work with any `ThreadStore` — the list just isn't durable across restarts unless the agent uses `LakebaseThreadStore`. See the dev-playground `Agent History` route for a working example. `` renders plain-text messages in v1 (markdown and tool-call chips are a planned enhancement). + ## Configuration reference ```ts diff --git a/docs/static/appkit-ui/styles.gen.css b/docs/static/appkit-ui/styles.gen.css index 8fa4174c8..f1d51a5dd 100644 --- a/docs/static/appkit-ui/styles.gen.css +++ b/docs/static/appkit-ui/styles.gen.css @@ -657,6 +657,9 @@ .h-\[200px\] { height: 200px; } + .h-\[700px\] { + height: 700px; + } .h-\[calc\(100\%-1px\)\] { height: calc(100% - 1px); } @@ -837,6 +840,9 @@ .max-w-\[80\%\] { max-width: 80%; } + .max-w-\[85\%\] { + max-width: 85%; + } .max-w-\[calc\(100\%-2rem\)\] { max-width: calc(100% - 2rem); } @@ -1515,6 +1521,9 @@ .py-12 { padding-block: calc(var(--spacing) * 12); } + .py-20 { + padding-block: calc(var(--spacing) * 20); + } .pt-0 { padding-top: calc(var(--spacing) * 0); } @@ -1714,6 +1723,12 @@ .text-muted-foreground { color: var(--muted-foreground); } + .text-muted-foreground\/60 { + color: var(--muted-foreground); + @supports (color: color-mix(in lab, red, red)) { + color: color-mix(in oklab, var(--muted-foreground) 60%, transparent); + } + } .text-popover-foreground { color: var(--popover-foreground); } @@ -2727,6 +2742,11 @@ color: var(--accent-foreground); } } + .focus\:text-destructive { + &:focus { + color: var(--destructive); + } + } .focus\:shadow-md { &:focus { --tw-shadow: 0 4px 6px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 2px 4px -2px var(--tw-shadow-color, rgb(0 0 0 / 0.1)); @@ -2776,6 +2796,11 @@ border-color: var(--ring); } } + .focus-visible\:opacity-100 { + &:focus-visible { + opacity: 100%; + } + } .focus-visible\:shadow-none { &:focus-visible { --tw-shadow: 0 0 #0000; 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} +
+ )} + +
+