@parallelworks/ui 0.28.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 };
@@ -7,19 +7,20 @@ import { FaUsers } from "../../icons/fa/FaUsers.js";
7
7
  import { IoSettingsOutline } from "../../icons/io5/IoSettingsOutline.js";
8
8
  import { PiShareBold } from "../../icons/pi/PiShareBold.js";
9
9
  import { TbEdit } from "../../icons/tb/TbEdit.js";
10
- import { positionKeys } from "../../components/keys.js";
11
10
  import { useRowMenu } from "../../list/RowContextMenu.js";
12
11
  import { useChatConfig } from "../core/config.js";
13
12
  import { useChat } from "../core/ChatProvider.js";
14
13
  import { FOCUS_SIDEBAR_SEARCH_EVENT } from "../core/events.js";
14
+ import { SidebarDrawer, SidebarRow, useRowDialogs } from "../ui/sidebar.js";
15
15
  import { conversationToMarkdown, downloadText, exportFilename } from "../core/exportConversation.js";
16
- import { SidebarGroupHeading, SidebarPanel, SidebarRow, SidebarToggle, useRowDialogs } from "../ui/sidebar.js";
16
+ import { ConversationSidebar } from "../ui/ConversationSidebar.js";
17
17
  import ShareDialog from "./ShareDialog.js";
18
18
  import cx from "classnames";
19
19
  import { useEffect, useMemo, useRef, useState } from "react";
20
20
  import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
21
21
  import { DateTime } from "luxon";
22
22
  //#region src/ai/components/ChatSidebar.tsx
