@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.
@@ -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;
@@ -0,0 +1,57 @@
1
+ import { createContext, useCallback, useContext, useRef, useState } from "react";
2
+ //#region src/ai/core/sidebarState.ts
3
+ var SIDEBAR_STORAGE_KEY = "aiChatSidebar";
4
+ var LEGACY_COLLAPSED_STORAGE_KEY = "aiChatSidebarCollapsed";
5
+ function readStoredSidebar() {
6
+ try {
7
+ const stored = globalThis.localStorage?.getItem(SIDEBAR_STORAGE_KEY);
8
+ if (stored === "expanded" || stored === "collapsed") return stored;
9
+ return globalThis.localStorage?.getItem(LEGACY_COLLAPSED_STORAGE_KEY) === "1" ? "collapsed" : "expanded";
10
+ } catch {
11
+ return "expanded";
12
+ }
13
+ }
14
+ function storeSidebar(state) {
15
+ if (state === "hidden") return;
16
+ try {
17
+ globalThis.localStorage?.setItem(SIDEBAR_STORAGE_KEY, state);
18
+ } catch {}
19
+ }
20
+ /** A value the host may own through a prop. Without the prop the provider
21
+ * keeps it; either way the change callback hears every change. The ref
22
+ * reads the latest value from callbacks that should not re-create. */
23
+ function useControllable(controlled, initial, onChange, remember) {
24
+ const [own, setOwn] = useState(initial);
25
+ const value = controlled === void 0 ? own : controlled;
26
+ const latest = useRef({
27
+ value,
28
+ controlled: controlled !== void 0,
29
+ onChange,
30
+ remember
31
+ });
32
+ latest.current = {
33
+ value,
34
+ controlled: controlled !== void 0,
35
+ onChange,
36
+ remember
37
+ };
38
+ return [
39
+ value,
40
+ useCallback((next) => {
41
+ const l = latest.current;
42
+ if (Object.is(next, l.value)) return;
43
+ if (!l.controlled) {
44
+ setOwn(next);
45
+ l.remember?.(next);
46
+ }
47
+ l.onChange?.(next);
48
+ }, []),
49
+ latest
50
+ ];
51
+ }
52
+ var ReportSidebarPresentationContext = createContext(() => {});
53
+ function useReportSidebarPresentation() {
54
+ return useContext(ReportSidebarPresentationContext);
55
+ }
56
+ //#endregion
57
+ export { ReportSidebarPresentationContext, readStoredSidebar, storeSidebar, useControllable, useReportSidebarPresentation };
@@ -11,7 +11,7 @@ export { default as BlockedGroupBanner } from './components/BlockedGroupBanner.j
11
11
  export { default as BranchNavigator } from './components/BranchNavigator.js';
12
12
  export { default as ChatEmptyState } from './components/ChatEmptyState.js';
13
13
  export { type ChatInputHandle, default as ChatInput, } from './components/ChatInput.js';
14
- export { default as ChatLayout } from './components/ChatLayout.js';
14
+ export { DRAWER_BELOW_PX, default as ChatLayout, type SidebarMode, } from './components/ChatLayout.js';
15
15
  export { default as ChatMessageView } from './components/ChatMessage.js';
16
16
  export { default as ChatMessageList } from './components/ChatMessageList.js';
17
17
  export { default as ChatSidebar } from './components/ChatSidebar.js';
@@ -34,11 +34,13 @@ export { KeyboardShortcutsProvider } from './core/KeyboardShortcutsProvider.js';
34
34
  export { applyPartDelta, finalizeParts } from './core/parts.js';
35
35
  export { pasteInlineMaxBytes } from './core/pastes.js';
36
36
  export { providerIssueFor } from './core/providerIssues.js';
37
+ export type { SidebarPresentation, SidebarState } from './core/sidebarState.js';
37
38
  export { default as useDragDrop } from './core/useDragDrop.js';
38
39
  export { useKeyboardShortcuts } from './core/useKeyboardShortcuts.js';
39
40
  export { type ChatStrings, defaultChatStrings } from './strings.js';
40
41
  export * from './types.js';
42
+ export { ConversationSidebar, type ConversationSidebarGroup, useResizableSidebarWidth, } from './ui/ConversationSidebar.js';
41
43
  export { type DropdownOption, default as Dropdown } from './ui/Dropdown.js';
