From 0dda93695e7f13defabfac6af80b8fd7feef0820 Mon Sep 17 00:00:00 2001 From: Kobe Attias Date: Mon, 21 Sep 2026 21:44:48 -0400 Subject: [PATCH] Break the AI Mode overlay into a shell, a sidebar and the panes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AIModeOverlay.tsx is composition only now. What it did itself moves to named modules: shell/useModalOverlayBehavior (focus, inert siblings, scroll lock, Esc), shell/AIModeHeader, and shell/WorkspacePanes, which lays out sidebar, chat and document. The chat and the document keep fixed places in the tree and swap on screen with flex order, so the layout can change without remounting the editor or the transcript. The sidebar is its own set of pieces — WorkspaceSidebar, a ConversationsSection that shows the most recent conversations and a "Show all" for the rest, and a SidebarRow and SidebarGroupHeading the documents section will share. The rest of the folder is grouped by concern: chat/, document/, start/, sidebar/, shell/. Verified with type-check, eslint and prettier only. Co-Authored-By: Claude Fable 5.1 --- components/AIMode/AIModeOverlay.tsx | 248 ++++-------------- components/AIMode/ConversationList.tsx | 196 -------------- components/AIMode/{ => chat}/ChatPane.tsx | 8 +- .../{ => chat}/ConversationTitleField.tsx | 0 components/AIMode/{ => chat}/DocumentCard.tsx | 2 +- .../AIMode/{ => document}/DocumentPane.tsx | 4 +- .../{ => document}/DraftBlockPreview.tsx | 0 .../{ => document}/useAIModeDocument.ts | 0 components/AIMode/shell/AIModeHeader.tsx | 46 ++++ components/AIMode/shell/WorkspacePanes.tsx | 149 +++++++++++ .../AIMode/shell/useModalOverlayBehavior.ts | 76 ++++++ .../AIMode/{ => sidebar}/ConversationMenu.tsx | 0 .../AIMode/sidebar/ConversationsSection.tsx | 139 ++++++++++ .../AIMode/sidebar/SidebarGroupHeading.tsx | 19 ++ components/AIMode/sidebar/SidebarRow.tsx | 77 ++++++ .../AIMode/sidebar/WorkspaceSidebar.tsx | 56 ++++ 16 files changed, 615 insertions(+), 405 deletions(-) delete mode 100644 components/AIMode/ConversationList.tsx rename components/AIMode/{ => chat}/ChatPane.tsx (97%) rename components/AIMode/{ => chat}/ConversationTitleField.tsx (100%) rename components/AIMode/{ => chat}/DocumentCard.tsx (97%) rename components/AIMode/{ => document}/DocumentPane.tsx (99%) rename components/AIMode/{ => document}/DraftBlockPreview.tsx (100%) rename components/AIMode/{ => document}/useAIModeDocument.ts (100%) create mode 100644 components/AIMode/shell/AIModeHeader.tsx create mode 100644 components/AIMode/shell/WorkspacePanes.tsx create mode 100644 components/AIMode/shell/useModalOverlayBehavior.ts rename components/AIMode/{ => sidebar}/ConversationMenu.tsx (100%) create mode 100644 components/AIMode/sidebar/ConversationsSection.tsx create mode 100644 components/AIMode/sidebar/SidebarGroupHeading.tsx create mode 100644 components/AIMode/sidebar/SidebarRow.tsx create mode 100644 components/AIMode/sidebar/WorkspaceSidebar.tsx diff --git a/components/AIMode/AIModeOverlay.tsx b/components/AIMode/AIModeOverlay.tsx index 01c6a0c03..9393a2a47 100644 --- a/components/AIMode/AIModeOverlay.tsx +++ b/components/AIMode/AIModeOverlay.tsx @@ -2,24 +2,22 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; -import { PanelRight, Sparkles, X } from 'lucide-react'; +import { PanelRight } from 'lucide-react'; import { cn } from '@/utils/styles'; -import { ResizeHandle } from '@/components/ui/ResizeHandle'; -import { SwipeableDrawer } from '@/components/ui/SwipeableDrawer'; import { useMediaQuery } from '@/hooks/useMediaQuery'; import { useResizableWidth } from '@/hooks/useResizableWidth'; import { useAIMode } from './AIModeContext'; -import { ChatPane } from './ChatPane'; -import { ConversationList } from './ConversationList'; -import { DocumentCard } from './DocumentCard'; -import { DocumentPane, type DocumentPaneView } from './DocumentPane'; +import { ChatPane } from './chat/ChatPane'; +import { DocumentCard } from './chat/DocumentCard'; +import { DocumentPane, type DocumentPaneView } from './document/DocumentPane'; +import { useAIModeDocument } from './document/useAIModeDocument'; +import { AIModeHeader } from './shell/AIModeHeader'; +import { useModalOverlayBehavior } from './shell/useModalOverlayBehavior'; +import { WorkspacePanes } from './shell/WorkspacePanes'; +import { WorkspaceSidebar } from './sidebar/WorkspaceSidebar'; import { useAIModeChat } from './useAIModeChat'; -import { useAIModeDocument } from './useAIModeDocument'; import { AI_MODE_NAME } from './copy'; -/** Above the overlay (9500), below BaseModal (9999). */ -const AI_MODE_DRAWER_Z_INDEX = 9600; - const LIST_MIN_WIDTH = 200; const LIST_MAX_WIDTH = 440; const LIST_DEFAULT_WIDTH = 264; @@ -31,35 +29,14 @@ const DETAILS_MIN_WIDTH = 560; const DOCUMENT_DEFAULT_SHARE = 0.55; /** - * A modal that portals outside the overlay (BaseModal, a drawer) is showing. - * Closed drawers stay mounted off-screen with `role="dialog"`, so presence in - * the DOM is not enough — the box has to intersect the viewport. - */ -function isForeignDialogOpen(): boolean { - const overlay = document.getElementById('ai-mode-overlay'); - return Array.from(document.querySelectorAll('[role="dialog"]')).some((el) => { - if (overlay?.contains(el)) return false; - const rect = el.getBoundingClientRect(); - return ( - rect.width > 0 && - rect.height > 0 && - rect.bottom > 0 && - rect.right > 0 && - rect.top < window.innerHeight && - rect.left < window.innerWidth - ); - }); -} - -/** - * The full-viewport shell: header, conversation list, chat, document. Sits - * below BaseModal (9999) and Tooltip (10000) so real modals and tooltips - * opened from inside it still render on top. + * The full-viewport workspace: header, sidebar, chat, document. Sits below + * BaseModal (9999) and Tooltip (10000) so real modals and tooltips opened + * from inside it still render on top. */ export function AIModeOverlay() { const { close } = useAIMode(); const state = useAIModeChat(); - // Below the tablet breakpoint the list lives in a bottom drawer. + // Below the tablet breakpoint the sidebar lives in a bottom drawer. const [listDrawerOpen, setListDrawerOpen] = useState(false); const closeListDrawer = useCallback(() => setListDrawerOpen(false), []); @@ -69,7 +46,7 @@ export function AIModeOverlay() { latestExecution: state.chat.latestExecution, }); - // Tailwind's `tablet` breakpoint; the drawer only exists below it. + // Tailwind's `tablet` breakpoint; the drawers only exist below it. const isBelowTablet = useMediaQuery('(max-width: 767px)') === true; // ---- pane widths, claude.ai style: both side panes drag, the chat takes the rest ---- @@ -152,69 +129,19 @@ export function AIModeOverlay() { /> ) : null; - // Esc closes, unless something inside already claimed it (a menu, a modal - // that portals outside the overlay). - useEffect(() => { - const handleKeyDown = (event: KeyboardEvent) => { - if (event.key !== 'Escape' || event.defaultPrevented) return; - if (isForeignDialogOpen()) return; - close(); - }; - document.addEventListener('keydown', handleKeyDown); - return () => document.removeEventListener('keydown', handleKeyDown); - }, [close]); - - // A real modal: the overlay portals to the body and everything else at - // the top level goes inert while it is open, so nothing behind it — a - // notebook editor that autofocuses late, say — can take focus or keys. - // Layers that mount later (menus, tooltips, modals) append after and stay - // live; the overlay's own drawers render inside it. const [rootEl, setRootEl] = useState(null); - useEffect(() => { - if (!rootEl) return; - rootEl.focus(); - const inerted: Element[] = []; - for (const child of Array.from(document.body.children)) { - if (child === rootEl || child.tagName === 'SCRIPT' || child.tagName === 'NEXTJS-PORTAL') { - continue; - } - if (child.hasAttribute('inert')) continue; - child.setAttribute('inert', ''); - inerted.push(child); - } - return () => { - for (const child of inerted) child.removeAttribute('inert'); - }; - }, [rootEl]); - - // Lock the page behind the overlay. - useEffect(() => { - const previous = document.body.style.overflow; - document.body.style.overflow = 'hidden'; - return () => { - document.body.style.overflow = previous; - }; - }, []); - - const conversationList = ( - { - state.selectChat(chatId); - closeListDrawer(); - }} - onNew={() => { - state.startNewChat(); - closeListDrawer(); - }} - onRename={state.rename} - onDelete={state.deleteChat} - loadNotes={state.notesForChat} - onRetry={state.list.refresh} + useModalOverlayBehavior({ rootEl, onEscape: close }); + + const documentPane = (presentation: 'pane' | 'drawer') => ( + ); @@ -228,50 +155,23 @@ export function AIModeOverlay() { aria-label={AI_MODE_NAME} className="fixed inset-0 z-[9500] flex flex-col bg-gray-50 outline-none" > -
-
-
-
- {showDocument && !isBelowTablet && ( - <> - - {documentTitle} - -
-
-
+ -
- -
+ } + chat={ setListDrawerOpen(true)} @@ -297,66 +197,10 @@ export function AIModeOverlay() { ) } /> -
- {/* One editor per note at a time: the pane mounts in the column above - the tablet breakpoint and in the drawer below it, never both. */} - {showDocument && !isBelowTablet && ( - - )} -
- - {/* Drawers portal to the body, so they need to stack above this overlay - (z-9500) while staying under BaseModal (9999). */} - - {conversationList} - - - {showDocument && isBelowTablet && ( - - )} - + } + document={showDocument ? documentPane('pane') : null} + documentDrawer={showDocument ? documentPane('drawer') : null} + /> , document.body ); diff --git a/components/AIMode/ConversationList.tsx b/components/AIMode/ConversationList.tsx deleted file mode 100644 index 81093e1dc..000000000 --- a/components/AIMode/ConversationList.tsx +++ /dev/null @@ -1,196 +0,0 @@ -'use client'; - -import { useState } from 'react'; -import { Plus } from 'lucide-react'; -import { Loader } from '@/components/ui/Loader'; -import { ConversationListSkeleton } from '@/components/skeletons/AIModeSkeleton'; -import { Button } from '@/components/ui/Button'; -import { formatTimeAgo } from '@/utils/date'; -import { cn } from '@/utils/styles'; -import type { ChatNoteRef, AgentChatListItem } from '@/types/agentChat'; -import type { ChatListAccess } from '@/hooks/useAgentChat'; -import { ConversationMenu } from './ConversationMenu'; -import { ConversationTitleField } from './ConversationTitleField'; - -const UNTITLED = 'Untitled conversation'; - -interface ConversationListProps { - readonly chats: AgentChatListItem[]; - readonly access: ChatListAccess; - readonly accessDetail: string | null; - readonly activeChatId: number | null; - /** Resolves a row's title, showing a rename before the server confirms it. */ - readonly titleFor: (chatId: number, fallback: string | null) => string | null; - readonly onSelect: (chatId: number) => void; - readonly onNew: () => void; - readonly onRename: (chatId: number, title: string) => Promise; - readonly onDelete: (chatId: number, options: { deleteNotes: boolean }) => Promise; - readonly loadNotes: (chatId: number) => Promise; - readonly onRetry: () => void; -} - -/** - * The left pane: the user's assistant conversations, newest activity first - * as the server orders them. Rows rename through a menu; nothing deletes, - * because the backend has no endpoint for it. - */ -export function ConversationList({ - chats, - access, - accessDetail, - activeChatId, - titleFor, - onSelect, - onNew, - onRename, - onDelete, - loadNotes, - onRetry, -}: ConversationListProps) { - const [renamingId, setRenamingId] = useState(null); - - return ( -
-
- -
- -
- {access === 'loading' && } - - {access === 'hidden' && ( -

- {accessDetail ?? 'You don’t have access to the assistant.'} -

- )} - - {access === 'error' && ( -
-

- {accessDetail ?? 'Couldn’t load your conversations.'} -

- -
- )} - - {access === 'ok' && chats.length === 0 && ( -

No conversations yet.

- )} - - {chats.map((item) => { - const isActive = item.id === activeChatId; - const title = titleFor(item.id, item.title); - return ( - onSelect(item.id)} - onStartRename={() => setRenamingId(item.id)} - onCancelRename={() => setRenamingId(null)} - onCommitRename={async (value) => { - setRenamingId(null); - const next = value.trim(); - if (!next || next === (title ?? '')) return; - await onRename(item.id, next); - }} - onDelete={(options) => onDelete(item.id, options)} - loadNotes={() => loadNotes(item.id)} - /> - ); - })} -
-
- ); -} - -interface ConversationRowProps { - readonly item: AgentChatListItem; - readonly title: string; - readonly isActive: boolean; - readonly renaming: boolean; - readonly onSelect: () => void; - readonly onStartRename: () => void; - readonly onCancelRename: () => void; - readonly onCommitRename: (value: string) => void; - readonly onDelete: (options: { deleteNotes: boolean }) => void; - readonly loadNotes: () => Promise; -} - -function ConversationRow({ - item, - title, - isActive, - renaming, - onSelect, - onStartRename, - onCancelRename, - onCommitRename, - onDelete, - loadNotes, -}: ConversationRowProps) { - return ( -
- {renaming ? ( -
- -
- ) : ( - - )} - - {!renaming && ( -
- -
- )} -
- ); -} diff --git a/components/AIMode/ChatPane.tsx b/components/AIMode/chat/ChatPane.tsx similarity index 97% rename from components/AIMode/ChatPane.tsx rename to components/AIMode/chat/ChatPane.tsx index 144425788..a4b909ce9 100644 --- a/components/AIMode/ChatPane.tsx +++ b/components/AIMode/chat/ChatPane.tsx @@ -7,14 +7,14 @@ import { ChatTranscript } from '@/components/AgentChat/ChatTranscript'; import { JumpToLatestButton } from '@/components/AgentChat/JumpToLatestButton'; import { ModelControls } from '@/components/AgentChat/ModelControls'; import { useJumpToLatest } from '@/hooks/useJumpToLatest'; -import { ConversationMenu } from './ConversationMenu'; +import { ConversationMenu } from '../sidebar/ConversationMenu'; import { ConversationTitleField } from './ConversationTitleField'; import { Button } from '@/components/ui/Button'; import { ChatTranscriptSkeleton } from '@/components/skeletons/AIModeSkeleton'; import { cn } from '@/utils/styles'; -import type { AIModeChatState } from './useAIModeChat'; -import { aiModeGreeting, INTENT_COPY } from './copy'; -import { StartScreen } from './start/StartScreen'; +import type { AIModeChatState } from '../useAIModeChat'; +import { aiModeGreeting, INTENT_COPY } from '../copy'; +import { StartScreen } from '../start/StartScreen'; import { useUser } from '@/contexts/UserContext'; interface ChatPaneProps { diff --git a/components/AIMode/ConversationTitleField.tsx b/components/AIMode/chat/ConversationTitleField.tsx similarity index 100% rename from components/AIMode/ConversationTitleField.tsx rename to components/AIMode/chat/ConversationTitleField.tsx diff --git a/components/AIMode/DocumentCard.tsx b/components/AIMode/chat/DocumentCard.tsx similarity index 97% rename from components/AIMode/DocumentCard.tsx rename to components/AIMode/chat/DocumentCard.tsx index 1542867c6..e8511b6a9 100644 --- a/components/AIMode/DocumentCard.tsx +++ b/components/AIMode/chat/DocumentCard.tsx @@ -3,7 +3,7 @@ import { FileText, Lock } from 'lucide-react'; import { Loader } from '@/components/ui/Loader'; import { cn } from '@/utils/styles'; -import type { DocumentStatus } from './useAIModeDocument'; +import type { DocumentStatus } from '../document/useAIModeDocument'; interface DocumentCardProps { readonly title: string; diff --git a/components/AIMode/DocumentPane.tsx b/components/AIMode/document/DocumentPane.tsx similarity index 99% rename from components/AIMode/DocumentPane.tsx rename to components/AIMode/document/DocumentPane.tsx index 0db8c753e..8f7e92243 100644 --- a/components/AIMode/DocumentPane.tsx +++ b/components/AIMode/document/DocumentPane.tsx @@ -24,8 +24,8 @@ import { DocumentPaneSkeleton } from '@/components/skeletons/AIModeSkeleton'; import { useUpdateNote } from '@/hooks/useNote'; import type { AgentChat } from '@/types/agentChat'; import { cn } from '@/utils/styles'; -import { DetailBlockStrip } from './document/details/DetailBlockStrip'; -import { PublishControls } from './document/PublishControls'; +import { DetailBlockStrip } from './details/DetailBlockStrip'; +import { PublishControls } from './PublishControls'; import type { AIModeDocument } from './useAIModeDocument'; /** The document itself, or the full publishing details form. */ diff --git a/components/AIMode/DraftBlockPreview.tsx b/components/AIMode/document/DraftBlockPreview.tsx similarity index 100% rename from components/AIMode/DraftBlockPreview.tsx rename to components/AIMode/document/DraftBlockPreview.tsx diff --git a/components/AIMode/useAIModeDocument.ts b/components/AIMode/document/useAIModeDocument.ts similarity index 100% rename from components/AIMode/useAIModeDocument.ts rename to components/AIMode/document/useAIModeDocument.ts diff --git a/components/AIMode/shell/AIModeHeader.tsx b/components/AIMode/shell/AIModeHeader.tsx new file mode 100644 index 000000000..1cccd8660 --- /dev/null +++ b/components/AIMode/shell/AIModeHeader.tsx @@ -0,0 +1,46 @@ +'use client'; + +import { Sparkles, X } from 'lucide-react'; +import { AI_MODE_NAME } from '../copy'; + +interface AIModeHeaderProps { + /** The open document's title, shown beside its publish controls; null when none is open. */ + readonly documentTitle: string | null; + /** + * Receives the element the document pane renders its publish controls + * into. Absent below the tablet breakpoint, where the drawer keeps them. + */ + readonly publishControlsRef?: (element: HTMLDivElement | null) => void; + readonly onClose: () => void; +} + +/** The workspace's top strip: the name, the open document's publishing state, close. */ +export function AIModeHeader({ documentTitle, publishControlsRef, onClose }: AIModeHeaderProps) { + return ( +
+
+
+
+ {documentTitle != null && publishControlsRef && ( + <> + + {documentTitle} + +
+
+
+ ); +} diff --git a/components/AIMode/shell/WorkspacePanes.tsx b/components/AIMode/shell/WorkspacePanes.tsx new file mode 100644 index 000000000..bb0694c49 --- /dev/null +++ b/components/AIMode/shell/WorkspacePanes.tsx @@ -0,0 +1,149 @@ +'use client'; + +import type { ReactNode } from 'react'; +import { ResizeHandle } from '@/components/ui/ResizeHandle'; +import { SwipeableDrawer } from '@/components/ui/SwipeableDrawer'; +import type { useResizableWidth } from '@/hooks/useResizableWidth'; +import { cn } from '@/utils/styles'; + +/** Above the overlay (9500), below BaseModal (9999). */ +const AI_MODE_DRAWER_Z_INDEX = 9600; + +type ResizableWidth = ReturnType; + +/** Which pane is the flexible main one; the other sits at a fixed width on the right. */ +export type WorkspaceLayout = 'chat' | 'document'; + +interface WorkspacePanesProps { + readonly layout: WorkspaceLayout; + readonly sidebar: ReactNode; + readonly chat: ReactNode; + /** The document column; null while no document is open. */ + readonly document: ReactNode | null; + /** The document as the drawer shows it below the tablet breakpoint. */ + readonly documentDrawer: ReactNode | null; + readonly isBelowTablet: boolean; + readonly sidebarWidth: ResizableWidth & { readonly min: number; readonly max: number }; + readonly sideWidth: ResizableWidth & { readonly min: number; readonly max: number }; + readonly listDrawerOpen: boolean; + readonly onCloseListDrawer: () => void; + readonly onCloseDocumentDrawer: () => void; + /** The overlay's root, which the drawers render inside so they stay live. */ + readonly container: HTMLElement | null; +} + +/** + * The three columns: sidebar, chat, document. The chat and the document keep + * their places in the tree whichever is the main pane — swapping them would + * remount the editor and the transcript — and trade places on screen with + * flex order. Both side panes drag; the main pane takes the rest. Below the + * tablet breakpoint the sidebar and the document live in bottom drawers. + */ +export function WorkspacePanes({ + layout, + sidebar, + chat, + document, + documentDrawer, + isBelowTablet, + sidebarWidth, + sideWidth, + listDrawerOpen, + onCloseListDrawer, + onCloseDocumentDrawer, + container, +}: WorkspacePanesProps) { + const documentOpen = document != null; + // With no document there is nothing to trade places with: the chat fills the space. + const chatIsMain = layout === 'chat' || !documentOpen; + + return ( + <> +
+ + +
+ {!chatIsMain && } + {chat} +
+ + {documentOpen && !isBelowTablet && ( + + )} +
+ + {/* Drawers portal to the body, so they need to stack above this overlay + (z-9500) while staying under BaseModal (9999). */} + + {sidebar} + + + {documentOpen && isBelowTablet && documentDrawer} + + + ); +} + +function SideResizeHandle({ + label, + width, +}: { + readonly label: string; + readonly width: WorkspacePanesProps['sideWidth']; +}) { + return ( + + ); +} diff --git a/components/AIMode/shell/useModalOverlayBehavior.ts b/components/AIMode/shell/useModalOverlayBehavior.ts new file mode 100644 index 000000000..ce9912628 --- /dev/null +++ b/components/AIMode/shell/useModalOverlayBehavior.ts @@ -0,0 +1,76 @@ +'use client'; + +import { useEffect } from 'react'; + +/** + * A modal that portals outside the overlay (BaseModal, a drawer) is showing. + * Closed drawers stay mounted off-screen with `role="dialog"`, so presence in + * the DOM is not enough — the box has to intersect the viewport. + */ +function isForeignDialogOpen(overlay: HTMLElement): boolean { + return Array.from(document.querySelectorAll('[role="dialog"]')).some((el) => { + if (overlay.contains(el)) return false; + const rect = el.getBoundingClientRect(); + return ( + rect.width > 0 && + rect.height > 0 && + rect.bottom > 0 && + rect.right > 0 && + rect.top < window.innerHeight && + rect.left < window.innerWidth + ); + }); +} + +interface ModalOverlayBehaviorOptions { + /** The overlay's root; nothing happens until it is mounted. */ + readonly rootEl: HTMLElement | null; + readonly onEscape: () => void; +} + +/** + * What makes a full-viewport overlay a real modal: it takes focus, everything + * else at the top level goes inert while it is open (so nothing behind it — + * a notebook editor that autofocuses late, say — can take focus or keys), + * the page behind it stops scrolling, and Esc closes it unless something + * inside already claimed the key (a menu, a modal that portals outside). + * Layers that mount later (menus, tooltips, modals) append after and stay + * live; the overlay's own drawers render inside it. + */ +export function useModalOverlayBehavior({ rootEl, onEscape }: ModalOverlayBehaviorOptions) { + useEffect(() => { + if (!rootEl) return; + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key !== 'Escape' || event.defaultPrevented) return; + if (isForeignDialogOpen(rootEl)) return; + onEscape(); + }; + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [rootEl, onEscape]); + + useEffect(() => { + if (!rootEl) return; + rootEl.focus(); + const inerted: Element[] = []; + for (const child of Array.from(document.body.children)) { + if (child === rootEl || child.tagName === 'SCRIPT' || child.tagName === 'NEXTJS-PORTAL') { + continue; + } + if (child.hasAttribute('inert')) continue; + child.setAttribute('inert', ''); + inerted.push(child); + } + return () => { + for (const child of inerted) child.removeAttribute('inert'); + }; + }, [rootEl]); + + useEffect(() => { + const previous = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + return () => { + document.body.style.overflow = previous; + }; + }, []); +} diff --git a/components/AIMode/ConversationMenu.tsx b/components/AIMode/sidebar/ConversationMenu.tsx similarity index 100% rename from components/AIMode/ConversationMenu.tsx rename to components/AIMode/sidebar/ConversationMenu.tsx diff --git a/components/AIMode/sidebar/ConversationsSection.tsx b/components/AIMode/sidebar/ConversationsSection.tsx new file mode 100644 index 000000000..9e310624f --- /dev/null +++ b/components/AIMode/sidebar/ConversationsSection.tsx @@ -0,0 +1,139 @@ +'use client'; + +import { useState } from 'react'; +import { Loader } from '@/components/ui/Loader'; +import { ConversationListSkeleton } from '@/components/skeletons/AIModeSkeleton'; +import { Button } from '@/components/ui/Button'; +import type { ChatListAccess } from '@/hooks/useAgentChat'; +import type { AgentChatListItem, ChatNoteRef } from '@/types/agentChat'; +import { formatTimeAgo } from '@/utils/date'; +import { ConversationTitleField } from '../chat/ConversationTitleField'; +import { ConversationMenu } from './ConversationMenu'; +import { SidebarGroupHeading } from './SidebarGroupHeading'; +import { SidebarRow } from './SidebarRow'; + +const UNTITLED = 'Untitled conversation'; +/** Recent conversations shown before the list asks to be expanded. */ +const RECENT_COUNT = 8; + +interface ConversationsSectionProps { + readonly chats: AgentChatListItem[]; + readonly access: ChatListAccess; + readonly accessDetail: string | null; + readonly activeChatId: number | null; + /** Resolves a row's title, showing a rename before the server confirms it. */ + readonly titleFor: (chatId: number, fallback: string | null) => string | null; + readonly onSelect: (chatId: number) => void; + readonly onRename: (chatId: number, title: string) => Promise; + readonly onDelete: (chatId: number, options: { deleteNotes: boolean }) => Promise; + readonly loadNotes: (chatId: number) => Promise; + readonly onRetry: () => void; +} + +/** + * The user's conversations, newest activity first as the server orders them, + * the most recent few at a time. Rows rename and delete through a menu. + */ +export function ConversationsSection({ + chats, + access, + accessDetail, + activeChatId, + titleFor, + onSelect, + onRename, + onDelete, + loadNotes, + onRetry, +}: ConversationsSectionProps) { + const [renamingId, setRenamingId] = useState(null); + const [showAll, setShowAll] = useState(false); + // The open conversation always shows, so selecting an older one from the + // full list does not fold it back out of sight. + const shown = showAll + ? chats + : chats.filter((item, index) => index < RECENT_COUNT || item.id === activeChatId); + const hiddenCount = chats.length - shown.length; + + return ( +
+ Conversations + + {access === 'loading' && } + + {access === 'hidden' && ( +

+ {accessDetail ?? 'You don’t have access to the assistant.'} +

+ )} + + {access === 'error' && ( +
+

+ {accessDetail ?? 'Couldn’t load your conversations.'} +

+ +
+ )} + + {access === 'ok' && chats.length === 0 && ( +

No conversations yet.

+ )} + + {shown.map((item) => { + const isActive = item.id === activeChatId; + const resolved = titleFor(item.id, item.title); + const title = resolved?.trim() || UNTITLED; + return ( + onSelect(item.id)} + titleAdornment={ + item.has_active_turn && ( + + ) + } + editing={ + renamingId === item.id && ( + setRenamingId(null)} + onCommit={async (value) => { + setRenamingId(null); + const next = value.trim(); + if (!next || next === (resolved ?? '')) return; + await onRename(item.id, next); + }} + className="text-[13px]" + /> + ) + } + menu={ + setRenamingId(item.id)} + onDelete={(options) => onDelete(item.id, options)} + loadNotes={() => loadNotes(item.id)} + /> + } + /> + ); + })} + + {hiddenCount > 0 && ( + + )} +
+ ); +} diff --git a/components/AIMode/sidebar/SidebarGroupHeading.tsx b/components/AIMode/sidebar/SidebarGroupHeading.tsx new file mode 100644 index 000000000..171ac1570 --- /dev/null +++ b/components/AIMode/sidebar/SidebarGroupHeading.tsx @@ -0,0 +1,19 @@ +import type { ReactNode } from 'react'; + +interface SidebarGroupHeadingProps { + readonly children: ReactNode; + /** A small control at the heading's right end. */ + readonly action?: ReactNode; +} + +/** A quiet label over a group of sidebar rows. */ +export function SidebarGroupHeading({ children, action }: SidebarGroupHeadingProps) { + return ( +
+

+ {children} +

+ {action} +
+ ); +} diff --git a/components/AIMode/sidebar/SidebarRow.tsx b/components/AIMode/sidebar/SidebarRow.tsx new file mode 100644 index 000000000..d9504d4f7 --- /dev/null +++ b/components/AIMode/sidebar/SidebarRow.tsx @@ -0,0 +1,77 @@ +'use client'; + +import type { ReactNode } from 'react'; +import { cn } from '@/utils/styles'; + +interface SidebarRowProps { + readonly title: string; + /** The small line under the title: a time, a kind, a status. */ + readonly meta: ReactNode; + /** Something before the title — a document's kind, say. */ + readonly leading?: ReactNode; + /** Beside the title, inside its line — a spinner for a running turn. */ + readonly titleAdornment?: ReactNode; + /** Controls at the row's right edge, shown on hover, focus, or while active. */ + readonly menu?: ReactNode; + readonly isActive: boolean; + readonly onSelect: () => void; + /** Replaces the row's face — an inline rename field. */ + readonly editing?: ReactNode; +} + +/** One entry in the workspace sidebar: a conversation, a document. */ +export function SidebarRow({ + title, + meta, + leading, + titleAdornment, + menu, + isActive, + onSelect, + editing, +}: SidebarRowProps) { + return ( +
+ {editing ? ( +
{editing}
+ ) : ( + + )} + + {!editing && menu && ( +
+ {menu} +
+ )} +
+ ); +} diff --git a/components/AIMode/sidebar/WorkspaceSidebar.tsx b/components/AIMode/sidebar/WorkspaceSidebar.tsx new file mode 100644 index 000000000..b8e419d9b --- /dev/null +++ b/components/AIMode/sidebar/WorkspaceSidebar.tsx @@ -0,0 +1,56 @@ +'use client'; + +import { Plus } from 'lucide-react'; +import { cn } from '@/utils/styles'; +import type { AIModeChatState } from '../useAIModeChat'; +import { ConversationsSection } from './ConversationsSection'; + +interface WorkspaceSidebarProps { + readonly state: AIModeChatState; + /** Runs after any choice that should put a drawer away. */ + readonly onNavigate?: () => void; +} + +/** The left column: start a conversation, or pick up one of your own. */ +export function WorkspaceSidebar({ state, onNavigate }: WorkspaceSidebarProps) { + const { chatId, list } = state; + + return ( +
+
+ +
+ + +
+ ); +}