23
+ var SIDEBAR_WIDTH_STORAGE_KEY = "aiChatSidebarWidth";
23
24
  function groupByDate(conversations) {
24
25
  const now = DateTime.now();
25
26
  const groups = {
@@ -40,7 +41,7 @@ function groupByDate(conversations) {
40
41
  }
41
42
  return Object.entries(groups).filter(([, items]) => items.length > 0);
42
43
  }
43
- function ConversationRow({ conv, isCurrent, sharingAvailable, openMenu, onRename, onShare, onDelete, onExport }) {
44
+ function ConversationRow({ conv, isCurrent, sharingAvailable, openMenu, onRename, onShare, onDelete, onExport, onOpen }) {
44
45
  const { LinkComponent, strings } = useChatConfig();
45
46
  const t = strings.sidebar;
46
47
  const menuItems = [
@@ -80,6 +81,7 @@ function ConversationRow({ conv, isCurrent, sharingAvailable, openMenu, onRename
80
81
  id: conv.id
81
82
  },
82
83
  className: "flex min-w-0 flex-1 items-center gap-1.5 py-1 text-left",
84
+ onClick: onOpen,
83
85
  children: [conv.isOwner === false && /* @__PURE__ */ jsx(FaUsers, {
84
86
  className: "h-3 w-3 shrink-0 theme-muted-text",
85
87
  title: t.sharedWithYou
@@ -90,68 +92,10 @@ function ConversationRow({ conv, isCurrent, sharingAvailable, openMenu, onRename
90
92
  })
91
93
  });
92
94
  }
93
- var SIDEBAR_WIDTH_STORAGE_KEY = "aiChatSidebarWidth";
94
- var SIDEBAR_MIN_WIDTH_PX = 200;
95
- var SIDEBAR_MAX_WIDTH_PX = 480;
96
- function clampSidebarWidth(width) {
97
- return Math.min(SIDEBAR_MAX_WIDTH_PX, Math.max(SIDEBAR_MIN_WIDTH_PX, width));
98
- }
99
- function readStoredSidebarWidth() {
100
- try {
101
- const stored = Number(window.localStorage.getItem(SIDEBAR_WIDTH_STORAGE_KEY));
102
- return Number.isFinite(stored) && stored > 0 ? clampSidebarWidth(stored) : 256;
103
- } catch {
104
- return 256;
105
- }
106
- }
107
- function storeSidebarWidth(width) {
108
- try {
109
- window.localStorage.setItem(SIDEBAR_WIDTH_STORAGE_KEY, String(width));
110
- } catch {}
111
- }
112
- function useResizableSidebarWidth() {
113
- const [width, setWidth] = useState(readStoredSidebarWidth);
114
- const [resizing, setResizing] = useState(false);
115
- const widthRef = useRef(width);
116
- widthRef.current = width;
117
- const startResize = (e) => {
118
- e.preventDefault();
119
- const startX = e.clientX;
120
- const startWidth = widthRef.current;
121
- setResizing(true);
122
- const onMove = (ev) => {
123
- setWidth(clampSidebarWidth(startWidth + ev.clientX - startX));
124
- };
125
- const onUp = () => {
126
- window.removeEventListener("pointermove", onMove);
127
- window.removeEventListener("pointerup", onUp);
128
- setResizing(false);
129
- storeSidebarWidth(widthRef.current);
130
- };
131
- window.addEventListener("pointermove", onMove);
132
- window.addEventListener("pointerup", onUp);
133
- };
134
- const reset = () => {
135
- setWidth(256);
136
- storeSidebarWidth(256);
137
- };
138
- const nudge = (delta) => {
139
- const next = clampSidebarWidth(widthRef.current + delta);
140
- setWidth(next);
141
- storeSidebarWidth(next);
142
- };
143
- return {
144
- width,
145
- resizing,
146
- startResize,
147
- reset,
148
- nudge
149
- };
150
- }
151
95
  function ChatSidebar({ actions = true }) {
152
96
  const { extraLinks, LinkComponent, strings } = useChatConfig();
153
97
  const tSidebar = strings.sidebar;
154
- 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();
155
99
  const sharingAvailable = !!adapter.sharing;
156
100
  const attachmentsAvailable = !!adapter.attachments;
157
101
  const [shareDialogOpen, setShareDialogOpen] = useState(false);
@@ -171,23 +115,28 @@ function ChatSidebar({ actions = true }) {
171
115
  onDelete: (conv) => deleteConversation(conv.id),
172
116
  onRename: (conv, title) => updateConversationTitle(conv.id, title)
173
117
  });
174
- const { width: sidebarWidth, resizing, startResize, reset: resetSidebarWidth, nudge: nudgeSidebarWidth } = useResizableSidebarWidth();
118
+ const drawer = sidebarPresentation === "drawer";
119
+ const sidebarCollapsed = !drawer && sidebar !== "expanded";
120
+ const closeDrawer = () => setDrawerOpen(false);
175
121
  const [search, setSearch] = useState("");
176
122
  const searchInputRef = useRef(null);
177
- const sidebarCollapsedRef = useRef(sidebarCollapsed);
178
- sidebarCollapsedRef.current = sidebarCollapsed;
123
+ const revealRef = useRef(() => {});
124
+ revealRef.current = () => {
125
+ if (drawer) setDrawerOpen(true);
126
+ else if (sidebar !== "expanded") setSidebar("expanded");
127
+ };
179
128
  useEffect(() => {
180
129
  loadConversations();
181
130
  }, [loadConversations]);
182
131
  useEffect(() => {
183
132
  if (!actions) return;
184
133
  const focusSearch = () => {
185
- if (sidebarCollapsedRef.current) toggleSidebar();
134
+ revealRef.current();
186
135
  requestAnimationFrame(() => searchInputRef.current?.focus());
187
136
  };
188
137
  document.addEventListener(FOCUS_SIDEBAR_SEARCH_EVENT, focusSearch);
189
138
  return () => document.removeEventListener(FOCUS_SIDEBAR_SEARCH_EVENT, focusSearch);
190
- }, [actions, toggleSidebar]);
139
+ }, [actions]);
191
140
  const filteredConversations = useMemo(() => {
192
141
  const query = search.trim().toLowerCase();
193
142
  if (!query) return conversations;
@@ -197,7 +146,11 @@ function ChatSidebar({ actions = true }) {
197
146
  search,
198
147
  tSidebar.untitled
199
148
  ]);
200
- const groupedConversations = useMemo(() => groupByDate(filteredConversations), [filteredConversations]);
149
+ const groups = useMemo(() => groupByDate(filteredConversations).map(([key, items]) => ({
150
+ key,
151
+ label: tSidebar[key],
152
+ items
153
+ })), [filteredConversations, tSidebar]);
201
154
  const handleExport = async (id) => {
202
155
  try {
203
156
  const conversation = await adapter.conversations.get(id);
@@ -211,130 +164,111 @@ function ChatSidebar({ actions = true }) {
211
164
  setShareDialogOpen(true);
212
165
  };
213
166
  const handleNewChat = () => {
167
+ closeDrawer();
214
168
  clearCurrentConversation();
215
169
  navigation.toNewChat();
216
170
  };
217
- return /* @__PURE__ */ jsxs(Fragment$1, { children: [
218
- /* @__PURE__ */ jsxs(SidebarPanel, {
219
- collapsed: sidebarCollapsed,
220
- width: sidebarWidth,
221
- resizing,
222
- children: [
223
- !sidebarCollapsed && /* @__PURE__ */ jsx("div", {
224
- role: "separator",
225
- tabIndex: 0,
226
- "aria-orientation": "vertical",
227
- "aria-label": "Resize conversation list",
228
- "aria-valuemin": SIDEBAR_MIN_WIDTH_PX,
229
- "aria-valuemax": SIDEBAR_MAX_WIDTH_PX,
230
- "aria-valuenow": sidebarWidth,
231
- title: "Drag to resize; double-click to reset",
232
- onPointerDown: startResize,
233
- onDoubleClick: resetSidebarWidth,
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
+ })]
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),
234
239
  onKeyDown: (e) => {
235
- if (e.key === "ArrowLeft") nudgeSidebarWidth(-16);
236
- else if (e.key === "ArrowRight") nudgeSidebarWidth(16);
240
+ if (e.key === "Escape" && search) {
241
+ e.stopPropagation();
242
+ setSearch("");
243
+ }
237
244
  },
238
- className: cx("absolute inset-y-0 right-0 z-10 w-1 cursor-col-resize touch-none", resizing ? "bg-(--theme-element)" : "hover:bg-(--theme-border)")
239
- }),
240
- actions && /* @__PURE__ */ jsxs("div", {
241
- className: "flex flex-col gap-0.5 px-2 pt-2 pb-1",
242
- children: [/* @__PURE__ */ jsxs("button", {
243
- type: "button",
244
- onClick: handleNewChat,
245
- className: "flex items-center h-9 px-2 gap-3 rounded-lg font-medium transition-colors hover:chat-tint",
246
- title: tSidebar.newChat,
247
- children: [/* @__PURE__ */ jsx(TbEdit, { className: cx("flex-shrink-0", "w-4 h-4") }), /* @__PURE__ */ jsx("span", {
248
- className: cx("text-sm transition-opacity duration-200", sidebarCollapsed ? "opacity-0 w-0 overflow-hidden" : "opacity-100"),
249
- children: tSidebar.newChat
250
- })]
251
- }), attachmentsAvailable && /* @__PURE__ */ jsxs(LinkComponent, {
252
- target: { kind: "attachments" },
253
- className: "flex items-center h-9 px-2 gap-3 rounded-lg transition-colors hover:chat-tint",
254
- title: tSidebar.attachments,
255
- children: [/* @__PURE__ */ jsx(FaRegFileImage, { className: "flex-shrink-0 w-4 h-4" }), /* @__PURE__ */ jsx("span", {
256
- className: cx("text-sm transition-opacity duration-200", sidebarCollapsed ? "opacity-0 w-0 overflow-hidden" : "opacity-100"),
257
- children: tSidebar.attachments
258
- })]
259
- })]
260
- }),
261
- actions && !sidebarCollapsed && conversations.length > 0 && /* @__PURE__ */ jsx("div", {
262
- className: "px-2 pb-2",
263
- children: /* @__PURE__ */ jsxs("div", {
264
- className: "relative",
265
- 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", {
266
- ref: searchInputRef,
267
- type: "text",
268
- value: search,
269
- onChange: (e) => setSearch(e.target.value),
270
- onKeyDown: (e) => {
271
- if (e.key === "Escape" && search) {
272
- e.stopPropagation();
273
- setSearch("");
274
- }
275
- },
276
- placeholder: strings.chrome.searchPlaceholder,
277
- "aria-label": strings.chrome.searchLabel,
278
- 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)"
279
- })]
280
- })
281
- }),
282
- /* @__PURE__ */ jsx("div", {
283
- className: cx("min-h-0 flex-1 overflow-y-auto px-2 pb-2 transition-opacity duration-200", sidebarCollapsed ? "opacity-0 pointer-events-none" : "opacity-100"),
284
- children: isLoading ? /* @__PURE__ */ jsx("div", {
285
- className: "space-y-3 px-2 pt-2",
286
- children: positionKeys(5, "skeleton").map((key, i) => /* @__PURE__ */ jsx("div", {
287
- className: "animate-pulse",
288
- children: /* @__PURE__ */ jsx("div", {
289
- className: "h-4 rounded theme-muted-panel",
290
- style: { width: `${70 - i * 10}%` }
291
- })
292
- }, key))
293
- }) : conversations.length === 0 ? /* @__PURE__ */ jsx("p", {
294
- className: "theme-muted-text text-sm px-2 whitespace-nowrap",
295
- children: tSidebar.noConversations
296
- }) : filteredConversations.length === 0 ? /* @__PURE__ */ jsx("p", {
297
- className: "theme-muted-text text-sm px-2",
298
- children: strings.chrome.noMatches
299
- }) : groupedConversations.map(([label, convs]) => /* @__PURE__ */ jsxs("div", {
300
- className: "mb-2",
301
- children: [/* @__PURE__ */ jsx(SidebarGroupHeading, { children: tSidebar[label] }), /* @__PURE__ */ jsx("ul", {
302
- className: "space-y-0.5",
303
- children: convs.map((conv) => /* @__PURE__ */ jsx(ConversationRow, {
304
- conv,
305
- isCurrent: activeConversationId === conv.id,
306
- sharingAvailable,
307
- openMenu,
308
- onRename: requestRename,
309
- onShare: handleShareClick,
310
- onDelete: requestDelete,
311
- onExport: handleExport
312
- }, conv.id))
313
- })]
314
- }, label))
315
- }),
316
- /* @__PURE__ */ jsxs("div", {
317
- className: cx("flex shrink-0 items-center gap-1 p-1.5", sidebarCollapsed ? "justify-center" : extraLinks.manageProviders ? "justify-between" : "justify-end"),
318
- children: [!sidebarCollapsed && extraLinks.manageProviders && /* @__PURE__ */ jsxs(LinkComponent, {
319
- target: {
320
- kind: "external",
321
- href: extraLinks.manageProviders
322
- },
323
- 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",
324
- title: tSidebar.manageProviders,
325
- children: [/* @__PURE__ */ jsx(IoSettingsOutline, { className: "h-3.5 w-3.5 shrink-0" }), /* @__PURE__ */ jsx("span", {
326
- className: "truncate",
327
- children: tSidebar.manageProviders
328
- })]
329
- }), /* @__PURE__ */ jsx(SidebarToggle, {
330
- collapsed: sidebarCollapsed,
331
- onToggle: toggleSidebar,
332
- openLabel: tSidebar.openSidebar,
333
- closeLabel: tSidebar.closeSidebar
334
- })]
335
- })
336
- ]
337
- }),
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)"
248
+ })]
249
+ })
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,
338
272
  contextMenu,
339
273
  dialogs,
340
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 {};