42
44
  export { NoticeBar, NoticeCard, type NoticeTone, OutputCard } from './ui/Notice.js';
43
- export { RenameDialog, type RowDialogStrings, SidebarGroupHeading, SidebarPanel, SidebarRailItem, SidebarRow, SidebarToggle, useRowDialogs, } from './ui/sidebar.js';
45
+ export { RenameDialog, type RowDialogStrings, SidebarDrawer, SidebarGroupHeading, SidebarPanel, SidebarRailItem, SidebarRow, SidebarToggle, useRowDialogs, } from './ui/sidebar.js';
44
46
  export { formatFileSize, isMac, isOrgModel } from './utils.js';
package/dist/ai/index.js CHANGED
@@ -35,13 +35,14 @@ import ChatEmptyState from "./components/ChatEmptyState.js";
35
35
  import { FOCUS_SIDEBAR_SEARCH_EVENT } from "./core/events.js";
36
36
  import { useKeyboardShortcuts } from "./core/useKeyboardShortcuts.js";
37
37
  import { KeyboardShortcutsProvider } from "./core/KeyboardShortcutsProvider.js";
38
- import { RenameDialog, SidebarGroupHeading, SidebarPanel, SidebarRailItem, SidebarRow, SidebarToggle, useRowDialogs } from "./ui/sidebar.js";
38
+ import { RenameDialog, SidebarDrawer, SidebarGroupHeading, SidebarPanel, SidebarRailItem, SidebarRow, SidebarToggle, useRowDialogs } from "./ui/sidebar.js";
39
+ import { ConversationSidebar, useResizableSidebarWidth } from "./ui/ConversationSidebar.js";
39
40
  import ShareDialog, { ShareButton } from "./components/ShareDialog.js";
40
41
  import ChatSidebar from "./components/ChatSidebar.js";
41
- import ChatLayout from "./components/ChatLayout.js";
42
+ import ChatLayout, { DRAWER_BELOW_PX } from "./components/ChatLayout.js";
42
43
  import ChatMessage_default from "./components/ChatMessage.js";
43
44
  import ChatMessageList from "./components/ChatMessageList.js";
44
45
  import ChatThread from "./components/ChatThread.js";
45
46
  import ComposerFrame from "./components/ComposerFrame.js";
46
47
  import { NoticeBar, NoticeCard, OutputCard } from "./ui/Notice.js";
