Skip to content
Draft
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
7 changes: 7 additions & 0 deletions apps/dev-playground/client/src/lib/nav.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,13 @@ export const NAV_GROUPS: ReadonlyArray<NavGroup> = [
"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 <ThreadList> + <AgentThread> — resume, rename, delete across restarts.",
icon: LayersIcon,
},
{
to: "/genie",
label: "Genie",
Expand Down
21 changes: 21 additions & 0 deletions apps/dev-playground/client/src/routeTree.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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',
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -212,6 +221,7 @@ export interface FileRouteTypes {
fullPaths:
| '/'
| '/agent'
| '/agent-history'
| '/ai-search'
| '/analytics'
| '/arrow-analytics'
Expand All @@ -235,6 +245,7 @@ export interface FileRouteTypes {
to:
| '/'
| '/agent'
| '/agent-history'
| '/ai-search'
| '/analytics'
| '/arrow-analytics'
Expand All @@ -258,6 +269,7 @@ export interface FileRouteTypes {
| '__root__'
| '/'
| '/agent'
| '/agent-history'
| '/ai-search'
| '/analytics'
| '/arrow-analytics'
Expand All @@ -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
Expand Down Expand Up @@ -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'
Expand All @@ -458,6 +478,7 @@ declare module '@tanstack/react-router' {
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
AgentRouteRoute: AgentRouteRoute,
AgentHistoryRouteRoute: AgentHistoryRouteRoute,
AiSearchRouteRoute: AiSearchRouteRoute,
AnalyticsRouteRoute: AnalyticsRouteRoute,
ArrowAnalyticsRouteRoute: ArrowAnalyticsRouteRoute,
Expand Down
60 changes: 60 additions & 0 deletions apps/dev-playground/client/src/routes/agent-history.route.tsx
Original file line number Diff line number Diff line change
@@ -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. `<ThreadList>`
* (left) owns the list; `<AgentThread>` (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 <AgentThread>'s per-thread
// transcript cache survives and re-opening a thread doesn't refetch.
const [activeThreadId, setActiveThreadId] = useState<string | undefined>();
// Bumping this tells <ThreadList> to refetch (new/updated thread).
const [listSignal, setListSignal] = useState(0);

const newConversation = useCallback(() => setActiveThreadId(undefined), []);
const refreshList = useCallback(() => setListSignal((n) => n + 1), []);

return (
<div className="min-h-screen bg-background">
<div className="mx-auto max-w-7xl px-6 py-12">
<div className="mb-8">
<h1 className="mb-2 text-3xl font-bold">Agent History</h1>
<p className="text-base text-muted-foreground">
<code>&lt;ThreadList&gt;</code> + <code>&lt;AgentThread&gt;</code>{" "}
from <code>@databricks/appkit-ui/react/beta</code>. Threads persist
across restarts when the agent uses <code>LakebaseThreadStore</code>
.
</p>
</div>

<div className="flex h-[700px] gap-6">
<div className="w-72 shrink-0 rounded-lg border bg-card">
<ThreadList
activeThreadId={activeThreadId}
onSelect={setActiveThreadId}
onNewThread={newConversation}
refetchSignal={listSignal}
/>
</div>
<div className="min-w-0 flex-1 rounded-lg border bg-card">
<AgentThread
threadId={activeThreadId}
onThreadCreated={setActiveThreadId}
onTurnComplete={refreshList}
/>
</div>
</div>
</div>
</div>
);
}
40 changes: 40 additions & 0 deletions docs/docs/plugins/agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
| `<ThreadList>` | component | History sidebar over `useAgentThreads`: controlled selection (`activeThreadId` / `onSelect`), per-row rename + delete. |
| `<AgentThread>` | 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<string>();
const [refresh, setRefresh] = useState(0);
return (
<div className="flex gap-4 h-[700px]">
<ThreadList
activeThreadId={active}
onSelect={setActive}
onNewThread={() => setActive(undefined)}
refetchSignal={refresh}
/>
<AgentThread
key={active ?? "new"}
threadId={active}
onThreadCreated={setActive}
onTurnComplete={() => setRefresh((n) => n + 1)}
/>
</div>
);
}
```

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. `<AgentThread>` renders plain-text messages in v1 (markdown and tool-call chips are a planned enhancement).

## Configuration reference

```ts
Expand Down
25 changes: 25 additions & 0 deletions docs/static/appkit-ui/styles.gen.css
Original file line number Diff line number Diff line change
Expand Up @@ -657,6 +657,9 @@
.h-\[200px\] {
height: 200px;
}
.h-\[700px\] {
height: 700px;
}
.h-\[calc\(100\%-1px\)\] {
height: calc(100% - 1px);
}
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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("<ThreadList>", () => {
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(<ThreadList onSelect={onSelect} />);

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(<ThreadList />);
await waitFor(() => expect(screen.getByText("No conversations yet")));
expect(screen.queryByText("+ New")).toBeNull();

rerender(<ThreadList onNewThread={onNewThread} />);
fireEvent.click(screen.getByText("+ New"));
expect(onNewThread).toHaveBeenCalled();
});
});
Loading