@dbx-tools/ui-mastra 0.6.0 → 0.6.1

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 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
- enableThreads
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
- - `enableThreads` turns on persisted conversation selection and the sidebar.
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
- enableThreads: true,
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`, `PendingApproval`,
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.0",
29
- "@dbx-tools/shared-mastra": "0.6.0",
30
- "@dbx-tools/ui-branding": "0.6.0",
31
- "@dbx-tools/ui-appkit": "0.6.0",
32
- "@dbx-tools/shared-genie": "0.6.0",
33
- "@dbx-tools/shared-model": "0.6.0"
28
+ "@dbx-tools/shared-core": "0.6.1",
29
+ "@dbx-tools/shared-genie": "0.6.1",
30
+ "@dbx-tools/shared-mastra": "0.6.1",
31
+ "@dbx-tools/ui-appkit": "0.6.1",
32
+ "@dbx-tools/shared-model": "0.6.1",
33
+ "@dbx-tools/ui-branding": "0.6.1"
34
34
  },
35
35
  "license": "UNLICENSED",
36
36
  "publishConfig": {
37
37
  "access": "public"
38
38
  },
39
- "version": "0.6.0",
39
+ "version": "0.6.1",
40
40
  "type": "module",
41
41
  "exports": {
42
42
  "./react": "./src/react/index.ts",
@@ -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
- /** Tailwind's `md` breakpoint (px). Below this the sidebar becomes a drawer. */
78
- const MOBILE_BREAKPOINT_PX = 768;
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` on a phone-width viewport (< {@link MOBILE_BREAKPOINT_PX}). Tracks
82
- * `matchMedia` so the layout switches live on resize/rotate. SSR-safe: defaults
83
- * to `false` (desktop) when `window` is unavailable.
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 useIsMobile = (): boolean => {
86
- const query = `(max-width: ${MOBILE_BREAKPOINT_PX - 1}px)`;
87
- const [isMobile, setIsMobile] = useState(() =>
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
- if (typeof window === "undefined") return;
92
- const mql = window.matchMedia(query);
93
- const onChange = () => setIsMobile(mql.matches);
94
- onChange();
95
- mql.addEventListener("change", onChange);
96
- return () => mql.removeEventListener("change", onChange);
97
- }, [query]);
98
- return isMobile;
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
- // The conversation sidebar turns on once the host wires both the
400
- // thread list and a selection handler. A header toggle lets the user
401
- // show/hide it on demand. Open state is controlled when the caller
402
- // supplies `sidebarOpen` + `onToggleSidebar` (the driver does this and
403
- // persists the choice); otherwise the view manages a session-only
404
- // open flag. Defaults to open.
405
- const showSidebar = Boolean(threads && onSelectThread);
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
- // Desktop inline-sidebar open state (persisted by the driver when it
408
- // supplies `sidebarOpen`/`onToggleSidebar`, else a session-only flag).
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
- // Are we on a phone-width viewport (< md / 768px)? Drives whether the
415
- // sidebar renders inline (desktop) or as an overlay drawer (mobile), and
416
- // which open-state the header toggle flips.
417
- const isMobile = useIsMobile();
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 (!isMobile) setMobileDrawerOpen(false);
424
- }, [isMobile]);
425
- // Unified state/handlers the render + header use, resolved by viewport.
426
- const sidebarOpen = isMobile ? mobileDrawerOpen : desktopSidebarOpen;
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 (isMobile) setMobileDrawerOpen((open) => !open);
429
- else toggleDesktopSidebar();
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 mobile hamburger, or a desktop "show"
434
- // affordance while the inline sidebar is collapsed - so the bar renders only
435
- // when that toggle would actually be visible (an open desktop sidebar has its
436
- // own hide button, leaving nothing for the bar to hold).
437
- const showSidebarToggle = showSidebar && (isMobile || !desktopSidebarOpen);
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
- // Props shared by the mobile drawer and the desktop inline sidebar - the two
442
- // render the SAME `ThreadSidebar`, differing only in framing (overlay vs.
443
- // inline) and, on mobile, closing the drawer after select / new. Building
444
- // the prop bag once keeps the two call sites from drifting.
445
- const sidebarProps: ThreadSidebarProps = {
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 sidebar beside the
479
- * chat column. The chat column owns the vertical layout and the
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 className={cn("flex h-full min-h-0", className)}>
488
- {showSidebar &&
489
- (isMobile
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
- * Mobile: a fixed overlay drawer with a tap-to-close backdrop, so
492
- * the conversation list never eats horizontal space from the chat
493
- * on a phone. Selecting a thread / starting a new one also closes
494
- * the drawer so the transcript comes back into view. Session-only
495
- * + default closed (see `mobileDrawerOpen`).
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
- mobileDrawerOpen && (
498
- <div className="fixed inset-0 z-40 flex">
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
- {...sidebarProps}
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
- * Desktop: an inline flex child sharing the row with the chat
524
- * column, using the persisted open/hide preference. Same
525
- * `sidebarProps` as mobile - only the framing + close-on-select
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
- desktopSidebarOpen && <ThreadSidebar {...sidebarProps} />)}
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 sidebar toggle. On mobile the toggle is
533
- * a persistent hamburger (the overlay drawer has no always-visible
534
- * hide button); on desktop it's a "show" affordance rendered only
535
- * while the inline sidebar is collapsed (an open sidebar has its
536
- * own hide button). `showHeader` already tracks that visibility, so
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 className="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">
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
- <PanelLeftIcon className="size-4" />
629
+ <SidebarToggleIcon className="size-4" />
550
630
  </Button>
551
631
  </TooltipTrigger>
552
632
  <TooltipContent>
@@ -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
- const enableThreads = options.enableThreads !== false;
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 and can be
1489
- * turned off with `enableThreads: false`.
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-r border-border bg-background",
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
- <PanelLeftIcon className="size-4" />
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
+ };
@@ -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
+ }