47
- export { AGENT_COLOR_NAMES, ActivityLine, AgentMessageParts, AllocationSelector, ApprovalBlock, AttachmentManager, AttachmentUpload, BlockedGroupBanner, BranchNavigator, ChatAdapterError, ChatEmptyState, ChatInput, ChatLayout, ChatMessageList, ChatMessage_default as ChatMessageView, ChatProvider, ChatSidebar, ChatThread, ComposerContext, ComposerControls, ComposerFrame, ComposerSettings, ConnectToolsLink, DEFAULT_PERMISSION_MODE, DragOverlay, Dropdown, FOCUS_SIDEBAR_SEARCH_EVENT, INTERRUPTED_NOTICE, KeyboardShortcutsProvider, MAX_EDIT_DIFF_LINES, MAX_WRITE_DIFF_LINES, ModelSelector, NAMED_PERMISSION_MODES, NoticeBar, NoticeCard, OutputCard, PermissionPicker, ProviderIssueBanner, RenameDialog, ShareButton, ShareDialog, SidebarGroupHeading, SidebarPanel, SidebarRailItem, SidebarRow, SidebarToggle, agentColorHex, agentTypeLabel, applyPartDelta, askQuestionFromArgs, bashExitCode, chatReducer, clampPermissionMode, classifyPseudoUserMessage, clip, compactCount, computeEditDiff, computeWriteRows, defaultChatStrings, durationToAbsHumanDuration, editSummary, finalizeParts, firstNonEmptyLine, formatElapsed, formatFileSize, fullToolArgs, getGreeting, getProviderKeyFromModelId, hiddenTranscriptTool, initialState, interruptionNotice, isEditDiffTool, isGroupableTool, isMac, isNamedPermissionMode, isOrgModel, languageForPath, lineDelta, parseEditArgs, parseSubagentNotice, pasteInlineMaxBytes, permissionModesUpTo, planFromArgs, plural, providerIssueFor, resolveChatConfig, resultFailed, rollupSentence, sanitizeLabel, sanitizePanelText, slashMatches, subagentDeliveryLine, summarizeToolArgs, taskCallFromArgs, thinkingHeading, tokenizeDiffLines, toolHeaderFor, toolSummary, useChat, useChatConfig, useDragDrop, useKeyboardShortcuts, useRowDialogs, wordEmphasisRanges };
48
+ export { AGENT_COLOR_NAMES, ActivityLine, AgentMessageParts, AllocationSelector, ApprovalBlock, AttachmentManager, AttachmentUpload, BlockedGroupBanner, BranchNavigator, ChatAdapterError, ChatEmptyState, ChatInput, ChatLayout, ChatMessageList, ChatMessage_default as ChatMessageView, ChatProvider, ChatSidebar, ChatThread, ComposerContext, ComposerControls, ComposerFrame, ComposerSettings, ConnectToolsLink, ConversationSidebar, DEFAULT_PERMISSION_MODE, DRAWER_BELOW_PX, DragOverlay, Dropdown, FOCUS_SIDEBAR_SEARCH_EVENT, INTERRUPTED_NOTICE, KeyboardShortcutsProvider, MAX_EDIT_DIFF_LINES, MAX_WRITE_DIFF_LINES, ModelSelector, NAMED_PERMISSION_MODES, NoticeBar, NoticeCard, OutputCard, PermissionPicker, ProviderIssueBanner, RenameDialog, ShareButton, ShareDialog, SidebarDrawer, SidebarGroupHeading, SidebarPanel, SidebarRailItem, SidebarRow, SidebarToggle, agentColorHex, agentTypeLabel, applyPartDelta, askQuestionFromArgs, bashExitCode, chatReducer, clampPermissionMode, classifyPseudoUserMessage, clip, compactCount, computeEditDiff, computeWriteRows, defaultChatStrings, durationToAbsHumanDuration, editSummary, finalizeParts, firstNonEmptyLine, formatElapsed, formatFileSize, fullToolArgs, getGreeting, getProviderKeyFromModelId, hiddenTranscriptTool, initialState, interruptionNotice, isEditDiffTool, isGroupableTool, isMac, isNamedPermissionMode, isOrgModel, languageForPath, lineDelta, parseEditArgs, parseSubagentNotice, pasteInlineMaxBytes, permissionModesUpTo, planFromArgs, plural, providerIssueFor, resolveChatConfig, resultFailed, rollupSentence, sanitizeLabel, sanitizePanelText, slashMatches, subagentDeliveryLine, summarizeToolArgs, taskCallFromArgs, thinkingHeading, tokenizeDiffLines, toolHeaderFor, toolSummary, useChat, useChatConfig, useDragDrop, useKeyboardShortcuts, useResizableSidebarWidth, useRowDialogs, wordEmphasisRanges };
@@ -33,6 +33,10 @@ export interface ChatStrings {
33
33
  bannerKeyRejected: (provider: string) => string;
34
34
  bannerUnreachable: (provider: string) => string;
35
35
  };
