@parallelworks/ui 0.29.0 → 0.30.0

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
@@ -114,6 +114,15 @@ export function Chat() {
114
114
  }
115
115
  ```
116
116
 
117
+ `ChatLayout sidebarMode="auto"` shows the conversation list as a drawer over
118
+ the thread when the chat is narrower than `drawerBelowPx` (768 by default),
119
+ measured on the chat's own box; `"drawer"` always does, and `"inline"` (the
120
+ default) never does. The reader's toggle drives the list unless the host
121
+ passes `sidebar` (`'expanded' | 'collapsed' | 'hidden'`) and `drawerOpen` to
122
+ `ChatProvider`, with `onSidebarChange` and `onDrawerOpenChange` to hear the
123
+ reader's requests. `useChat().toggleSidebar` opens or closes whichever the
124
+ layout shows, for a host header's own button.
125
+
117
126
  Swap the mock for `createOpenAIChatAdapter({ baseUrl, apiKey })` from
118
127
  `@parallelworks/ui/ai/openai`, or implement `ChatAdapter` against your own
119
128
  backend. It requires `conversations`, `models` and `streamCompletion`; the
@@ -1,5 +1,14 @@
1
- import type { ReactNode } from 'react';
2
- export default function ChatLayout({ children, sidebarActions, }: {
1
+ import { type ReactNode } from 'react';
2
+ /** Where the conversation list goes: beside the thread, in a drawer over it,
3
+ * or whichever fits the width the chat is given. */
4
+ export type SidebarMode = 'inline' | 'drawer' | 'auto';
5
+ export declare const DRAWER_BELOW_PX = 768;
6
+ export default function ChatLayout({ children, sidebarActions, sidebarMode, drawerBelowPx, drawerToggle, }: {
3
7
  children: ReactNode;
4
8
  sidebarActions?: boolean;
9
+ sidebarMode?: SidebarMode;
10
+ /** The chat's width under which 'auto' shows the drawer. */
11
+ drawerBelowPx?: number;
12
+ /** False when the host's own header opens the drawer. */
13
+ drawerToggle?: boolean;
5
14
  }): import("react").JSX.Element;
@@ -1,15 +1,63 @@
1
+ import { useChatConfig } from "../core/config.js";
2
+ import { useReportSidebarPresentation } from "../core/sidebarState.js";
3
+ import { useChat } from "../core/ChatProvider.js";
1
4
  import { KeyboardShortcutsProvider } from "../core/KeyboardShortcutsProvider.js";
5
+ import { SidebarToggle } from "../ui/sidebar.js";
2
6
  import ChatSidebar from "./ChatSidebar.js";
7
+ import cx from "classnames";
8
+ import { useLayoutEffect, useRef, useState } from "react";
3
9
  import { jsx, jsxs } from "react/jsx-runtime";
4
10
  //#region src/ai/components/ChatLayout.tsx
5
- function ChatLayout({ children, sidebarActions = true }) {
11
+ var DRAWER_BELOW_PX = 768;
12
+ function useSidebarPresentation(ref, mode, drawerBelowPx) {
13
+ const [narrow, setNarrow] = useState(false);
14
+ useLayoutEffect(() => {
15
+ const el = ref.current;
16
+ if (mode !== "auto" || !el) return;
17
+ const measure = () => setNarrow(el.clientWidth < drawerBelowPx);
18
+ measure();
19
+ if (typeof ResizeObserver === "undefined") return;
20
+ const observer = new ResizeObserver(measure);
21
+ observer.observe(el);
22
+ return () => observer.disconnect();
23
+ }, [
24
+ ref,
25
+ mode,
26
+ drawerBelowPx
27
+ ]);
28
+ const presentation = mode === "drawer" || mode === "auto" && narrow ? "drawer" : "inline";
29
+ const report = useReportSidebarPresentation();
30
+ useLayoutEffect(() => {
31
+ report(presentation);
32
+ }, [report, presentation]);
33
+ useLayoutEffect(() => () => report("inline"), [report]);
34
+ return presentation;
35
+ }
36
+ function ChatLayout({ children, sidebarActions = true, sidebarMode = "inline", drawerBelowPx = 768, drawerToggle = true }) {
37
+ const ref = useRef(null);
38
+ const drawer = useSidebarPresentation(ref, sidebarMode, drawerBelowPx) === "drawer";
39
+ const { drawerOpen, toggleSidebar } = useChat();
40
+ const { strings } = useChatConfig();
6
41
  return /* @__PURE__ */ jsx(KeyboardShortcutsProvider, { children: /* @__PURE__ */ jsxs("div", {
7
- className: "flex h-full",
8
- children: [/* @__PURE__ */ jsx(ChatSidebar, { actions: sidebarActions }), /* @__PURE__ */ jsx("main", {
42
+ ref,
43
+ className: cx("flex h-full", drawer && "relative overflow-hidden"),
44
+ children: [/* @__PURE__ */ jsx(ChatSidebar, { actions: sidebarActions }), /* @__PURE__ */ jsxs("main", {
45
+ inert: drawer && drawerOpen,
9
46
  className: "flex-1 flex flex-col min-w-0",
10
- children
47
+ children: [drawer && drawerToggle && /* @__PURE__ */ jsx("div", {
48
+ className: "flex shrink-0 items-center bg-(--theme-panel-bg) px-2 pt-2",
49
+ children: /* @__PURE__ */ jsx(SidebarToggle, {
50
+ collapsed: !drawerOpen,
51
+ onToggle: toggleSidebar,
52
+ openLabel: strings.sidebar.openSidebar,
53
+ closeLabel: strings.sidebar.closeSidebar
54
+ })
55
+ }), /* @__PURE__ */ jsx("div", {
56
+ className: "flex min-h-0 flex-1 flex-col",
57
+ children
58
+ })]
11
59
  })]
12
60
  }) });
13
61
  }
14
62
  //#endregion
15
- export { ChatLayout as default };
63
+ export { DRAWER_BELOW_PX, ChatLayout as default };
@@ -11,8 +11,8 @@ import { useRowMenu } from "../../list/RowContextMenu.js";
11
11
  import { useChatConfig } from "../core/config.js";
12
12
  import { useChat } from "../core/ChatProvider.js";
13
13
  import { FOCUS_SIDEBAR_SEARCH_EVENT } from "../core/events.js";
14
+ import { SidebarDrawer, SidebarRow, useRowDialogs } from "../ui/sidebar.js";
14
15
  import { conversationToMarkdown, downloadText, exportFilename } from "../core/exportConversation.js";
15
- import { SidebarRow, useRowDialogs } from "../ui/sidebar.js";
16
16
  import { ConversationSidebar } from "../ui/ConversationSidebar.js";
17
17
  import ShareDialog from "./ShareDialog.js";
18
18
  import cx from "classnames";
@@ -41,7 +41,7 @@ function groupByDate(conversations) {
41
41
  }
42
42
  return Object.entries(groups).filter(([, items]) => items.length > 0);
43
43
  }
44
- function ConversationRow({ conv, isCurrent, sharingAvailable, openMenu, onRename, onShare, onDelete, onExport }) {
44
+ function ConversationRow({ conv, isCurrent, sharingAvailable, openMenu, onRename, onShare, onDelete, onExport, onOpen }) {
45
45
  const { LinkComponent, strings } = useChatConfig();
46
46
  const t = strings.sidebar;
47
47
  const menuItems = [
@@ -81,6 +81,7 @@ function ConversationRow({ conv, isCurrent, sharingAvailable, openMenu, onRename
81
81
  id: conv.id
82
82
  },
83
83
  className: "flex min-w-0 flex-1 items-center gap-1.5 py-1 text-left",
84
+ onClick: onOpen,
84
85
  children: [conv.isOwner === false && /* @__PURE__ */ jsx(FaUsers, {
85
86
  className: "h-3 w-3 shrink-0 theme-muted-text",
86
87
  title: t.sharedWithYou
@@ -94,7 +95,7 @@ function ConversationRow({ conv, isCurrent, sharingAvailable, openMenu, onRename
94
95
  function ChatSidebar({ actions = true }) {
95
96
  const { extraLinks, LinkComponent, strings } = useChatConfig();
96
97
  const tSidebar = strings.sidebar;
97
- const { adapter, navigation, notify, activeConversationId, conversations, sidebarCollapsed, toggleSidebar, isLoading, loadConversations, deleteConversation, updateConversationTitle, clearCurrentConversation } = useChat();
98
+ const { adapter, navigation, notify, activeConversationId, conversations, sidebar, sidebarPresentation, drawerOpen, setSidebar, setDrawerOpen, toggleSidebar, isLoading, loadConversations, deleteConversation, updateConversationTitle, clearCurrentConversation } = useChat();
98
99
  const sharingAvailable = !!adapter.sharing;
99
100
  const attachmentsAvailable = !!adapter.attachments;
100
101
  const [shareDialogOpen, setShareDialogOpen] = useState(false);
@@ -114,22 +115,28 @@ function ChatSidebar({ actions = true }) {
114
115
  onDelete: (conv) => deleteConversation(conv.id),
115
116
  onRename: (conv, title) => updateConversationTitle(conv.id, title)
116
117
  });
118
+ const drawer = sidebarPresentation === "drawer";
119
+ const sidebarCollapsed = !drawer && sidebar !== "expanded";
120
+ const closeDrawer = () => setDrawerOpen(false);
117
121
  const [search, setSearch] = useState("");
118
122
  const searchInputRef = useRef(null);
119
- const sidebarCollapsedRef = useRef(sidebarCollapsed);
120
- sidebarCollapsedRef.current = sidebarCollapsed;
123
+ const revealRef = useRef(() => {});
124
+ revealRef.current = () => {
125
+ if (drawer) setDrawerOpen(true);
126
+ else if (sidebar !== "expanded") setSidebar("expanded");
127
+ };
121
128
  useEffect(() => {
122
129
  loadConversations();
123
130
  }, [loadConversations]);
124
131
  useEffect(() => {
125
132
  if (!actions) return;
126
133
  const focusSearch = () => {
127
- if (sidebarCollapsedRef.current) toggleSidebar();
134
+ revealRef.current();
128
135
  requestAnimationFrame(() => searchInputRef.current?.focus());
129
136
  };
130
137
  document.addEventListener(FOCUS_SIDEBAR_SEARCH_EVENT, focusSearch);
131
138
  return () => document.removeEventListener(FOCUS_SIDEBAR_SEARCH_EVENT, focusSearch);
132
- }, [actions, toggleSidebar]);
139
+ }, [actions]);
133
140
  const filteredConversations = useMemo(() => {
134
141
  const query = search.trim().toLowerCase();
135
142
  if (!query) return conversations;
@@ -157,100 +164,111 @@ function ChatSidebar({ actions = true }) {
157
164
  setShareDialogOpen(true);
158
165
  };
159
166
  const handleNewChat = () => {
167
+ closeDrawer();
160
168
  clearCurrentConversation();
161
169
  navigation.toNewChat();
162
170
  };
163
- return /* @__PURE__ */ jsxs(Fragment$1, { children: [
164
- /* @__PURE__ */ jsx(ConversationSidebar, {
165
- collapsed: sidebarCollapsed,
166
- onToggle: toggleSidebar,
167
- toggleLabels: {
168
- open: tSidebar.openSidebar,
169
- close: tSidebar.closeSidebar
170
- },
171
- resize: {
172
- storageKey: SIDEBAR_WIDTH_STORAGE_KEY,
173
- label: tSidebar.resizeLabel,
174
- hint: tSidebar.resizeHint
175
- },
176
- groups,
177
- getKey: (conv) => conv.id,
178
- renderRow: (conv) => /* @__PURE__ */ jsx(ConversationRow, {
179
- conv,
180
- isCurrent: activeConversationId === conv.id,
181
- sharingAvailable,
182
- openMenu,
183
- onRename: requestRename,
184
- onShare: handleShareClick,
185
- onDelete: requestDelete,
186
- onExport: handleExport
187
- }),
188
- loading: isLoading,
189
- placeholder: conversations.length === 0 ? /* @__PURE__ */ jsx("p", {
190
- className: "theme-muted-text text-sm px-2 whitespace-nowrap",
191
- children: tSidebar.noConversations
192
- }) : /* @__PURE__ */ jsx("p", {
193
- className: "theme-muted-text text-sm px-2",
194
- children: strings.chrome.noMatches
195
- }),
196
- cycle: {
197
- current: filteredConversations.find((conv) => conv.id === activeConversationId),
198
- onSelect: (conv) => navigation.toConversation(conv.id)
199
- },
200
- top: actions && /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsxs("div", {
201
- className: "flex flex-col gap-0.5 px-2 pt-2 pb-1",
202
- children: [/* @__PURE__ */ jsxs("button", {
203
- type: "button",
204
- onClick: handleNewChat,
205
- className: "flex items-center h-9 px-2 gap-3 rounded-lg font-medium transition-colors hover:chat-tint",
206
- title: tSidebar.newChat,
207
- children: [/* @__PURE__ */ jsx(TbEdit, { className: cx("flex-shrink-0", "w-4 h-4") }), /* @__PURE__ */ jsx("span", {
208
- className: cx("text-sm transition-opacity duration-200", sidebarCollapsed ? "opacity-0 w-0 overflow-hidden" : "opacity-100"),
209
- children: tSidebar.newChat
210
- })]
211
- }), attachmentsAvailable && /* @__PURE__ */ jsxs(LinkComponent, {
212
- target: { kind: "attachments" },
213
- className: "flex items-center h-9 px-2 gap-3 rounded-lg transition-colors hover:chat-tint",
214
- title: tSidebar.attachments,
215
- children: [/* @__PURE__ */ jsx(FaRegFileImage, { className: "flex-shrink-0 w-4 h-4" }), /* @__PURE__ */ jsx("span", {
216
- className: cx("text-sm transition-opacity duration-200", sidebarCollapsed ? "opacity-0 w-0 overflow-hidden" : "opacity-100"),
217
- children: tSidebar.attachments
218
- })]
171
+ const list = /* @__PURE__ */ jsx(ConversationSidebar, {
172
+ collapsed: sidebarCollapsed,
173
+ fill: drawer,
174
+ onToggle: toggleSidebar,
175
+ toggleLabels: {
176
+ open: tSidebar.openSidebar,
177
+ close: tSidebar.closeSidebar
178
+ },
179
+ resize: {
180
+ storageKey: SIDEBAR_WIDTH_STORAGE_KEY,
181
+ label: tSidebar.resizeLabel,
182
+ hint: tSidebar.resizeHint
183
+ },
184
+ groups,
185
+ getKey: (conv) => conv.id,
186
+ renderRow: (conv) => /* @__PURE__ */ jsx(ConversationRow, {
187
+ conv,
188
+ isCurrent: activeConversationId === conv.id,
189
+ sharingAvailable,
190
+ openMenu,
191
+ onRename: requestRename,
192
+ onShare: handleShareClick,
193
+ onDelete: requestDelete,
194
+ onExport: handleExport,
195
+ onOpen: closeDrawer
196
+ }),
197
+ loading: isLoading,
198
+ placeholder: conversations.length === 0 ? /* @__PURE__ */ jsx("p", {
199
+ className: "theme-muted-text text-sm px-2 whitespace-nowrap",
200
+ children: tSidebar.noConversations
201
+ }) : /* @__PURE__ */ jsx("p", {
202
+ className: "theme-muted-text text-sm px-2",
203
+ children: strings.chrome.noMatches
204
+ }),
205
+ cycle: {
206
+ current: filteredConversations.find((conv) => conv.id === activeConversationId),
207
+ onSelect: (conv) => navigation.toConversation(conv.id)
208
+ },
209
+ top: actions && /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsxs("div", {
210
+ className: "flex flex-col gap-0.5 px-2 pt-2 pb-1",
211
+ children: [/* @__PURE__ */ jsxs("button", {
212
+ type: "button",
213
+ onClick: handleNewChat,
214
+ className: "flex items-center h-9 px-2 gap-3 rounded-lg font-medium transition-colors hover:chat-tint",
215
+ title: tSidebar.newChat,
216
+ children: [/* @__PURE__ */ jsx(TbEdit, { className: cx("flex-shrink-0", "w-4 h-4") }), /* @__PURE__ */ jsx("span", {
217
+ className: cx("text-sm transition-opacity duration-200", sidebarCollapsed ? "opacity-0 w-0 overflow-hidden" : "opacity-100"),
218
+ children: tSidebar.newChat
219
219
  })]
220
- }), !sidebarCollapsed && conversations.length > 0 && /* @__PURE__ */ jsx("div", {
221
- className: "px-2 pb-2",
222
- children: /* @__PURE__ */ jsxs("div", {
223
- className: "relative",
224
- children: [/* @__PURE__ */ jsx(FaSearch, { className: "absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 theme-muted-text pointer-events-none" }), /* @__PURE__ */ jsx("input", {
225
- ref: searchInputRef,
226
- type: "text",
227
- value: search,
228
- onChange: (e) => setSearch(e.target.value),
229
- onKeyDown: (e) => {
230
- if (e.key === "Escape" && search) {
231
- e.stopPropagation();
232
- setSearch("");
233
- }
234
- },
235
- placeholder: strings.chrome.searchPlaceholder,
236
- "aria-label": strings.chrome.searchLabel,
237
- className: "w-full h-9 pl-8 pr-2 text-sm rounded-lg border border-transparent chat-tint theme-text placeholder:theme-muted-text transition-colors focus:outline-none focus:border-(--theme-border) focus:bg-(--theme-panel-bg)"
238
- })]
239
- })
240
- })] }),
241
- footer: extraLinks.manageProviders && /* @__PURE__ */ jsxs(LinkComponent, {
242
- target: {
243
- kind: "external",
244
- href: extraLinks.manageProviders
245
- },
246
- className: "flex min-w-0 items-center gap-2 rounded-md px-2 py-1.5 text-xs theme-muted-text transition-colors hover:chat-tint hover:theme-text",
247
- title: tSidebar.manageProviders,
248
- children: [/* @__PURE__ */ jsx(IoSettingsOutline, { className: "h-3.5 w-3.5 shrink-0" }), /* @__PURE__ */ jsx("span", {
249
- className: "truncate",
250
- children: tSidebar.manageProviders
220
+ }), attachmentsAvailable && /* @__PURE__ */ jsxs(LinkComponent, {
221
+ target: { kind: "attachments" },
222
+ className: "flex items-center h-9 px-2 gap-3 rounded-lg transition-colors hover:chat-tint",
223
+ title: tSidebar.attachments,
224
+ onClick: closeDrawer,
225
+ children: [/* @__PURE__ */ jsx(FaRegFileImage, { className: "flex-shrink-0 w-4 h-4" }), /* @__PURE__ */ jsx("span", {
226
+ className: cx("text-sm transition-opacity duration-200", sidebarCollapsed ? "opacity-0 w-0 overflow-hidden" : "opacity-100"),
227
+ children: tSidebar.attachments
228
+ })]
229
+ })]
230
+ }), !sidebarCollapsed && conversations.length > 0 && /* @__PURE__ */ jsx("div", {
231
+ className: "px-2 pb-2",
232
+ children: /* @__PURE__ */ jsxs("div", {
233
+ className: "relative",
234
+ children: [/* @__PURE__ */ jsx(FaSearch, { className: "absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 theme-muted-text pointer-events-none" }), /* @__PURE__ */ jsx("input", {
235
+ ref: searchInputRef,
236
+ type: "text",
237
+ value: search,
238
+ onChange: (e) => setSearch(e.target.value),
239
+ onKeyDown: (e) => {
240
+ if (e.key === "Escape" && search) {
241
+ e.stopPropagation();
242
+ setSearch("");
243
+ }
244
+ },
245
+ placeholder: strings.chrome.searchPlaceholder,
246
+ "aria-label": strings.chrome.searchLabel,
247
+ className: "w-full h-9 pl-8 pr-2 text-sm rounded-lg border border-transparent chat-tint theme-text placeholder:theme-muted-text transition-colors focus:outline-none focus:border-(--theme-border) focus:bg-(--theme-panel-bg)"
251
248
  })]
252
249
  })
253
- }),
250
+ })] }),
251
+ footer: extraLinks.manageProviders && /* @__PURE__ */ jsxs(LinkComponent, {
252
+ target: {
253
+ kind: "external",
254
+ href: extraLinks.manageProviders
255
+ },
256
+ className: "flex min-w-0 items-center gap-2 rounded-md px-2 py-1.5 text-xs theme-muted-text transition-colors hover:chat-tint hover:theme-text",
257
+ title: tSidebar.manageProviders,
258
+ children: [/* @__PURE__ */ jsx(IoSettingsOutline, { className: "h-3.5 w-3.5 shrink-0" }), /* @__PURE__ */ jsx("span", {
259
+ className: "truncate",
260
+ children: tSidebar.manageProviders
261
+ })]
262
+ })
263
+ });
264
+ return /* @__PURE__ */ jsxs(Fragment$1, { children: [
265
+ drawer ? /* @__PURE__ */ jsx(SidebarDrawer, {
266
+ open: drawerOpen,
267
+ onClose: closeDrawer,
268
+ label: tSidebar.drawerLabel,
269
+ closeLabel: tSidebar.closeSidebar,
270
+ children: list
271
+ }) : sidebar !== "hidden" && list,
254
272
  contextMenu,
255
273
  dialogs,
256
274
  conversationToShare && /* @__PURE__ */ jsx(ShareDialog, {
@@ -13,6 +13,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
13
13
  import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
14
14
  import { createPortal } from "react-dom";
15
15
  //#region src/ai/components/ModelSelector.tsx
16
+ var ORG_OWNER = "org";
16
17
  /** The search field plus the list's max-h-80, so the flip decision matches
17
18
  * what renders. */
18
19
  var MAX_PANEL_HEIGHT_PX = 380;
@@ -181,6 +182,8 @@ function ModelSelector({ targetSession, variant = "default" }) {
181
182
  ref: buttonRef,
182
183
  type: "button",
183
184
  onClick: () => setIsOpen(!isOpen),
185
+ "aria-haspopup": "dialog",
186
+ "aria-expanded": isOpen,
184
187
  disabled: models.length === 0 && !targetSessionStatus && unreachableSessions.length === 0,
185
188
  className: cx(boxClass, "transition-all cursor-pointer bg-transparent", "focus:outline-none", "disabled:opacity-50 disabled:cursor-not-allowed", bare ? "hover:chat-tint" : "border theme-border hover:theme-hover"),
186
189
  children: [
@@ -299,6 +302,7 @@ function ModelSelector({ targetSession, variant = "default" }) {
299
302
  const platform = providerInfo?.platformLabel;
300
303
  const platformSuffix = providerInfo && platform && platform.toLowerCase() !== providerName.toLowerCase() ? platform : null;
301
304
  const isOwner = providerInfo?.user === currentUser.username;
305
+ const headerSuffix = [platformSuffix, providerInfo && !isOwner && providerInfo.user !== ORG_OWNER ? strings.modelPicker.sharedBy(providerInfo.user) : null].filter(Boolean).join(" · ");
302
306
  const settingsHref = providerInfo && extraLinks.providerSettings ? extraLinks.providerSettings(providerInfo.user, providerInfo.name) : null;
303
307
  const issue = providerIssueFor(providerIssues, providerModels[0]);
304
308
  return /* @__PURE__ */ jsxs("div", { children: [
@@ -317,7 +321,7 @@ function ModelSelector({ targetSession, variant = "default" }) {
317
321
  }),
318
322
  /* @__PURE__ */ jsx("span", {
319
323
  className: "min-w-0 flex-1 truncate text-xs theme-muted-text",
320
- children: platformSuffix ? `· ${platformSuffix}` : ""
324
+ children: headerSuffix ? `· ${headerSuffix}` : ""
321
325
  }),
322
326
  /* @__PURE__ */ jsxs("span", {
323
327
  className: "text-xs theme-muted-text",
@@ -2,6 +2,7 @@ import { type ReactNode } from 'react';
2
2
  import type { ChatAdapter } from '../adapter/types.js';
3
3
  import type { AttachmentMeta, ChatModel, ChatNavigation, ChatNotify, ChatUser, Conversation, ConversationSummary, ChatMessage as Message, MessagePart, ProviderInfo, ProviderIssue, UnreachableSession } from '../types.js';
4
4
  import { type ChatUIConfig } from './config.js';
5
+ import { type SidebarPresentation, type SidebarState } from './sidebarState.js';
5
6
  interface ChatContextValue {
6
7
  adapter: ChatAdapter;
7
8
  currentUser: ChatUser;
@@ -28,7 +29,11 @@ interface ChatContextValue {
28
29
  streamingReasoning: string;
29
30
  streamingParts: MessagePart[];
30
31
  thinkingStartTime: number | null;
32
+ sidebar: SidebarState;
33
+ /** True unless the sidebar is expanded. */
31
34
  sidebarCollapsed: boolean;
35
+ sidebarPresentation: SidebarPresentation;
36
+ drawerOpen: boolean;
32
37
  queuedMessages: Message[];
33
38
  loadConversations: () => Promise<void>;
34
39
  loadConversation: (id: string) => Promise<void>;
@@ -44,6 +49,10 @@ interface ChatContextValue {
44
49
  stopStreaming: () => void;
45
50
  removeQueuedMessage: (id: string) => void;
46
51
  flushQueuedMessages: () => void;
52
+ setSidebar: (sidebar: SidebarState) => void;
53
+ setDrawerOpen: (open: boolean) => void;
54
+ /** Opens or closes the drawer while the layout shows one; otherwise moves
55
+ * between expanded and the rail. */
47
56
  toggleSidebar: () => void;
48
57
  updateConversationTitle: (id: string, title: string) => Promise<boolean>;
49
58
  navigateToBranch: (messageId: string) => void;
@@ -57,7 +66,15 @@ export interface ChatProviderProps {
57
66
  notify: ChatNotify;
58
67
  activeConversationId?: string | null;
59
68
  config?: ChatUIConfig;
69
+ /** Owns the sidebar's state. Omit to leave it to the reader, remembered
70
+ * per browser. */
71
+ sidebar?: SidebarState;
72
+ onSidebarChange?: (sidebar: SidebarState) => void;
73
+ /** Owns whether the drawer is open while the layout shows the sidebar as
74
+ * one. Omit to leave it to the reader; it starts closed. */
75
+ drawerOpen?: boolean;
76
+ onDrawerOpenChange?: (open: boolean) => void;
60
77
  children: ReactNode;
61
78
  }
62
- export declare function ChatProvider({ adapter, currentUser, navigation, notify, activeConversationId, config, children, }: ChatProviderProps): import("react").JSX.Element;
79
+ export declare function ChatProvider({ adapter, currentUser, navigation, notify, activeConversationId, config, sidebar: sidebarProp, onSidebarChange, drawerOpen: drawerOpenProp, onDrawerOpenChange, children, }: ChatProviderProps): import("react").JSX.Element;
63
80
  export {};
@@ -2,25 +2,13 @@ import { applyPartDelta, finalizeParts } from "./parts.js";
2
2
  import { chatReducer, initialState } from "./chatReducer.js";
3
3
  import { ChatConfigProvider, resolveChatConfig } from "./config.js";
4
4
  import { providerIssueFor } from "./providerIssues.js";
5
+ import { ReportSidebarPresentationContext, readStoredSidebar, storeSidebar, useControllable } from "./sidebarState.js";
5
6
  import { useSSEStream } from "./useSSEStream.js";
6
- import { createContext, useCallback, useContext, useEffect, useMemo, useReducer, useRef } from "react";
7
+ import { createContext, useCallback, useContext, useEffect, useMemo, useReducer, useRef, useState } from "react";
7
8
  import { jsx } from "react/jsx-runtime";
8
9
  import { DateTime } from "luxon";
9
10
  //#region src/ai/core/ChatProvider.tsx
10
11
  var SELECTED_MODEL_STORAGE_KEY = "aiChatSelectedModel";
11
- var SIDEBAR_COLLAPSED_STORAGE_KEY = "aiChatSidebarCollapsed";
12
- function readStoredSidebarCollapsed() {
13
- try {
14
- return globalThis.localStorage?.getItem(SIDEBAR_COLLAPSED_STORAGE_KEY) === "1";
15
- } catch {
16
- return false;
17
- }
18
- }
19
- function storeSidebarCollapsed(collapsed) {
20
- try {
21
- globalThis.localStorage?.setItem(SIDEBAR_COLLAPSED_STORAGE_KEY, collapsed ? "1" : "0");
22
- } catch {}
23
- }
24
12
  function readStoredModel() {
25
13
  try {
26
14
  return globalThis.localStorage?.getItem(SELECTED_MODEL_STORAGE_KEY) ?? null;
@@ -73,11 +61,8 @@ function useChat() {
73
61
  if (!context) throw new Error("useChat must be used within a ChatProvider");
74
62
  return context;
75
63
  }
76
- function ChatProvider({ adapter, currentUser, navigation, notify, activeConversationId = null, config, children }) {
77
- const [state, dispatch] = useReducer(chatReducer, initialState, (seed) => ({
78
- ...seed,
79
- sidebarCollapsed: readStoredSidebarCollapsed()
80
- }));
64
+ function ChatProvider({ adapter, currentUser, navigation, notify, activeConversationId = null, config, sidebar: sidebarProp, onSidebarChange, drawerOpen: drawerOpenProp, onDrawerOpenChange, children }) {
65
+ const [state, dispatch] = useReducer(chatReducer, initialState);
81
66
  const hasLoadedConversationsRef = useRef(false);
82
67
  const { onStreamingChunk, startStreaming, stopAnimationLoop, resetAnimationRefs, targetStreamContentRef, streamActiveRef, streamingConversationIdRef, backgroundStreamsRef, abortControllerRef } = useSSEStream(dispatch);
83
68
  const currentConversationIdRef = useRef(null);
@@ -700,12 +685,24 @@ function ChatProvider({ adapter, currentUser, navigation, notify, activeConversa
700
685
  state.currentConversation?.messages,
701
686
  state.thinkingStartTime
702
687
  ]);
703
- const sidebarCollapsedRef = useRef(state.sidebarCollapsed);
704
- sidebarCollapsedRef.current = state.sidebarCollapsed;
688
+ const [sidebar, setSidebar, sidebarRef] = useControllable(sidebarProp, readStoredSidebar, onSidebarChange, storeSidebar);
689
+ const [drawerOpen, setDrawerOpen, drawerOpenRef] = useControllable(drawerOpenProp, () => false, onDrawerOpenChange);
690
+ const [sidebarPresentation, setSidebarPresentation] = useState("inline");
691
+ const presentationRef = useRef(sidebarPresentation);
692
+ presentationRef.current = sidebarPresentation;
693
+ const reportSidebarPresentation = useCallback((presentation) => {
694
+ setSidebarPresentation(presentation);
695
+ if (presentation === "inline") setDrawerOpen(false);
696
+ }, [setDrawerOpen]);
705
697
  const toggleSidebar = useCallback(() => {
706
- storeSidebarCollapsed(!sidebarCollapsedRef.current);
707
- dispatch({ type: "TOGGLE_SIDEBAR" });
708
- }, []);
698
+ if (presentationRef.current === "drawer") setDrawerOpen(!drawerOpenRef.current.value);
699
+ else setSidebar(sidebarRef.current.value === "expanded" ? "collapsed" : "expanded");
700
+ }, [
701
+ setDrawerOpen,
702
+ drawerOpenRef,
703
+ setSidebar,
704
+ sidebarRef
705
+ ]);
709
706
  const navigateToBranch = useCallback((messageId) => {
710
707
  if (!state.currentConversation) return;
711
708
  dispatch({
@@ -770,7 +767,10 @@ function ChatProvider({ adapter, currentUser, navigation, notify, activeConversa
770
767
  streamingReasoning: state.streamingReasoning,
771
768
  streamingParts: state.streamingParts,
772
769
  thinkingStartTime: state.thinkingStartTime,
773
- sidebarCollapsed: state.sidebarCollapsed,
770
+ sidebar,
771
+ sidebarCollapsed: sidebar !== "expanded",
772
+ sidebarPresentation,
773
+ drawerOpen,
774
774
  queuedMessages: state.queuedMessages,
775
775
  loadConversations,
776
776
  loadConversation,
@@ -786,6 +786,8 @@ function ChatProvider({ adapter, currentUser, navigation, notify, activeConversa
786
786
  stopStreaming,
787
787
  removeQueuedMessage,
788
788
  flushQueuedMessages,
789
+ setSidebar,
790
+ setDrawerOpen,
789
791
  toggleSidebar,
790
792
  updateConversationTitle,
791
793
  navigateToBranch,
@@ -797,6 +799,9 @@ function ChatProvider({ adapter, currentUser, navigation, notify, activeConversa
797
799
  notify,
798
800
  activeConversationId,
799
801
  state,
802
+ sidebar,
803
+ sidebarPresentation,
804
+ drawerOpen,
800
805
  isSelectedModelAvailable,
801
806
  loadConversations,
802
807
  loadConversation,
@@ -812,6 +817,8 @@ function ChatProvider({ adapter, currentUser, navigation, notify, activeConversa
812
817
  stopStreaming,
813
818
  removeQueuedMessage,
814
819
  flushQueuedMessages,
820
+ setSidebar,
821
+ setDrawerOpen,
815
822
  toggleSidebar,
816
823
  updateConversationTitle,
817
824
  navigateToBranch,
@@ -819,9 +826,12 @@ function ChatProvider({ adapter, currentUser, navigation, notify, activeConversa
819
826
  ]);
820
827
  return /* @__PURE__ */ jsx(ChatConfigProvider, {
821
828
  value: resolvedConfig,
822
- children: /* @__PURE__ */ jsx(ChatContext.Provider, {
823
- value,
824
- children
829
+ children: /* @__PURE__ */ jsx(ReportSidebarPresentationContext.Provider, {
830
+ value: reportSidebarPresentation,
831
+ children: /* @__PURE__ */ jsx(ChatContext.Provider, {
832
+ value,
833
+ children
834
+ })
825
835
  })
826
836
  });
827
837
  }
@@ -20,7 +20,6 @@ export interface ChatState {
20
20
  streamingReasoning: string;
21
21
  streamingParts: MessagePart[];
22
22
  thinkingStartTime: number | null;
23
- sidebarCollapsed: boolean;
24
23
  queuedMessages: ChatMessage[];
25
24
  }
26
25
  export type ChatAction = {
@@ -80,8 +79,6 @@ export type ChatAction = {
80
79
  messageId: string;
81
80
  updates: Partial<ChatMessage>;
82
81
  conversationId: string;
83
- } | {
84
- type: 'TOGGLE_SIDEBAR';
85
82
  } | {
86
83
  type: 'ADD_CONVERSATION';
87
84
  conversation: ConversationSummary;
@@ -20,7 +20,6 @@ var initialState = {
20
20
  streamingReasoning: "",
21
21
  streamingParts: [],
22
22
  thinkingStartTime: null,
23
- sidebarCollapsed: false,
24
23
  queuedMessages: []
25
24
  };
26
25
  function chatReducer(state, action) {
@@ -165,10 +164,6 @@ function chatReducer(state, action) {
165
164
  } : msg)
166
165
  }
167
166
  };
168
- case "TOGGLE_SIDEBAR": return {
169
- ...state,
170
- sidebarCollapsed: !state.sidebarCollapsed
171
- };
172
167
  case "ADD_CONVERSATION":
173
168
  if (state.conversations.some((c) => c.id === action.conversation.id)) return state;
174
169
  return {
@@ -0,0 +1,19 @@
1
+ /** The conversation list beside the thread: full width, a rail of icons, or
2
+ * not shown at all (the host offers its own way back). */
3
+ export type SidebarState = 'expanded' | 'collapsed' | 'hidden';
4
+ /** How the layout shows the list right now: as a column beside the thread,
5
+ * or as a drawer over it. */
6
+ export type SidebarPresentation = 'inline' | 'drawer';
7
+ export declare function readStoredSidebar(): SidebarState;
8
+ export declare function storeSidebar(state: SidebarState): void;
9
+ /** A value the host may own through a prop. Without the prop the provider
10
+ * keeps it; either way the change callback hears every change. The ref
11
+ * reads the latest value from callbacks that should not re-create. */
12
+ export declare function useControllable<T>(controlled: T | undefined, initial: () => T, onChange: ((value: T) => void) | undefined, remember?: (value: T) => void): readonly [T, (next: T) => void, import("react").RefObject<{
13
+ value: T;
14
+ controlled: boolean;
15
+ onChange: ((value: T) => void) | undefined;
16
+ remember: ((value: T) => void) | undefined;
17
+ }>];
18
+ export declare const ReportSidebarPresentationContext: import("react").Context<(presentation: SidebarPresentation) => void>;
19
+ export declare function useReportSidebarPresentation(): (presentation: SidebarPresentation) => void;