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 ( +
+
+ +
+ + +
+ ); +}