@dbx-tools/ui-mastra 0.6.0 → 0.6.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +54 -6
- package/index.ts +8 -1
- package/package.json +7 -7
- package/src/react/chat-view.tsx +160 -80
- package/src/react/index.ts +4 -0
- package/src/react/mastra-chat.tsx +22 -3
- package/src/react/thread-sidebar.tsx +14 -27
- package/src/react/thread-tabs.tsx +191 -0
- package/src/react/types.ts +24 -1
- package/src/support/thread-labels.ts +30 -0
- package/src/support/thread-tabs.ts +60 -0
package/README.md
CHANGED
|
@@ -27,6 +27,9 @@ Key features:
|
|
|
27
27
|
the server plugin.
|
|
28
28
|
- Conversation sidebar with new, select, rename, delete, active-thread, and
|
|
29
29
|
background-streaming states, plus a per-row cancel for a running thread.
|
|
30
|
+
- Placeable conversation UI: dock the list left or right, switch to an
|
|
31
|
+
editor-style tab strip across the top, turn it off, or let `auto` pick between
|
|
32
|
+
a side panel and tabs from the chat's own width.
|
|
30
33
|
- Concurrent threads: run several conversations at once, switch between them
|
|
31
34
|
while each keeps streaming, and cancel any one independently (per-thread abort
|
|
32
35
|
- routing, no shared client state).
|
|
@@ -80,7 +83,7 @@ export function App() {
|
|
|
80
83
|
<MastraChat
|
|
81
84
|
agentId="analyst"
|
|
82
85
|
showModelPicker
|
|
83
|
-
|
|
86
|
+
threadPlacement="auto"
|
|
84
87
|
enableExport
|
|
85
88
|
enableFeedback
|
|
86
89
|
className="h-dvh"
|
|
@@ -100,11 +103,48 @@ Useful options:
|
|
|
100
103
|
- `showModelPicker` fetches `/models` and sends `X-Mastra-Model` overrides.
|
|
101
104
|
- `suggestions` overrides Genie starter questions; omit it to auto-fetch
|
|
102
105
|
`/suggestions`, or pass `[]` to hide suggestions.
|
|
103
|
-
- `
|
|
106
|
+
- `threadPlacement` chooses where conversation management renders, or turns it
|
|
107
|
+
off. See [Place The Conversation List](#place-the-conversation-list).
|
|
108
|
+
- `enableThreads: false` is the older shorthand for
|
|
109
|
+
`threadPlacement: "disabled"`.
|
|
104
110
|
- `enableExport` adds whole-conversation and per-message export affordances.
|
|
105
111
|
- `enableFeedback` enables thumbs/comment controls when the server reports
|
|
106
112
|
MLflow feedback is available and a turn produced a trace id.
|
|
107
113
|
|
|
114
|
+
## Place The Conversation List
|
|
115
|
+
|
|
116
|
+
Conversation management is on by default. Where it lives is one option:
|
|
117
|
+
|
|
118
|
+
```tsx
|
|
119
|
+
<MastraChat threadPlacement="top" />
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
| `threadPlacement` | Layout |
|
|
123
|
+
| ----------------- | -------------------------------------------------------------------------- |
|
|
124
|
+
| `auto` (default) | `left` while the chat is wide, `top` once it is too narrow for a panel |
|
|
125
|
+
| `left` / `right` | List docked to that edge, collapsing to an overlay drawer on a narrow chat |
|
|
126
|
+
| `top` | Tab strip of open conversations, with a history menu for the rest |
|
|
127
|
+
| `disabled` | No thread UI - the classic single-thread chat on the session cookie |
|
|
128
|
+
|
|
129
|
+
The docked placements render the sidebar as an inline column with a persisted
|
|
130
|
+
show/hide toggle. Below 768px of chat width the panel becomes an overlay drawer
|
|
131
|
+
on the same edge, so the transcript never loses room; the drawer is
|
|
132
|
+
session-scoped and starts closed, and closes itself after a selection.
|
|
133
|
+
|
|
134
|
+
The `top` placement is the editor-tab model: each open conversation is a tab with
|
|
135
|
+
its title, a spinner while it streams in the background, and a close affordance
|
|
136
|
+
that only takes the tab off the strip (the conversation stays in history). A `+`
|
|
137
|
+
starts a fresh conversation and a history button opens the full list - the same
|
|
138
|
+
sidebar, framed as a menu - so rename, delete, and cancel still work on anything
|
|
139
|
+
not currently tabbed. Closing the active tab moves to a neighbouring one, or
|
|
140
|
+
starts a fresh conversation when it was the last tab open. The open set is
|
|
141
|
+
session state; the strip reseeds from the most recent conversations on the next
|
|
142
|
+
load.
|
|
143
|
+
|
|
144
|
+
`auto` measures the chat's own element rather than the viewport, so a chat
|
|
145
|
+
embedded in a split view or side panel switches to tabs on the space it actually
|
|
146
|
+
has instead of waiting for the window to shrink.
|
|
147
|
+
|
|
108
148
|
## Use The Headless Driver
|
|
109
149
|
|
|
110
150
|
```tsx
|
|
@@ -114,7 +154,7 @@ export function CustomChat() {
|
|
|
114
154
|
const chat = useMastraChat({
|
|
115
155
|
agentId: "analyst",
|
|
116
156
|
showModelPicker: true,
|
|
117
|
-
|
|
157
|
+
threadPlacement: "left",
|
|
118
158
|
});
|
|
119
159
|
|
|
120
160
|
return <ChatView {...chat} className="h-full" />;
|
|
@@ -233,11 +273,19 @@ touching `navigator.clipboard` or `URL.createObjectURL` again.
|
|
|
233
273
|
`useMastraDefaultModel`, `useMastraSuggestions`, `useMastraThreads`,
|
|
234
274
|
`useChartFetch`, `useStatementFetch` - route/config hooks for controlled
|
|
235
275
|
clients.
|
|
236
|
-
- `ThreadSidebar` - controlled conversation list.
|
|
276
|
+
- `ThreadSidebar` - controlled conversation list, dockable to either edge.
|
|
277
|
+
- `ThreadTabs` - conversation tab strip plus history menu for the `top`
|
|
278
|
+
placement; reuses `ThreadSidebar` for the menu itself.
|
|
237
279
|
- `ExportMenu` - shared export format menu.
|
|
280
|
+
- `src/support/thread-tabs.ts` - pure open-tab bookkeeping (`syncThreadTabs`,
|
|
281
|
+
`closeThreadTab`, `nextActiveThreadTab`) so the strip's state is testable
|
|
282
|
+
without a DOM.
|
|
283
|
+
- `src/support/thread-labels.ts` - `threadTitle` / `relativeTime`, shared by
|
|
284
|
+
the sidebar and the tab strip so a row and a tab never disagree about how an
|
|
285
|
+
untitled or freshly-updated conversation reads.
|
|
238
286
|
- Types - `ChatViewProps`, `MastraChatProps`, `UseMastraChatOptions`,
|
|
239
|
-
`ThreadSummary`, `ToolEvent`, `ToolProgress`,
|
|
240
|
-
`FeedbackSubmission`, and related UI contract types.
|
|
287
|
+
`ThreadPlacement`, `ThreadSummary`, `ToolEvent`, `ToolProgress`,
|
|
288
|
+
`PendingApproval`, `FeedbackSubmission`, and related UI contract types.
|
|
241
289
|
|
|
242
290
|
Server-side routes and event production live in
|
|
243
291
|
[`@dbx-tools/appkit-mastra`](../../node/appkit-mastra). Browser-safe route,
|
package/index.ts
CHANGED
|
@@ -13,6 +13,7 @@ export * as reactMastraChat from "./src/react/mastra-chat.tsx";
|
|
|
13
13
|
export * as reactSuggestionPills from "./src/react/suggestion-pills.tsx";
|
|
14
14
|
export * as reactSuggestions from "./src/react/suggestions.ts";
|
|
15
15
|
export * as reactThreadSidebar from "./src/react/thread-sidebar.tsx";
|
|
16
|
+
export * as reactThreadTabs from "./src/react/thread-tabs.tsx";
|
|
16
17
|
export * as reactToolPill from "./src/react/tool-pill.tsx";
|
|
17
18
|
export * as reactTypes from "./src/react/types.ts";
|
|
18
19
|
export * as supportChartOption from "./src/support/chart-option.ts";
|
|
@@ -22,7 +23,9 @@ export * as supportExport from "./src/support/export.ts";
|
|
|
22
23
|
export * as supportMastraClient from "./src/support/mastra-client.ts";
|
|
23
24
|
export * as supportMastraStream from "./src/support/mastra-stream.ts";
|
|
24
25
|
export * as supportShikiPlugin from "./src/support/shiki-plugin.ts";
|
|
26
|
+
export * as supportThreadLabels from "./src/support/thread-labels.ts";
|
|
25
27
|
export * as supportThreadSessions from "./src/support/thread-sessions.ts";
|
|
28
|
+
export * as supportThreadTabs from "./src/support/thread-tabs.ts";
|
|
26
29
|
export { AssistantBubble, UserBubble } from "./src/react/bubbles.tsx";
|
|
27
30
|
export { ChatView } from "./src/react/chat-view.tsx";
|
|
28
31
|
export { colorizeDelta, renderDataCell, humanizeLabel, TABLE_WRAPPER_CLASSES, DataGrid } from "./src/react/data-grid.tsx";
|
|
@@ -38,8 +41,10 @@ export type { SuggestionPillsProps } from "./src/react/suggestion-pills.tsx";
|
|
|
38
41
|
export { dedupeSuggestions, collectSuggestions } from "./src/react/suggestions.ts";
|
|
39
42
|
export { ThreadSidebar } from "./src/react/thread-sidebar.tsx";
|
|
40
43
|
export type { ThreadSidebarProps } from "./src/react/thread-sidebar.tsx";
|
|
44
|
+
export { ThreadTabs } from "./src/react/thread-tabs.tsx";
|
|
45
|
+
export type { ThreadTabsProps } from "./src/react/thread-tabs.tsx";
|
|
41
46
|
export { humanizeToolName, ToolSessionPill } from "./src/react/tool-pill.tsx";
|
|
42
|
-
export type { ChatStatus, ToolEvent, ToolProgress, ChatModelOption, QueuedSteer, FeedbackValue, FeedbackSubmission, MessageFeedback, ThreadSummary, ChatViewProps, ApprovalDecision, PendingApproval } from "./src/react/types.ts";
|
|
47
|
+
export type { ChatStatus, ToolEvent, ToolProgress, ChatModelOption, QueuedSteer, FeedbackValue, FeedbackSubmission, MessageFeedback, ThreadSummary, ThreadPlacement, ChatViewProps, ApprovalDecision, PendingApproval } from "./src/react/types.ts";
|
|
43
48
|
export { normalizeChartOption } from "./src/support/chart-option.ts";
|
|
44
49
|
export { copyText } from "./src/support/clipboard.ts";
|
|
45
50
|
export { downloadFile } from "./src/support/download.ts";
|
|
@@ -50,5 +55,7 @@ export type { ByIdFetchState } from "./src/support/mastra-client.ts";
|
|
|
50
55
|
export { processMastraStream, asMastraStreamResponse } from "./src/support/mastra-stream.ts";
|
|
51
56
|
export type { MastraStreamChunk, MastraStreamResponse } from "./src/support/mastra-stream.ts";
|
|
52
57
|
export { highlightToHtml, createShikiPlugin } from "./src/support/shiki-plugin.ts";
|
|
58
|
+
export { threadTitle, relativeTime } from "./src/support/thread-labels.ts";
|
|
53
59
|
export { DEFAULT_THREAD_SESSION_KEY, createThreadSession, isSessionRunning, enqueueSteer, removeSteer, reorderSteers, terminateRunningToolEvents, sessionKey } from "./src/support/thread-sessions.ts";
|
|
54
60
|
export type { ThreadSession } from "./src/support/thread-sessions.ts";
|
|
61
|
+
export { THREAD_TAB_SEED_MAX, syncThreadTabs, closeThreadTab, nextActiveThreadTab } from "./src/support/thread-tabs.ts";
|
package/package.json
CHANGED
|
@@ -25,18 +25,18 @@
|
|
|
25
25
|
"shiki": "^3.0.0",
|
|
26
26
|
"sql-formatter": "^15.6.9",
|
|
27
27
|
"streamdown": "^2.5.0",
|
|
28
|
-
"@dbx-tools/shared-core": "0.6.
|
|
29
|
-
"@dbx-tools/shared-
|
|
30
|
-
"@dbx-tools/
|
|
31
|
-
"@dbx-tools/ui-appkit": "0.6.
|
|
32
|
-
"@dbx-tools/shared-
|
|
33
|
-
"@dbx-tools/
|
|
28
|
+
"@dbx-tools/shared-core": "0.6.2",
|
|
29
|
+
"@dbx-tools/shared-genie": "0.6.2",
|
|
30
|
+
"@dbx-tools/shared-model": "0.6.2",
|
|
31
|
+
"@dbx-tools/ui-appkit": "0.6.2",
|
|
32
|
+
"@dbx-tools/shared-mastra": "0.6.2",
|
|
33
|
+
"@dbx-tools/ui-branding": "0.6.2"
|
|
34
34
|
},
|
|
35
35
|
"license": "UNLICENSED",
|
|
36
36
|
"publishConfig": {
|
|
37
37
|
"access": "public"
|
|
38
38
|
},
|
|
39
|
-
"version": "0.6.
|
|
39
|
+
"version": "0.6.2",
|
|
40
40
|
"type": "module",
|
|
41
41
|
"exports": {
|
|
42
42
|
"./react": "./src/react/index.ts",
|
package/src/react/chat-view.tsx
CHANGED
|
@@ -38,6 +38,7 @@ import {
|
|
|
38
38
|
GripVerticalIcon,
|
|
39
39
|
MessageSquareIcon,
|
|
40
40
|
PanelLeftIcon,
|
|
41
|
+
PanelRightIcon,
|
|
41
42
|
RefreshCwIcon,
|
|
42
43
|
SendHorizontalIcon,
|
|
43
44
|
SendIcon,
|
|
@@ -51,7 +52,9 @@ import { AssistantBubble, UserBubble } from "./bubbles.tsx";
|
|
|
51
52
|
import { ExportMenu } from "./export-menu.tsx";
|
|
52
53
|
import { SuggestionPills } from "./suggestion-pills.tsx";
|
|
53
54
|
import { ThreadSidebar, type ThreadSidebarProps } from "./thread-sidebar.tsx";
|
|
55
|
+
import { ThreadTabs } from "./thread-tabs.tsx";
|
|
54
56
|
import type { ChatViewProps } from "./types.ts";
|
|
57
|
+
import { closeThreadTab, nextActiveThreadTab, syncThreadTabs } from "../support/thread-tabs.ts";
|
|
55
58
|
|
|
56
59
|
// Controlled, presentational chat shell: the scroll container, header
|
|
57
60
|
// (model picker + clear), empty state, transcript of message bubbles,
|
|
@@ -74,28 +77,41 @@ const TOP_LOAD_MORE_THRESHOLD_PX = 120;
|
|
|
74
77
|
*/
|
|
75
78
|
const DEFAULT_MODEL_VALUE = "__default__";
|
|
76
79
|
|
|
77
|
-
/**
|
|
78
|
-
|
|
80
|
+
/**
|
|
81
|
+
* Width (px) below which the chat is too narrow to give up a column to a
|
|
82
|
+
* docked conversation list. Matches Tailwind's `md` breakpoint. Under it, a
|
|
83
|
+
* `left` / `right` placement collapses to an overlay drawer and `auto`
|
|
84
|
+
* switches to the `top` tab strip.
|
|
85
|
+
*/
|
|
86
|
+
const SIDE_PANEL_MIN_WIDTH_PX = 768;
|
|
79
87
|
|
|
80
88
|
/**
|
|
81
|
-
* `true`
|
|
82
|
-
*
|
|
83
|
-
*
|
|
89
|
+
* `true` while the element behind `ref` is narrower than
|
|
90
|
+
* {@link SIDE_PANEL_MIN_WIDTH_PX}. Measured with a `ResizeObserver` on the
|
|
91
|
+
* chat itself rather than `matchMedia` on the viewport, so a chat embedded in
|
|
92
|
+
* a panel or split view reacts to the space it actually has - a wide window
|
|
93
|
+
* with a 400px chat column is narrow as far as the layout is concerned.
|
|
94
|
+
* Falls back to the viewport width until the first measurement lands, and is
|
|
95
|
+
* SSR-safe (assumes wide when `window` is unavailable).
|
|
84
96
|
*/
|
|
85
|
-
const
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
typeof window === "undefined" ? false : window.matchMedia(query).matches,
|
|
97
|
+
const useIsNarrow = (ref: React.RefObject<HTMLElement | null>): boolean => {
|
|
98
|
+
const [isNarrow, setIsNarrow] = useState(() =>
|
|
99
|
+
typeof window === "undefined" ? false : window.innerWidth < SIDE_PANEL_MIN_WIDTH_PX,
|
|
89
100
|
);
|
|
90
101
|
useEffect(() => {
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
const
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
102
|
+
const el = ref.current;
|
|
103
|
+
if (!el || typeof ResizeObserver === "undefined") return;
|
|
104
|
+
const observer = new ResizeObserver((entries) => {
|
|
105
|
+
const width = entries[0]?.contentRect.width ?? el.clientWidth;
|
|
106
|
+
// A zero width means the chat is detached or hidden (a closed tab
|
|
107
|
+
// panel); keep the last real measurement rather than flipping layout
|
|
108
|
+
// behind the user's back.
|
|
109
|
+
if (width > 0) setIsNarrow(width < SIDE_PANEL_MIN_WIDTH_PX);
|
|
110
|
+
});
|
|
111
|
+
observer.observe(el);
|
|
112
|
+
return () => observer.disconnect();
|
|
113
|
+
}, [ref]);
|
|
114
|
+
return isNarrow;
|
|
99
115
|
};
|
|
100
116
|
|
|
101
117
|
export const ChatView = ({
|
|
@@ -124,6 +140,7 @@ export const ChatView = ({
|
|
|
124
140
|
pendingApprovalsByMessage = {},
|
|
125
141
|
onClear,
|
|
126
142
|
threads,
|
|
143
|
+
threadPlacement = "auto",
|
|
127
144
|
activeThreadId,
|
|
128
145
|
streamingThreadIds = [],
|
|
129
146
|
isLoadingThreads = false,
|
|
@@ -154,6 +171,9 @@ export const ChatView = ({
|
|
|
154
171
|
// only drives styling and lags a render behind the pointerdown, which on
|
|
155
172
|
// touch dropped the first moves and made the drag feel dead.
|
|
156
173
|
const draggingIdRef = useRef<string | null>(null);
|
|
174
|
+
// Root layout element, measured to decide whether the chat has room for a
|
|
175
|
+
// docked conversation list (see `useIsNarrow`).
|
|
176
|
+
const rootRef = useRef<HTMLDivElement>(null);
|
|
157
177
|
const scrollRef = useRef<HTMLDivElement>(null);
|
|
158
178
|
const contentRef = useRef<HTMLDivElement>(null);
|
|
159
179
|
// Composer textarea, auto-grown with its content up to the CSS `max-h`.
|
|
@@ -396,65 +416,97 @@ export const ChatView = ({
|
|
|
396
416
|
);
|
|
397
417
|
const showClear = Boolean(onClear);
|
|
398
418
|
const showExport = Boolean(onExportConversation);
|
|
399
|
-
//
|
|
400
|
-
//
|
|
401
|
-
//
|
|
402
|
-
|
|
403
|
-
//
|
|
404
|
-
//
|
|
405
|
-
const
|
|
419
|
+
// Conversation management turns on once the host wires both the thread list
|
|
420
|
+
// and a selection handler, and the placement isn't `disabled`. Where it
|
|
421
|
+
// renders is `threadPlacement`, resolved below.
|
|
422
|
+
const showThreads = Boolean(threads && onSelectThread) && threadPlacement !== "disabled";
|
|
423
|
+
// Is the chat itself too narrow for a docked list? Measured on the root
|
|
424
|
+
// element, so an embedded/split-pane chat decides on its own width.
|
|
425
|
+
const isNarrow = useIsNarrow(rootRef);
|
|
426
|
+
// `auto` picks the layout for the space available: a left panel while the
|
|
427
|
+
// chat is wide, the tab strip once it is too narrow to spare a column.
|
|
428
|
+
// Every other value is honoured verbatim.
|
|
429
|
+
const placement = threadPlacement === "auto" ? (isNarrow ? "top" : "left") : threadPlacement;
|
|
430
|
+
const tabbedThreads = showThreads && placement === "top";
|
|
431
|
+
// A docked list. Narrow chats render it as an overlay drawer on the same
|
|
432
|
+
// edge instead of an inline column, so the transcript keeps its width.
|
|
433
|
+
const dockedSide = placement === "right" ? "right" : "left";
|
|
434
|
+
const dockedThreads = showThreads && (placement === "left" || placement === "right");
|
|
435
|
+
// Docked-panel open state, controlled when the caller supplies
|
|
436
|
+
// `sidebarOpen` + `onToggleSidebar` (the driver does this and persists the
|
|
437
|
+
// choice); otherwise the view manages a session-only flag. Defaults to open.
|
|
406
438
|
const [internalSidebarOpen, setInternalSidebarOpen] = useState(true);
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
const desktopSidebarOpen = sidebarOpenProp ?? internalSidebarOpen;
|
|
410
|
-
const toggleDesktopSidebar = () => {
|
|
439
|
+
const inlineSidebarOpen = sidebarOpenProp ?? internalSidebarOpen;
|
|
440
|
+
const toggleInlineSidebar = () => {
|
|
411
441
|
if (onToggleSidebar) onToggleSidebar();
|
|
412
442
|
else setInternalSidebarOpen((open) => !open);
|
|
413
443
|
};
|
|
414
|
-
//
|
|
415
|
-
//
|
|
416
|
-
//
|
|
417
|
-
const
|
|
418
|
-
// Mobile drawer is a SESSION-only, default-closed state so a persisted
|
|
419
|
-
// "open" desktop preference never auto-opens the drawer over the chat on a
|
|
420
|
-
// phone. Reset closed whenever we drop back to a mobile viewport.
|
|
421
|
-
const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false);
|
|
444
|
+
// The overlay drawer is SESSION-only and default-closed so a persisted
|
|
445
|
+
// "open" preference never auto-opens a drawer over a narrow chat. Reset
|
|
446
|
+
// closed whenever the chat widens back out to an inline panel.
|
|
447
|
+
const [drawerOpen, setDrawerOpen] = useState(false);
|
|
422
448
|
useEffect(() => {
|
|
423
|
-
if (!
|
|
424
|
-
}, [
|
|
425
|
-
// Unified state/handlers the render + header use, resolved by
|
|
426
|
-
const sidebarOpen =
|
|
449
|
+
if (!isNarrow) setDrawerOpen(false);
|
|
450
|
+
}, [isNarrow]);
|
|
451
|
+
// Unified state/handlers the render + header use, resolved by width.
|
|
452
|
+
const sidebarOpen = isNarrow ? drawerOpen : inlineSidebarOpen;
|
|
427
453
|
const toggleSidebar = () => {
|
|
428
|
-
if (
|
|
429
|
-
else
|
|
454
|
+
if (isNarrow) setDrawerOpen((open) => !open);
|
|
455
|
+
else toggleInlineSidebar();
|
|
430
456
|
};
|
|
431
457
|
// The top bar carries only the sidebar toggle; the model picker, export, and
|
|
432
458
|
// clear controls live in a toolbar row below the composer, closer to where
|
|
433
|
-
// the user is typing. The toggle is a
|
|
434
|
-
// affordance while the inline
|
|
435
|
-
// when that toggle would actually be visible (an open
|
|
436
|
-
// own hide button, leaving nothing for the bar to hold
|
|
437
|
-
|
|
459
|
+
// the user is typing. The toggle is a narrow-layout hamburger, or a "show"
|
|
460
|
+
// affordance while the inline panel is collapsed - so the bar renders only
|
|
461
|
+
// when that toggle would actually be visible (an open inline panel has its
|
|
462
|
+
// own hide button, leaving nothing for the bar to hold, and the tab strip
|
|
463
|
+
// needs no toggle at all).
|
|
464
|
+
const showSidebarToggle = dockedThreads && (isNarrow || !inlineSidebarOpen);
|
|
438
465
|
const showHeader = showSidebarToggle;
|
|
439
466
|
const showComposerToolbar = showModelDisplay || showExport || showClear;
|
|
467
|
+
// Collapse icon points at the edge the panel lives on, matching the hide
|
|
468
|
+
// button inside the panel itself.
|
|
469
|
+
const SidebarToggleIcon = dockedSide === "right" ? PanelRightIcon : PanelLeftIcon;
|
|
440
470
|
|
|
441
|
-
//
|
|
442
|
-
//
|
|
443
|
-
//
|
|
444
|
-
//
|
|
445
|
-
|
|
471
|
+
// The conversation list itself, shared by all three thread surfaces - the
|
|
472
|
+
// overlay drawer, the inline panel, and the tab strip's history menu all
|
|
473
|
+
// render the SAME `ThreadSidebar`, differing only in framing (and, for the
|
|
474
|
+
// transient ones, closing themselves after select / new). Building the bag
|
|
475
|
+
// once keeps the call sites from drifting; framing props (`onHide`, `side`,
|
|
476
|
+
// `className`) are added per site.
|
|
477
|
+
const threadListProps: Omit<ThreadSidebarProps, "onHide" | "side" | "className"> = {
|
|
446
478
|
threads: threads ?? [],
|
|
447
479
|
...(activeThreadId ? { activeThreadId } : {}),
|
|
448
480
|
streamingThreadIds,
|
|
449
481
|
isLoading: isLoadingThreads,
|
|
450
482
|
onSelect: (id) => onSelectThread?.(id),
|
|
451
|
-
onHide: toggleSidebar,
|
|
452
483
|
...(onNewThread ? { onNew: onNewThread } : {}),
|
|
453
484
|
...(onDeleteThread ? { onDelete: onDeleteThread } : {}),
|
|
454
485
|
...(onRenameThread ? { onRename: onRenameThread } : {}),
|
|
455
486
|
...(onCancelThread ? { onCancel: onCancelThread } : {}),
|
|
456
487
|
};
|
|
457
488
|
|
|
489
|
+
// Which conversations are open as tabs in the `top` placement (session
|
|
490
|
+
// state; the strip reseeds from the newest conversations on the next load).
|
|
491
|
+
// The sync keeps the list in step with the thread list and the selection,
|
|
492
|
+
// and returns the same array when nothing changed so this effect settles.
|
|
493
|
+
const [openTabIds, setOpenTabIds] = useState<string[]>([]);
|
|
494
|
+
useEffect(() => {
|
|
495
|
+
if (!tabbedThreads) return;
|
|
496
|
+
setOpenTabIds((prev) => syncThreadTabs(prev, threads ?? [], activeThreadId));
|
|
497
|
+
}, [tabbedThreads, threads, activeThreadId]);
|
|
498
|
+
|
|
499
|
+
// Close a tab. Closing the ACTIVE one has to move the selection too, or the
|
|
500
|
+
// sync above would immediately reopen it: switch to a neighbouring tab, or
|
|
501
|
+
// start a fresh conversation when that was the last one open.
|
|
502
|
+
const closeTab = (threadId: string) => {
|
|
503
|
+
const fallback = nextActiveThreadTab(openTabIds, threadId);
|
|
504
|
+
setOpenTabIds((prev) => closeThreadTab(prev, threadId));
|
|
505
|
+
if (threadId !== activeThreadId) return;
|
|
506
|
+
if (fallback) onSelectThread?.(fallback);
|
|
507
|
+
else onNewThread?.();
|
|
508
|
+
};
|
|
509
|
+
|
|
458
510
|
// Clear confirmation is an AppKit `AlertDialog` (a real modal), plus an
|
|
459
511
|
// in-flight flag so the DELETE can't be double-fired. `clearing` disables
|
|
460
512
|
// the confirm action while `onClear` runs; the dialog closes on settle.
|
|
@@ -475,34 +527,47 @@ export const ChatView = ({
|
|
|
475
527
|
return (
|
|
476
528
|
<TooltipProvider delayDuration={200}>
|
|
477
529
|
{/*
|
|
478
|
-
* Outer row hosts the optional conversation
|
|
479
|
-
* chat column
|
|
530
|
+
* Outer row hosts the optional docked conversation list beside the
|
|
531
|
+
* chat column (`flex-row-reverse` puts it on the right edge without a
|
|
532
|
+
* second render path). The chat column owns the vertical layout and the
|
|
480
533
|
* scroll; the centered `max-w-4xl` framing lives on each section
|
|
481
|
-
* (header, transcript, suggestions, composer) instead of the
|
|
534
|
+
* (tabs, header, transcript, suggestions, composer) instead of the
|
|
482
535
|
* outer shell, so the scroll area's scrollbar sits at the far
|
|
483
536
|
* right - outside the centered column - and the composer lines up
|
|
484
537
|
* with the message column regardless of whether a scrollbar is
|
|
485
538
|
* showing.
|
|
486
539
|
*/}
|
|
487
|
-
<div
|
|
488
|
-
{
|
|
489
|
-
|
|
540
|
+
<div
|
|
541
|
+
ref={rootRef}
|
|
542
|
+
className={cn(
|
|
543
|
+
"flex h-full min-h-0",
|
|
544
|
+
dockedThreads && dockedSide === "right" && "flex-row-reverse",
|
|
545
|
+
className,
|
|
546
|
+
)}
|
|
547
|
+
>
|
|
548
|
+
{dockedThreads &&
|
|
549
|
+
(isNarrow
|
|
490
550
|
? /*
|
|
491
|
-
*
|
|
492
|
-
* the conversation list never eats
|
|
493
|
-
*
|
|
494
|
-
*
|
|
495
|
-
* + default closed
|
|
551
|
+
* Narrow: a fixed overlay drawer on the docked edge with a
|
|
552
|
+
* tap-to-close backdrop, so the conversation list never eats
|
|
553
|
+
* horizontal space from an already-cramped chat. Selecting a
|
|
554
|
+
* thread / starting a new one also closes the drawer so the
|
|
555
|
+
* transcript comes back into view. Session-only + default closed
|
|
556
|
+
* (see `drawerOpen`).
|
|
496
557
|
*/
|
|
497
|
-
|
|
498
|
-
<div
|
|
558
|
+
drawerOpen && (
|
|
559
|
+
<div
|
|
560
|
+
className={cn("fixed inset-0 z-40 flex", dockedSide === "right" && "justify-end")}
|
|
561
|
+
>
|
|
499
562
|
<div
|
|
500
563
|
className="absolute inset-0 bg-black/50"
|
|
501
564
|
onClick={toggleSidebar}
|
|
502
565
|
aria-hidden="true"
|
|
503
566
|
/>
|
|
504
567
|
<ThreadSidebar
|
|
505
|
-
{...
|
|
568
|
+
{...threadListProps}
|
|
569
|
+
onHide={toggleSidebar}
|
|
570
|
+
side={dockedSide}
|
|
506
571
|
onSelect={(id) => {
|
|
507
572
|
onSelectThread?.(id);
|
|
508
573
|
toggleSidebar();
|
|
@@ -520,23 +585,38 @@ export const ChatView = ({
|
|
|
520
585
|
</div>
|
|
521
586
|
)
|
|
522
587
|
: /*
|
|
523
|
-
*
|
|
524
|
-
* column, using the persisted open/hide preference. Same
|
|
525
|
-
*
|
|
526
|
-
* differ.
|
|
588
|
+
* Wide: an inline flex child sharing the row with the chat
|
|
589
|
+
* column, using the persisted open/hide preference. Same list as
|
|
590
|
+
* the drawer - only the framing + close-on-select differ.
|
|
527
591
|
*/
|
|
528
|
-
|
|
592
|
+
inlineSidebarOpen && (
|
|
593
|
+
<ThreadSidebar {...threadListProps} onHide={toggleSidebar} side={dockedSide} />
|
|
594
|
+
))}
|
|
529
595
|
<div className="flex h-full min-w-0 flex-1 flex-col">
|
|
596
|
+
{tabbedThreads && (
|
|
597
|
+
/*
|
|
598
|
+
* `top` placement: the open conversations as an editor-style tab
|
|
599
|
+
* strip, with the rest reachable through its history menu. Takes
|
|
600
|
+
* the place of both the docked panel and the header toggle.
|
|
601
|
+
*/
|
|
602
|
+
<ThreadTabs {...threadListProps} openThreadIds={openTabIds} onCloseTab={closeTab} />
|
|
603
|
+
)}
|
|
530
604
|
{showHeader && (
|
|
531
605
|
/*
|
|
532
|
-
* Slim top bar holding the
|
|
533
|
-
* a persistent hamburger (the overlay drawer has no
|
|
534
|
-
* hide button);
|
|
535
|
-
* while the inline
|
|
536
|
-
* own hide button). `showHeader` already tracks that
|
|
537
|
-
* the bar never renders empty.
|
|
606
|
+
* Slim top bar holding the docked panel's toggle. On a narrow chat
|
|
607
|
+
* it's a persistent hamburger (the overlay drawer has no
|
|
608
|
+
* always-visible hide button); otherwise it's a "show" affordance
|
|
609
|
+
* rendered only while the inline panel is collapsed (an open panel
|
|
610
|
+
* has its own hide button). `showHeader` already tracks that
|
|
611
|
+
* visibility, so the bar never renders empty.
|
|
538
612
|
*/
|
|
539
|
-
<div
|
|
613
|
+
<div
|
|
614
|
+
className={cn(
|
|
615
|
+
"mx-auto flex w-full max-w-4xl items-center gap-2 px-3 pb-2 pt-1 text-xs text-muted-foreground md:gap-3 md:px-6",
|
|
616
|
+
// Keep the toggle on the same edge as the panel it opens.
|
|
617
|
+
dockedSide === "right" && "justify-end",
|
|
618
|
+
)}
|
|
619
|
+
>
|
|
540
620
|
<Tooltip>
|
|
541
621
|
<TooltipTrigger asChild>
|
|
542
622
|
<Button
|
|
@@ -546,7 +626,7 @@ export const ChatView = ({
|
|
|
546
626
|
onClick={toggleSidebar}
|
|
547
627
|
aria-label={sidebarOpen ? "Hide conversations" : "Show conversations"}
|
|
548
628
|
>
|
|
549
|
-
<
|
|
629
|
+
<SidebarToggleIcon className="size-4" />
|
|
550
630
|
</Button>
|
|
551
631
|
</TooltipTrigger>
|
|
552
632
|
<TooltipContent>
|
package/src/react/index.ts
CHANGED
|
@@ -27,6 +27,9 @@ export { ExportMenu } from "./export-menu.tsx";
|
|
|
27
27
|
export { MastraChat, useMastraChat } from "./mastra-chat.tsx";
|
|
28
28
|
export type { MastraChatProps, UseMastraChatOptions } from "./mastra-chat.tsx";
|
|
29
29
|
export { ThreadSidebar } from "./thread-sidebar.tsx";
|
|
30
|
+
export type { ThreadSidebarProps } from "./thread-sidebar.tsx";
|
|
31
|
+
export { ThreadTabs } from "./thread-tabs.tsx";
|
|
32
|
+
export type { ThreadTabsProps } from "./thread-tabs.tsx";
|
|
30
33
|
export type {
|
|
31
34
|
ApprovalDecision,
|
|
32
35
|
ChatModelOption,
|
|
@@ -37,6 +40,7 @@ export type {
|
|
|
37
40
|
FeedbackValue,
|
|
38
41
|
MessageFeedback,
|
|
39
42
|
PendingApproval,
|
|
43
|
+
ThreadPlacement,
|
|
40
44
|
ThreadSummary,
|
|
41
45
|
ToolEvent,
|
|
42
46
|
ToolProgress,
|
|
@@ -10,6 +10,7 @@ import type {
|
|
|
10
10
|
ChatViewProps,
|
|
11
11
|
FeedbackSubmission,
|
|
12
12
|
MessageFeedback,
|
|
13
|
+
ThreadPlacement,
|
|
13
14
|
ThreadSummary,
|
|
14
15
|
ToolEvent,
|
|
15
16
|
ToolProgress,
|
|
@@ -192,8 +193,20 @@ export interface UseMastraChatOptions {
|
|
|
192
193
|
* them / start new ones / delete them. On by default. Set `false` for
|
|
193
194
|
* the classic single-thread chat anchored to the per-session cookie
|
|
194
195
|
* (no sidebar, no thread tracking).
|
|
196
|
+
*
|
|
197
|
+
* Shorthand for {@link threadPlacement}: `false` is `"disabled"`. When both
|
|
198
|
+
* are passed, `enableThreads: false` wins.
|
|
195
199
|
*/
|
|
196
200
|
enableThreads?: boolean;
|
|
201
|
+
/**
|
|
202
|
+
* Where conversation management renders: `"left"` / `"right"` dock the list
|
|
203
|
+
* to that edge, `"top"` shows the open conversations as an editor-style tab
|
|
204
|
+
* strip with a history menu, `"disabled"` turns thread management off, and
|
|
205
|
+
* the default `"auto"` picks `"left"` while the chat is wide enough for a
|
|
206
|
+
* side panel and `"top"` once it gets too narrow for one (measured on the
|
|
207
|
+
* chat's own width, so an embedded panel decides on its own space).
|
|
208
|
+
*/
|
|
209
|
+
threadPlacement?: ThreadPlacement;
|
|
197
210
|
/**
|
|
198
211
|
* Enable chat export. Off by default (opt-in). When on, the header
|
|
199
212
|
* shows an "Export" menu for the whole conversation and each assistant
|
|
@@ -257,7 +270,11 @@ export const useMastraChat = (
|
|
|
257
270
|
// conversations a user owns. Each call (stream / history / clear) carries
|
|
258
271
|
// its thread id per request, so many threads can run concurrently without
|
|
259
272
|
// sharing routing state.
|
|
260
|
-
|
|
273
|
+
// `enableThreads: false` is the legacy way to say "no thread management", so
|
|
274
|
+
// it collapses into the placement rather than living as a second flag.
|
|
275
|
+
const threadPlacement: ThreadPlacement =
|
|
276
|
+
options.enableThreads === false ? "disabled" : (options.threadPlacement ?? "auto");
|
|
277
|
+
const enableThreads = threadPlacement !== "disabled";
|
|
261
278
|
// Export is opt-in (default off): the host turns it on explicitly.
|
|
262
279
|
const enableExport = options.enableExport === true;
|
|
263
280
|
// Feedback defaults to export's setting; an explicit option overrides.
|
|
@@ -1427,6 +1444,7 @@ export const useMastraChat = (
|
|
|
1427
1444
|
hasMore: activeSession.hasMoreHistory,
|
|
1428
1445
|
isLoadingHistory,
|
|
1429
1446
|
onClear: handleClear,
|
|
1447
|
+
threadPlacement,
|
|
1430
1448
|
// Conversation management: hand ChatView the thread list + handlers
|
|
1431
1449
|
// only when enabled, so the sidebar stays hidden for the classic
|
|
1432
1450
|
// single-thread chat (ChatView keys the sidebar off these props).
|
|
@@ -1485,8 +1503,9 @@ export interface MastraChatProps extends UseMastraChatOptions {
|
|
|
1485
1503
|
* history pagination, and built-in conversation management (a sidebar
|
|
1486
1504
|
* of the resource's threads with select / new / delete, persisted
|
|
1487
1505
|
* across reloads) - all with no host wiring. The model picker is opt-in
|
|
1488
|
-
* via `showModelPicker`; thread management is on by default
|
|
1489
|
-
*
|
|
1506
|
+
* via `showModelPicker`; thread management is on by default, laid out per
|
|
1507
|
+
* `threadPlacement` (`auto` docks it left and falls back to a tab strip on a
|
|
1508
|
+
* narrow chat) and turned off with `threadPlacement: "disabled"`.
|
|
1490
1509
|
*/
|
|
1491
1510
|
export const MastraChat = ({ className, ...options }: MastraChatProps) => {
|
|
1492
1511
|
const chat = useMastraChat(options);
|
|
@@ -12,12 +12,14 @@ import {
|
|
|
12
12
|
Loader2Icon,
|
|
13
13
|
MessageSquarePlusIcon,
|
|
14
14
|
PanelLeftIcon,
|
|
15
|
+
PanelRightIcon,
|
|
15
16
|
PencilIcon,
|
|
16
17
|
SquareIcon,
|
|
17
18
|
Trash2Icon,
|
|
18
19
|
} from "lucide-react";
|
|
19
20
|
import { useState } from "react";
|
|
20
21
|
import type { ThreadSummary } from "./types.ts";
|
|
22
|
+
import { relativeTime, threadTitle } from "../support/thread-labels.ts";
|
|
21
23
|
|
|
22
24
|
// Presentational conversation list. Renders the threads a resource owns
|
|
23
25
|
// so the user can switch between them, start a new one, rename one, and
|
|
@@ -51,6 +53,11 @@ export interface ThreadSidebarProps {
|
|
|
51
53
|
onCancel?: (threadId: string) => void;
|
|
52
54
|
/** Collapse the sidebar. Renders the hide button in the header when provided. */
|
|
53
55
|
onHide?: () => void;
|
|
56
|
+
/**
|
|
57
|
+
* Edge the sidebar is docked to. Only affects framing - which side carries
|
|
58
|
+
* the divider, and which way the collapse icon points. Defaults to `left`.
|
|
59
|
+
*/
|
|
60
|
+
side?: "left" | "right";
|
|
54
61
|
/** Extra classes merged onto the sidebar root. */
|
|
55
62
|
className?: string;
|
|
56
63
|
}
|
|
@@ -76,8 +83,12 @@ export const ThreadSidebar = ({
|
|
|
76
83
|
onRename,
|
|
77
84
|
onCancel,
|
|
78
85
|
onHide,
|
|
86
|
+
side = "left",
|
|
79
87
|
className,
|
|
80
88
|
}: ThreadSidebarProps) => {
|
|
89
|
+
// The collapse icon points at the edge the panel lives on, so the same
|
|
90
|
+
// button reads as "tuck this away" on either side.
|
|
91
|
+
const HideIcon = side === "right" ? PanelRightIcon : PanelLeftIcon;
|
|
81
92
|
// Thread id armed for deletion (first trash click). A second click on
|
|
82
93
|
// the same row confirms; clicking elsewhere / another row resets it.
|
|
83
94
|
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
|
|
@@ -119,7 +130,8 @@ export const ThreadSidebar = ({
|
|
|
119
130
|
return (
|
|
120
131
|
<div
|
|
121
132
|
className={cn(
|
|
122
|
-
"flex h-full w-64 shrink-0 flex-col border-
|
|
133
|
+
"flex h-full w-64 shrink-0 flex-col border-border bg-background",
|
|
134
|
+
side === "right" ? "border-l" : "border-r",
|
|
123
135
|
className,
|
|
124
136
|
)}
|
|
125
137
|
>
|
|
@@ -148,7 +160,7 @@ export const ThreadSidebar = ({
|
|
|
148
160
|
aria-label="Hide conversations"
|
|
149
161
|
className={cn("size-8 shrink-0", !onNew && "ml-auto")}
|
|
150
162
|
>
|
|
151
|
-
<
|
|
163
|
+
<HideIcon className="size-4" />
|
|
152
164
|
</Button>
|
|
153
165
|
</TooltipTrigger>
|
|
154
166
|
<TooltipContent>Hide conversations</TooltipContent>
|
|
@@ -315,28 +327,3 @@ export const ThreadSidebar = ({
|
|
|
315
327
|
</div>
|
|
316
328
|
);
|
|
317
329
|
};
|
|
318
|
-
|
|
319
|
-
/** Title for a thread row, falling back to a placeholder when unnamed. */
|
|
320
|
-
function threadTitle(thread: ThreadSummary): string {
|
|
321
|
-
const title = thread.title?.trim();
|
|
322
|
-
return title && title.length > 0 ? title : "New conversation";
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
/**
|
|
326
|
-
* Render an ISO-8601 timestamp as a coarse "time ago" hint
|
|
327
|
-
* (`just now`, `5m ago`, `3h ago`, `2d ago`, or a locale date for
|
|
328
|
-
* anything older than a week). Invalid input renders nothing.
|
|
329
|
-
*/
|
|
330
|
-
function relativeTime(iso: string): string {
|
|
331
|
-
const then = new Date(iso).getTime();
|
|
332
|
-
if (Number.isNaN(then)) return "";
|
|
333
|
-
const diffMs = Date.now() - then;
|
|
334
|
-
const minutes = Math.floor(diffMs / 60_000);
|
|
335
|
-
if (minutes < 1) return "just now";
|
|
336
|
-
if (minutes < 60) return `${minutes}m ago`;
|
|
337
|
-
const hours = Math.floor(minutes / 60);
|
|
338
|
-
if (hours < 24) return `${hours}h ago`;
|
|
339
|
-
const days = Math.floor(hours / 24);
|
|
340
|
-
if (days < 7) return `${days}d ago`;
|
|
341
|
-
return new Date(then).toLocaleDateString();
|
|
342
|
-
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Button,
|
|
3
|
+
Popover,
|
|
4
|
+
PopoverContent,
|
|
5
|
+
PopoverTrigger,
|
|
6
|
+
Tooltip,
|
|
7
|
+
TooltipContent,
|
|
8
|
+
TooltipTrigger,
|
|
9
|
+
cn,
|
|
10
|
+
} from "@dbx-tools/ui-appkit/react";
|
|
11
|
+
import { HistoryIcon, Loader2Icon, PlusIcon, XIcon } from "lucide-react";
|
|
12
|
+
import { useEffect, useRef, useState } from "react";
|
|
13
|
+
import { ThreadSidebar, type ThreadSidebarProps } from "./thread-sidebar.tsx";
|
|
14
|
+
import { threadTitle } from "../support/thread-labels.ts";
|
|
15
|
+
|
|
16
|
+
// Editor-style conversation tabs: the open conversations across the top of the
|
|
17
|
+
// chat, plus a "new chat" button and a history menu holding every other
|
|
18
|
+
// conversation. Used by the `top` thread placement, where a side panel would
|
|
19
|
+
// eat too much of a narrow chat.
|
|
20
|
+
//
|
|
21
|
+
// The history menu reuses `ThreadSidebar` verbatim inside a popover rather than
|
|
22
|
+
// reimplementing a list, so rename / delete / cancel / streaming behave exactly
|
|
23
|
+
// as they do in the docked placements.
|
|
24
|
+
|
|
25
|
+
/** Props for {@link ThreadTabs}. */
|
|
26
|
+
export interface ThreadTabsProps extends Omit<ThreadSidebarProps, "onHide" | "side"> {
|
|
27
|
+
/**
|
|
28
|
+
* Ids of the conversations open as tabs, left to right. Ids without a
|
|
29
|
+
* matching {@link ThreadSidebarProps.threads} entry still render (a
|
|
30
|
+
* brand-new conversation isn't in the server list yet).
|
|
31
|
+
*/
|
|
32
|
+
openThreadIds: string[];
|
|
33
|
+
/**
|
|
34
|
+
* Take a conversation off the strip. The conversation itself is kept - use
|
|
35
|
+
* {@link ThreadSidebarProps.onDelete} to remove it. Per-tab close
|
|
36
|
+
* affordance hidden when omitted.
|
|
37
|
+
*/
|
|
38
|
+
onCloseTab?: (threadId: string) => void;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Tab strip for the `top` thread placement. Each open conversation is a tab
|
|
43
|
+
* showing its title, a spinner while it streams in the background, and a close
|
|
44
|
+
* affordance; a trailing `+` starts a fresh conversation and a history button
|
|
45
|
+
* opens the full conversation list (a {@link ThreadSidebar} in a popover) so
|
|
46
|
+
* anything not currently tabbed is one click away. The active tab is scrolled
|
|
47
|
+
* into view when the selection changes, so switching from history never leaves
|
|
48
|
+
* the current conversation off-screen.
|
|
49
|
+
*/
|
|
50
|
+
export const ThreadTabs = ({ openThreadIds, onCloseTab, className, ...list }: ThreadTabsProps) => {
|
|
51
|
+
const { threads, activeThreadId, streamingThreadIds = [], onSelect, onNew } = list;
|
|
52
|
+
// Controlled so picking a conversation (or starting one) dismisses the menu
|
|
53
|
+
// instead of leaving it open over the transcript.
|
|
54
|
+
const [historyOpen, setHistoryOpen] = useState(false);
|
|
55
|
+
const activeTabRef = useRef<HTMLDivElement | null>(null);
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
activeTabRef.current?.scrollIntoView({ block: "nearest", inline: "nearest" });
|
|
58
|
+
}, [activeThreadId]);
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
// The divider spans the whole chat width while the strip's contents stay
|
|
62
|
+
// in the same centered column as the transcript and composer.
|
|
63
|
+
<div className={cn("w-full border-b border-border text-xs", className)}>
|
|
64
|
+
<div className="mx-auto flex w-full max-w-4xl items-center gap-1 px-2 py-1 md:px-4">
|
|
65
|
+
<div role="tablist" className="flex min-w-0 flex-1 items-center gap-1 overflow-x-auto">
|
|
66
|
+
{openThreadIds.map((id) => {
|
|
67
|
+
const thread = threads.find((t) => t.id === id) ?? { id };
|
|
68
|
+
const isActive = id === activeThreadId;
|
|
69
|
+
const isStreaming = streamingThreadIds.includes(id);
|
|
70
|
+
const title = threadTitle(thread);
|
|
71
|
+
return (
|
|
72
|
+
<Tooltip key={id}>
|
|
73
|
+
<TooltipTrigger asChild>
|
|
74
|
+
<div
|
|
75
|
+
ref={isActive ? activeTabRef : undefined}
|
|
76
|
+
role="tab"
|
|
77
|
+
aria-selected={isActive}
|
|
78
|
+
tabIndex={0}
|
|
79
|
+
onClick={() => onSelect(id)}
|
|
80
|
+
onKeyDown={(e) => {
|
|
81
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
82
|
+
e.preventDefault();
|
|
83
|
+
onSelect(id);
|
|
84
|
+
}
|
|
85
|
+
}}
|
|
86
|
+
className={cn(
|
|
87
|
+
"group flex max-w-[12rem] shrink-0 cursor-pointer items-center gap-1.5",
|
|
88
|
+
"rounded-md border border-transparent px-2 py-1",
|
|
89
|
+
"hover:bg-accent hover:text-accent-foreground",
|
|
90
|
+
isActive && "border-border bg-accent text-accent-foreground",
|
|
91
|
+
)}
|
|
92
|
+
>
|
|
93
|
+
{isStreaming && (
|
|
94
|
+
<Loader2Icon
|
|
95
|
+
aria-label="Streaming"
|
|
96
|
+
className="size-3 shrink-0 animate-spin text-primary"
|
|
97
|
+
/>
|
|
98
|
+
)}
|
|
99
|
+
<span className="truncate">{title}</span>
|
|
100
|
+
{onCloseTab && (
|
|
101
|
+
<Button
|
|
102
|
+
type="button"
|
|
103
|
+
variant="ghost"
|
|
104
|
+
size="icon"
|
|
105
|
+
onClick={(e) => {
|
|
106
|
+
e.stopPropagation();
|
|
107
|
+
onCloseTab(id);
|
|
108
|
+
}}
|
|
109
|
+
aria-label="Close tab"
|
|
110
|
+
className={cn(
|
|
111
|
+
"size-4 shrink-0",
|
|
112
|
+
// The active tab always shows its close button; the
|
|
113
|
+
// rest reveal one on hover / keyboard focus so the
|
|
114
|
+
// strip stays quiet.
|
|
115
|
+
!isActive &&
|
|
116
|
+
"opacity-0 group-hover:opacity-100 focus-visible:opacity-100",
|
|
117
|
+
)}
|
|
118
|
+
>
|
|
119
|
+
<XIcon className="size-3" />
|
|
120
|
+
</Button>
|
|
121
|
+
)}
|
|
122
|
+
</div>
|
|
123
|
+
</TooltipTrigger>
|
|
124
|
+
<TooltipContent>{title}</TooltipContent>
|
|
125
|
+
</Tooltip>
|
|
126
|
+
);
|
|
127
|
+
})}
|
|
128
|
+
</div>
|
|
129
|
+
{onNew && (
|
|
130
|
+
<Tooltip>
|
|
131
|
+
<TooltipTrigger asChild>
|
|
132
|
+
<Button
|
|
133
|
+
type="button"
|
|
134
|
+
variant="ghost"
|
|
135
|
+
size="icon"
|
|
136
|
+
onClick={onNew}
|
|
137
|
+
aria-label="New chat"
|
|
138
|
+
className="size-7 shrink-0"
|
|
139
|
+
>
|
|
140
|
+
<PlusIcon className="size-4" />
|
|
141
|
+
</Button>
|
|
142
|
+
</TooltipTrigger>
|
|
143
|
+
<TooltipContent>New chat</TooltipContent>
|
|
144
|
+
</Tooltip>
|
|
145
|
+
)}
|
|
146
|
+
<Popover open={historyOpen} onOpenChange={setHistoryOpen}>
|
|
147
|
+
<Tooltip>
|
|
148
|
+
<TooltipTrigger asChild>
|
|
149
|
+
<PopoverTrigger asChild>
|
|
150
|
+
<Button
|
|
151
|
+
type="button"
|
|
152
|
+
variant="ghost"
|
|
153
|
+
size="icon"
|
|
154
|
+
aria-label="Conversation history"
|
|
155
|
+
className="size-7 shrink-0"
|
|
156
|
+
>
|
|
157
|
+
<HistoryIcon className="size-4" />
|
|
158
|
+
</Button>
|
|
159
|
+
</PopoverTrigger>
|
|
160
|
+
</TooltipTrigger>
|
|
161
|
+
<TooltipContent>Conversation history</TooltipContent>
|
|
162
|
+
</Tooltip>
|
|
163
|
+
<PopoverContent align="end" className="w-72 p-0">
|
|
164
|
+
{/*
|
|
165
|
+
* The same list the docked placements render, just framed as a menu:
|
|
166
|
+
* selecting (or starting) a conversation opens it as a tab and
|
|
167
|
+
* dismisses the popover, while rename / delete / cancel work in
|
|
168
|
+
* place.
|
|
169
|
+
*/}
|
|
170
|
+
<ThreadSidebar
|
|
171
|
+
{...list}
|
|
172
|
+
onSelect={(id) => {
|
|
173
|
+
onSelect(id);
|
|
174
|
+
setHistoryOpen(false);
|
|
175
|
+
}}
|
|
176
|
+
{...(onNew
|
|
177
|
+
? {
|
|
178
|
+
onNew: () => {
|
|
179
|
+
onNew();
|
|
180
|
+
setHistoryOpen(false);
|
|
181
|
+
},
|
|
182
|
+
}
|
|
183
|
+
: {})}
|
|
184
|
+
className="h-96 w-full border-none"
|
|
185
|
+
/>
|
|
186
|
+
</PopoverContent>
|
|
187
|
+
</Popover>
|
|
188
|
+
</div>
|
|
189
|
+
</div>
|
|
190
|
+
);
|
|
191
|
+
};
|
package/src/react/types.ts
CHANGED
|
@@ -87,6 +87,21 @@ export type ThreadSummary = {
|
|
|
87
87
|
updatedAt?: string;
|
|
88
88
|
};
|
|
89
89
|
|
|
90
|
+
/**
|
|
91
|
+
* Where conversation management lives in the chat's layout, and whether it
|
|
92
|
+
* exists at all:
|
|
93
|
+
*
|
|
94
|
+
* - `disabled` - no thread UI (the classic single-thread chat).
|
|
95
|
+
* - `auto` - `left` while the chat is wide enough for a side panel, `top`
|
|
96
|
+
* once it gets too narrow for one. Measured off the chat's own width, so
|
|
97
|
+
* an embedded panel switches on its own space rather than the viewport's.
|
|
98
|
+
* - `left` / `right` - a conversation list docked to that edge, collapsing to
|
|
99
|
+
* an overlay drawer on the same edge when the chat is narrow.
|
|
100
|
+
* - `top` - a tab strip of open conversations above the transcript, with a
|
|
101
|
+
* history menu listing every other conversation.
|
|
102
|
+
*/
|
|
103
|
+
export type ThreadPlacement = "disabled" | "auto" | "left" | "right" | "top";
|
|
104
|
+
|
|
90
105
|
export type ChatViewProps = {
|
|
91
106
|
messages: UIMessage[];
|
|
92
107
|
status: ChatStatus;
|
|
@@ -217,6 +232,13 @@ export type ChatViewProps = {
|
|
|
217
232
|
* the classic single-thread chat with no sidebar.
|
|
218
233
|
*/
|
|
219
234
|
threads?: ThreadSummary[];
|
|
235
|
+
/**
|
|
236
|
+
* Where the conversation list renders (or `"disabled"` to hide it even when
|
|
237
|
+
* {@link threads} and {@link onSelectThread} are wired). Defaults to
|
|
238
|
+
* `"auto"`: a left side panel while the chat is wide, a tab strip once it
|
|
239
|
+
* is too narrow for one. See {@link ThreadPlacement}.
|
|
240
|
+
*/
|
|
241
|
+
threadPlacement?: ThreadPlacement;
|
|
220
242
|
/** Id of the currently-active thread, highlighted in the sidebar. */
|
|
221
243
|
activeThreadId?: string;
|
|
222
244
|
/**
|
|
@@ -265,7 +287,8 @@ export type ChatViewProps = {
|
|
|
265
287
|
* this together with {@link onToggleSidebar} to control and persist
|
|
266
288
|
* the show/hide choice from the host (the driver does this so the
|
|
267
289
|
* choice survives reloads). The header toggle is shown whenever the
|
|
268
|
-
* sidebar is enabled, regardless of who owns the state.
|
|
290
|
+
* sidebar is enabled, regardless of who owns the state. Only applies to
|
|
291
|
+
* the `left` / `right` placements; the `top` tab strip is always visible.
|
|
269
292
|
*/
|
|
270
293
|
sidebarOpen?: boolean;
|
|
271
294
|
/**
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { ThreadSummary } from "../react/types.ts";
|
|
2
|
+
|
|
3
|
+
// Display formatting for a conversation row, shared by the two surfaces that
|
|
4
|
+
// list threads (`ThreadSidebar` and `ThreadTabs`) so a sidebar row and a tab
|
|
5
|
+
// never disagree about what an untitled or freshly-updated thread reads as.
|
|
6
|
+
|
|
7
|
+
/** Title for a thread, falling back to a placeholder when unnamed. */
|
|
8
|
+
export function threadTitle(thread: ThreadSummary): string {
|
|
9
|
+
const title = thread.title?.trim();
|
|
10
|
+
return title && title.length > 0 ? title : "New conversation";
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Render an ISO-8601 timestamp as a coarse "time ago" hint
|
|
15
|
+
* (`just now`, `5m ago`, `3h ago`, `2d ago`, or a locale date for
|
|
16
|
+
* anything older than a week). Invalid input renders nothing.
|
|
17
|
+
*/
|
|
18
|
+
export function relativeTime(iso: string): string {
|
|
19
|
+
const then = new Date(iso).getTime();
|
|
20
|
+
if (Number.isNaN(then)) return "";
|
|
21
|
+
const diffMs = Date.now() - then;
|
|
22
|
+
const minutes = Math.floor(diffMs / 60_000);
|
|
23
|
+
if (minutes < 1) return "just now";
|
|
24
|
+
if (minutes < 60) return `${minutes}m ago`;
|
|
25
|
+
const hours = Math.floor(minutes / 60);
|
|
26
|
+
if (hours < 24) return `${hours}h ago`;
|
|
27
|
+
const days = Math.floor(hours / 24);
|
|
28
|
+
if (days < 7) return `${days}d ago`;
|
|
29
|
+
return new Date(then).toLocaleDateString();
|
|
30
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { ThreadSummary } from "../react/types.ts";
|
|
2
|
+
|
|
3
|
+
// Which conversations are open as tabs in the `top` thread placement, kept as
|
|
4
|
+
// pure functions so the tab strip's bookkeeping is testable without a DOM.
|
|
5
|
+
//
|
|
6
|
+
// A tab is an OPEN conversation, not the conversation itself: closing a tab
|
|
7
|
+
// only takes it off the strip (the thread stays in history and in the list the
|
|
8
|
+
// history menu shows), while deleting a thread removes it everywhere. The set
|
|
9
|
+
// is session-scoped - the strip reseeds from the most recent conversations on
|
|
10
|
+
// the next load - so a reload never resurrects a stale tab for a thread the
|
|
11
|
+
// user has since deleted.
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* How many of the most recent conversations the strip opens as tabs the first
|
|
15
|
+
* time the thread list arrives. Sized to fill a typical strip without pushing
|
|
16
|
+
* the active tab off-screen behind horizontal scroll.
|
|
17
|
+
*/
|
|
18
|
+
export const THREAD_TAB_SEED_MAX = 5;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Reconcile the open tabs against the current thread list and selection:
|
|
22
|
+
* drop tabs whose conversation no longer exists, seed from the newest
|
|
23
|
+
* conversations while the strip is empty, and always keep a tab for the active
|
|
24
|
+
* thread (a brand-new one isn't in the server list yet, so it's kept on the
|
|
25
|
+
* strength of being active alone).
|
|
26
|
+
*
|
|
27
|
+
* Returns `openIds` itself when nothing changed, so a caller can drive this
|
|
28
|
+
* from an effect without looping on a fresh array every render.
|
|
29
|
+
*/
|
|
30
|
+
export function syncThreadTabs(
|
|
31
|
+
openIds: string[],
|
|
32
|
+
threads: ThreadSummary[],
|
|
33
|
+
activeThreadId?: string,
|
|
34
|
+
seedMax: number = THREAD_TAB_SEED_MAX,
|
|
35
|
+
): string[] {
|
|
36
|
+
const known = new Set(threads.map((thread) => thread.id));
|
|
37
|
+
let next = openIds.filter((id) => known.has(id) || id === activeThreadId);
|
|
38
|
+
if (next.length === 0) next = threads.slice(0, seedMax).map((thread) => thread.id);
|
|
39
|
+
if (activeThreadId && !next.includes(activeThreadId)) next = [...next, activeThreadId];
|
|
40
|
+
const unchanged = next.length === openIds.length && next.every((id, i) => id === openIds[i]);
|
|
41
|
+
return unchanged ? openIds : next;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Take a conversation off the strip, leaving the rest in order. */
|
|
45
|
+
export function closeThreadTab(openIds: string[], id: string): string[] {
|
|
46
|
+
return openIds.filter((tab) => tab !== id);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Which tab to activate after closing `id`: the one to its right, else its
|
|
51
|
+
* left neighbour, else `undefined` when it was the only tab open (the caller
|
|
52
|
+
* then starts a fresh conversation rather than leaving nothing selected).
|
|
53
|
+
*/
|
|
54
|
+
export function nextActiveThreadTab(openIds: string[], id: string): string | undefined {
|
|
55
|
+
const index = openIds.indexOf(id);
|
|
56
|
+
if (index === -1) return undefined;
|
|
57
|
+
if (index + 1 < openIds.length) return openIds[index + 1];
|
|
58
|
+
if (index > 0) return openIds[index - 1];
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|