36
+ modelPicker: {
37
+ /** Beside a provider another user registered and shared with the reader. */
38
+ sharedBy: (owner: string) => string;
39
+ };
36
40
  queue: {
37
41
  remove: string;
38
42
  /** Under a message that goes out once the running reply finishes. */
@@ -96,6 +100,10 @@ export interface ChatStrings {
96
100
  sharedWithYou: string;
97
101
  openSidebar: string;
98
102
  closeSidebar: string;
103
+ /** Names the drawer the list opens in on a narrow layout. */
104
+ drawerLabel: string;
105
+ resizeLabel: string;
106
+ resizeHint: string;
99
107
  newChat: string;
100
108
  attachments: string;
101
109
  connectTools: string;
@@ -47,6 +47,7 @@ var defaultChatStrings = {
47
47
  bannerKeyRejected: (provider) => `API key rejected for \u201c${provider}\u201d`,
48
48
  bannerUnreachable: (provider) => `\u201c${provider}\u201d is unreachable`
49
49
  },
50
+ modelPicker: { sharedBy: (owner) => `Shared by ${owner}` },
50
51
  activity: {
51
52
  tokens: (input, output) => `↑${input} ↓${output} tokens`,
52
53
  stop: "Stop"
@@ -100,6 +101,9 @@ var defaultChatStrings = {
100
101
  sharedWithYou: "Shared with you",
101
102
  openSidebar: "Open sidebar",
102
103
  closeSidebar: "Close sidebar",
104
+ drawerLabel: "Conversations",
105
+ resizeLabel: "Resize conversation list",
106
+ resizeHint: "Drag to resize; double-click to reset",
103
107
  newChat: "New chat",
104
108
  attachments: "Attachments",
105
109
  connectTools: "Connect Tools",
@@ -0,0 +1,61 @@
1
+ import { type ReactNode } from 'react';
2
+ export declare const SIDEBAR_MIN_WIDTH_PX = 200;
3
+ export declare const SIDEBAR_MAX_WIDTH_PX = 480;
4
+ /** A sidebar width the reader drags, nudges or resets, kept per list under
5
+ * its own storage key. */
6
+ export declare function useResizableSidebarWidth(storageKey: string): {
7
+ width: number;
8
+ resizing: boolean;
9
+ startResize: (e: React.PointerEvent<HTMLElement>) => void;
10
+ reset: () => void;
11
+ nudge: (delta: number) => void;
12
+ };
13
+ export interface ConversationSidebarGroup<T> {
14
+ key: string;
15
+ label: ReactNode;
16
+ tone?: 'default' | 'attention';
17
+ items: T[];
18
+ }
19
+ /** A list of conversations beside the thread: headed groups of rows that
20
+ * collapse to a rail, with an optional drag handle for the width. The host
21
+ * decides what is listed, how it is grouped and how each row reads. */
22
+ export declare function ConversationSidebar<T>({ label, collapsed: collapsedProp, onToggle, toggleLabels, resize, fill, groups, getKey, renderRow, loading, placeholder, top, rail, footer, cycle, groupTestId, children, }: {
23
+ label?: string;
24
+ collapsed: boolean;
25
+ onToggle: () => void;
26
+ toggleLabels: {
27
+ open: string;
28
+ close: string;
29
+ };
30
+ /** Omit for a fixed width. */
31
+ resize?: {
32
+ storageKey: string;
33
+ label: string;
34
+ hint: string;
35
+ };
36
+ /** Takes the width of its container, as inside a drawer, with no rail and
37
+ * no drag handle. */
38
+ fill?: boolean;
39
+ groups: ConversationSidebarGroup<T>[];
40
+ getKey: (item: T) => string;
41
+ /** One `SidebarRow`. */
42
+ renderRow: (item: T) => ReactNode;
43
+ loading?: boolean;
44
+ /** Shown instead of the groups when there are none, such as an empty
45
+ * state, a search with no matches, or an error. */
46
+ placeholder?: ReactNode;
47
+ /** Above the list, collapsed or not: new-item buttons and search. */
48
+ top?: ReactNode;
49
+ /** What the collapsed rail holds, as `SidebarRailItem`s. */
50
+ rail?: ReactNode;
51
+ /** Beside the toggle at the bottom, while expanded. */
52
+ footer?: ReactNode;
53
+ /** ⌘⌥↑ and ⌘⌥↓ (Ctrl+Alt elsewhere) select the previous or next item. */
54
+ cycle?: {
55
+ current: T | undefined;
56
+ onSelect: (item: T) => void;
57
+ };
58
+ groupTestId?: string;
59
+ /** Dialogs and menus that belong to the list. */
60
+ children?: ReactNode;
61
+ }): import("react").JSX.Element;
@@ -0,0 +1,165 @@
1
+ import { positionKeys } from "../../components/keys.js";
2
+ import { SidebarGroupHeading, SidebarPanel, SidebarToggle } from "./sidebar.js";
3
+ import cx from "classnames";
4
+ import { Fragment, useEffect, useRef, useState } from "react";
5
+ import { jsx, jsxs } from "react/jsx-runtime";
6
+ function clampSidebarWidth(width) {
7
+ return Math.min(480, Math.max(200, width));
8
+ }
9
+ function readStoredSidebarWidth(storageKey) {
10
+ try {
11
+ const stored = Number(window.localStorage.getItem(storageKey));
12
+ return Number.isFinite(stored) && stored > 0 ? clampSidebarWidth(stored) : 256;
13
+ } catch {
14
+ return 256;
15
+ }
16
+ }
17
+ function storeSidebarWidth(storageKey, width) {
18
+ try {
19
+ window.localStorage.setItem(storageKey, String(width));
20
+ } catch {}
21
+ }
22
+ /** A sidebar width the reader drags, nudges or resets, kept per list under
23
+ * its own storage key. */
24
+ function useResizableSidebarWidth(storageKey) {
25
+ const [width, setWidth] = useState(() => readStoredSidebarWidth(storageKey));
26
+ const [resizing, setResizing] = useState(false);
27
+ const widthRef = useRef(width);
28
+ widthRef.current = width;
29
+ const startResize = (e) => {
30
+ e.preventDefault();
31
+ const startX = e.clientX;
32
+ const startWidth = widthRef.current;
33
+ setResizing(true);
34
+ const onMove = (ev) => {
35
+ setWidth(clampSidebarWidth(startWidth + ev.clientX - startX));
36
+ };
37
+ const onUp = () => {
38
+ window.removeEventListener("pointermove", onMove);
39
+ window.removeEventListener("pointerup", onUp);
40
+ setResizing(false);
41
+ storeSidebarWidth(storageKey, widthRef.current);
42
+ };
43
+ window.addEventListener("pointermove", onMove);
44
+ window.addEventListener("pointerup", onUp);
45
+ };
46
+ const reset = () => {
47
+ setWidth(256);
48
+ storeSidebarWidth(storageKey, 256);
49
+ };
50
+ const nudge = (delta) => {
51
+ const next = clampSidebarWidth(widthRef.current + delta);
52
+ setWidth(next);
53
+ storeSidebarWidth(storageKey, next);
54
+ };
55
+ return {
56
+ width,
57
+ resizing,
58
+ startResize,
59
+ reset,
60
+ nudge
61
+ };
62
+ }
63
+ /** Steps through the listed items in order and wraps, so moving between
64
+ * conversations never needs the pointer. */
65
+ function useItemCycling(ordered, cycle) {
66
+ const latest = useRef({
67
+ ordered,
68
+ cycle
69
+ });
70
+ latest.current = {
71
+ ordered,
72
+ cycle
73
+ };
74
+ const enabled = !!cycle;
75
+ useEffect(() => {
76
+ if (!enabled) return;
77
+ const onKeyDown = (e) => {
78
+ const step = e.key === "ArrowDown" ? 1 : e.key === "ArrowUp" ? -1 : 0;
79
+ const { ordered: items, cycle: c } = latest.current;
80
+ if (!step || !e.altKey || !(e.metaKey || e.ctrlKey) || !c || items.length === 0) return;
81
+ e.preventDefault();
82
+ const at = c.current === void 0 ? -1 : items.indexOf(c.current);
83
+ const next = at < 0 ? items[step > 0 ? 0 : items.length - 1] : items[(at + step + items.length) % items.length];
84
+ if (next !== void 0) c.onSelect(next);
85
+ };
86
+ document.addEventListener("keydown", onKeyDown);
87
+ return () => document.removeEventListener("keydown", onKeyDown);
88
+ }, [enabled]);
89
+ }
90
+ /** A list of conversations beside the thread: headed groups of rows that
91
+ * collapse to a rail, with an optional drag handle for the width. The host
92
+ * decides what is listed, how it is grouped and how each row reads. */
93
+ function ConversationSidebar({ label, collapsed: collapsedProp, onToggle, toggleLabels, resize, fill = false, groups, getKey, renderRow, loading = false, placeholder, top, rail, footer, cycle, groupTestId, children }) {
94
+ const sized = useResizableSidebarWidth(resize?.storageKey ?? "");
95
+ useItemCycling(groups.flatMap((g) => g.items), cycle);
96
+ const collapsed = collapsedProp && !fill;
97
+ const resizable = resize && !fill;
98
+ return /* @__PURE__ */ jsxs(SidebarPanel, {
99
+ collapsed,
100
+ label,
101
+ ...resizable ? {
102
+ width: sized.width,
103
+ resizing: sized.resizing
104
+ } : {},
105
+ ...fill ? { style: { width: "100%" } } : {},
106
+ children: [
107
+ resizable && !collapsed && /* @__PURE__ */ jsx("div", {
108
+ role: "separator",
109
+ tabIndex: 0,
110
+ "aria-orientation": "vertical",
111
+ "aria-label": resize.label,
112
+ "aria-valuemin": 200,
113
+ "aria-valuemax": 480,
114
+ "aria-valuenow": sized.width,
115
+ title: resize.hint,
116
+ onPointerDown: sized.startResize,
117
+ onDoubleClick: sized.reset,
118
+ onKeyDown: (e) => {
119
+ if (e.key === "ArrowLeft") sized.nudge(-16);
120
+ else if (e.key === "ArrowRight") sized.nudge(16);
121
+ },
122
+ className: cx("absolute inset-y-0 right-0 z-10 w-1 cursor-col-resize touch-none", sized.resizing ? "bg-(--theme-element)" : "hover:bg-(--theme-border)")
123
+ }),
124
+ top,
125
+ collapsed && rail ? /* @__PURE__ */ jsx("ul", {
126
+ className: "flex min-h-0 flex-1 flex-col items-center gap-1 overflow-y-auto py-1",
127
+ children: rail
128
+ }) : /* @__PURE__ */ jsx("div", {
129
+ className: cx("min-h-0 flex-1 overflow-y-auto px-2 pb-2 transition-opacity duration-200", collapsed ? "pointer-events-none opacity-0" : "opacity-100"),
130
+ children: loading ? /* @__PURE__ */ jsx("div", {
131
+ className: "space-y-3 px-2 pt-2",
132
+ children: positionKeys(5, "skeleton").map((key, i) => /* @__PURE__ */ jsx("div", {
133
+ className: "animate-pulse",
134
+ children: /* @__PURE__ */ jsx("div", {
135
+ className: "h-4 rounded theme-muted-panel",
136
+ style: { width: `${70 - i * 10}%` }
137
+ })
138
+ }, key))
139
+ }) : groups.length === 0 ? placeholder : groups.map((group) => /* @__PURE__ */ jsxs("div", {
140
+ className: "mb-2",
141
+ children: [/* @__PURE__ */ jsx(SidebarGroupHeading, {
142
+ tone: group.tone ?? "default",
143
+ testId: groupTestId,
144
+ children: group.label
145
+ }), /* @__PURE__ */ jsx("ul", {
146
+ className: "space-y-0.5",
147
+ children: group.items.map((item) => /* @__PURE__ */ jsx(Fragment, { children: renderRow(item) }, getKey(item)))
148
+ })]
149
+ }, group.key))
150
+ }),
151
+ /* @__PURE__ */ jsxs("div", {
152
+ className: cx("flex shrink-0 items-center gap-1 p-1.5", collapsed ? "justify-center" : footer ? "justify-between" : "justify-end"),
153
+ children: [!collapsed && footer, /* @__PURE__ */ jsx(SidebarToggle, {
154
+ collapsed,
155
+ onToggle,
156
+ openLabel: toggleLabels.open,
157
+ closeLabel: toggleLabels.close
158
+ })]
159
+ }),
160
+ children
161
+ ]
162
+ });
163
+ }
164
+ //#endregion
165
+ export { ConversationSidebar, useResizableSidebarWidth };
@@ -8,11 +8,22 @@ export declare function SidebarPanel({ collapsed, width, railWidth, resizing, la
8
8
  width?: number;
9
9
  railWidth?: number;
10
10
  resizing?: boolean;
11
- label?: string;
11
+ label?: string | undefined;
12
12
  className?: string;
13
13
  style?: CSSProperties;
14
14
  children: ReactNode;
15
15
  }): import("react").JSX.Element;
16
+ /** The list over the content of a layout too narrow to keep it beside the
17
+ * content. Place it in a positioned container: the drawer covers that
18
+ * container, not the window, so an embedded chat keeps to its own panel.
19
+ * Tapping outside or Escape closes it, and focus returns where it was. */
20
+ export declare function SidebarDrawer({ open, onClose, label, closeLabel, children, }: {
21
+ open: boolean;
22
+ onClose: () => void;
23
+ label: string;
24
+ closeLabel: string;
25
+ children: ReactNode;
26
+ }): import("react").JSX.Element;
16
27
  export declare function SidebarToggle({ collapsed, onToggle, openLabel, closeLabel, className, }: {
17
28
  collapsed: boolean;
18
29
  onToggle: () => void;
@@ -24,7 +35,7 @@ export declare function SidebarGroupHeading({ children, tone, testId, }: {
24
35
  children: ReactNode;
25
36
  /** 'attention' is for the group the reader is being asked to act on. */
26
37
  tone?: 'default' | 'attention';
27
- testId?: string;
38
+ testId?: string | undefined;
28
39
  }): import("react").JSX.Element;
29
40
  /** One entry in the list: the row highlights while it is the open one or
30
41
  * while its menu is up, and the menu opens from its button or a right-click. */
@@ -3,7 +3,7 @@ import { ConfirmModal } from "../../components/ConfirmModal.js";
3
3
  import { focusOnMount } from "../../components/focus.js";
4
4
  import { MoreButton, rowContextMenuProps, useRowMenuActive } from "../../list/RowContextMenu.js";
5
5
  import cx from "classnames";
6
- import { useState } from "react";
6
+ import { useEffect, useRef, useState } from "react";
7
7
  import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
8
8
  //#region src/ai/ui/sidebar.tsx
9
9
  var SIDEBAR_RAIL_WIDTH_PX = 48;
@@ -22,6 +22,41 @@ function SidebarPanel({ collapsed, width = 256, railWidth = SIDEBAR_RAIL_WIDTH_P
22
22
  children
23
23
  });
24
24
  }
25
+ /** The list over the content of a layout too narrow to keep it beside the
26
+ * content. Place it in a positioned container: the drawer covers that
27
+ * container, not the window, so an embedded chat keeps to its own panel.
28
+ * Tapping outside or Escape closes it, and focus returns where it was. */
29
+ function SidebarDrawer({ open, onClose, label, closeLabel, children }) {
30
+ const panelRef = useRef(null);
31
+ useEffect(() => {
32
+ if (!open) return;
33
+ const returnTo = document.activeElement instanceof HTMLElement ? document.activeElement : null;
34
+ panelRef.current?.focus();
35
+ return () => returnTo?.focus();
36
+ }, [open]);
37
+ return /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx("button", {
38
+ type: "button",
39
+ tabIndex: -1,
40
+ "aria-label": closeLabel,
41
+ onClick: onClose,
42
+ className: cx("absolute inset-0 z-20 bg-black/40 transition-opacity duration-300", open ? "opacity-100" : "pointer-events-none opacity-0")
43
+ }), /* @__PURE__ */ jsx("div", {
44
+ ref: panelRef,
45
+ role: "dialog",
46
+ "aria-modal": "true",
47
+ "aria-label": label,
48
+ tabIndex: -1,
49
+ inert: !open,
50
+ onKeyDown: (e) => {
51
+ if (e.key === "Escape") {
52
+ e.stopPropagation();
53
+ onClose();
54
+ }
55
+ },
56
+ className: cx("absolute inset-y-0 left-0 z-30 flex w-[min(20rem,85%)] shadow-xl transition-transform duration-300 ease-in-out focus:outline-none", open ? "translate-x-0" : "-translate-x-full"),
57
+ children
58
+ })] });
59
+ }
25
60
  function SidebarToggle({ collapsed, onToggle, openLabel, closeLabel, className }) {
26
61
  const label = collapsed ? openLabel : closeLabel;
27
62
  return /* @__PURE__ */ jsx("button", {
@@ -147,4 +182,4 @@ function useRowDialogs({ strings, nameOf, onDelete, onRename }) {
147
182
  };
148
183
  }
149
184
  //#endregion
150
- export { RenameDialog, SidebarGroupHeading, SidebarPanel, SidebarRailItem, SidebarRow, SidebarToggle, useRowDialogs };
185
+ export { RenameDialog, SidebarDrawer, SidebarGroupHeading, SidebarPanel, SidebarRailItem, SidebarRow, SidebarToggle, useRowDialogs };