From 6ebd61d221360359f7780ef392839b17a381f351 Mon Sep 17 00:00:00 2001 From: Kobe Attias Date: Mon, 21 Sep 2026 21:50:37 -0400 Subject: [PATCH] Open documents in the workspace, with a chat on each that is kept once used MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The workspace's URL now names a target: a conversation (?aiChat) or a document (?aiNote, with ?aiChat for the chat on it) and which pane is the main one (?aiView=doc). Clicking a conversation opens chat-first; clicking a document from the sidebar's new Documents section opens it document-first with a fresh chat beside it, scoped to that note through the notebook's own chat transport. A header toggle swaps the layout for the current target; the panes keep their places in the tree and trade places on screen. A document's chat is created on its first message, like any other, and from then on it is listed under Conversations — the assistant listing now includes notebook chats — and reopens on its document. Rows route renames and deletes to the surface that serves them, and a chat on a document never offers to delete the document. The sidebar's Documents section lists the organization's notes with each one's kind marked by the money's direction, refetching when the open conversation creates one. Verified with type-check, eslint and prettier only. Co-Authored-By: Claude Fable 5.1 --- components/AIMode/AIModeContext.tsx | 161 ++++++++++++++---- components/AIMode/AIModeOverlay.tsx | 43 +++-- components/AIMode/chat/ChatPane.tsx | 21 ++- .../AIMode/chat/DocumentChatEmptyState.tsx | 19 +++ components/AIMode/shell/AIModeHeader.tsx | 43 ++++- components/AIMode/shell/WorkspacePanes.tsx | 4 +- .../AIMode/sidebar/ConversationsSection.tsx | 4 +- .../AIMode/sidebar/DocumentsSection.tsx | 120 +++++++++++++ .../AIMode/sidebar/WorkspaceSidebar.tsx | 25 ++- components/AIMode/useAIModeChat.ts | 151 +++++++++------- services/assistantChat.service.ts | 5 +- services/chatTransport.ts | 5 +- services/notebookChat.service.ts | 4 + types/agentChat.ts | 12 ++ 14 files changed, 481 insertions(+), 136 deletions(-) create mode 100644 components/AIMode/chat/DocumentChatEmptyState.tsx create mode 100644 components/AIMode/sidebar/DocumentsSection.tsx diff --git a/components/AIMode/AIModeContext.tsx b/components/AIMode/AIModeContext.tsx index f5c7188e4..d76b232ff 100644 --- a/components/AIMode/AIModeContext.tsx +++ b/components/AIMode/AIModeContext.tsx @@ -14,32 +14,100 @@ import { import dynamic from 'next/dynamic'; import { usePathname, useSearchParams } from 'next/navigation'; -/** `?ai=1` opens the overlay; `?ai=1&aiChat=` selects a conversation. */ +/** + * `?ai=1` opens the workspace. `aiChat=` selects a conversation; + * `aiNote=` opens a document, with `aiChat` then naming the chat on that + * document; `aiView=doc` puts the document in the main pane. + */ export const AI_MODE_OPEN_PARAM = 'ai'; export const AI_MODE_CHAT_PARAM = 'aiChat'; +export const AI_MODE_NOTE_PARAM = 'aiNote'; +export const AI_MODE_VIEW_PARAM = 'aiView'; +const DOCUMENT_VIEW = 'doc'; + +/** + * What the workspace is open on: one of the user's conversations (null = the + * new-conversation screen), or a document with a chat scoped to it (null = + * a chat not yet started). + */ +export type WorkspaceTarget = + | { readonly kind: 'conversation'; readonly chatId: number | null } + | { readonly kind: 'document'; readonly noteId: number; readonly chatId: number | null }; + +/** Which pane is the main one; the other sits at a fixed width beside it. */ +export type WorkspaceLayout = 'chat' | 'document'; interface AIModeUrlState { - isOpen: boolean; - chatId: number | null; + readonly isOpen: boolean; + readonly target: WorkspaceTarget; + readonly layout: WorkspaceLayout; } +const NEW_CONVERSATION: WorkspaceTarget = { kind: 'conversation', chatId: null }; +const CLOSED: AIModeUrlState = { isOpen: false, target: NEW_CONVERSATION, layout: 'chat' }; + export interface AIModeContextValue extends AIModeUrlState { - /** Open on the last selected conversation, or the new-conversation screen. */ + /** Open on the last target, or the new-conversation screen. */ open: () => void; close: () => void; toggle: () => void; + /** Open on a target, laid out as its kind reads best unless told otherwise. */ + selectTarget: (target: WorkspaceTarget, layout?: WorkspaceLayout) => void; /** Select a conversation (null = the new-conversation screen), opening if needed. */ selectChat: (chatId: number | null) => void; + /** Open a document with a fresh chat beside it. */ + selectDocument: (noteId: number) => void; + /** Swap which pane is the main one; nothing else changes. */ + setLayout: (layout: WorkspaceLayout) => void; } const AIModeContext = createContext(null); -function parseChatId(raw: string | null): number | null { +function parseId(raw: string | null): number | null { if (raw == null) return null; const parsed = Number.parseInt(raw, 10); return Number.isFinite(parsed) && parsed > 0 ? parsed : null; } +/** The layout a target opens in when nothing says otherwise. */ +const defaultLayout = (target: WorkspaceTarget): WorkspaceLayout => + target.kind === 'document' ? 'document' : 'chat'; + +function readUrlState(params: URLSearchParams): AIModeUrlState { + if (params.get(AI_MODE_OPEN_PARAM) !== '1') return CLOSED; + const chatId = parseId(params.get(AI_MODE_CHAT_PARAM)); + const noteId = parseId(params.get(AI_MODE_NOTE_PARAM)); + const target: WorkspaceTarget = + noteId != null ? { kind: 'document', noteId, chatId } : { kind: 'conversation', chatId }; + // Only a document can be the main pane. + const layout: WorkspaceLayout = + noteId != null && params.get(AI_MODE_VIEW_PARAM) === DOCUMENT_VIEW ? 'document' : 'chat'; + return { isOpen: true, target, layout }; +} + +function writeUrlState(params: URLSearchParams, state: AIModeUrlState): void { + for (const key of [ + AI_MODE_OPEN_PARAM, + AI_MODE_CHAT_PARAM, + AI_MODE_NOTE_PARAM, + AI_MODE_VIEW_PARAM, + ]) { + params.delete(key); + } + if (!state.isOpen) return; + params.set(AI_MODE_OPEN_PARAM, '1'); + if (state.target.chatId != null) params.set(AI_MODE_CHAT_PARAM, String(state.target.chatId)); + if (state.target.kind === 'document') { + params.set(AI_MODE_NOTE_PARAM, String(state.target.noteId)); + if (state.layout === 'document') params.set(AI_MODE_VIEW_PARAM, DOCUMENT_VIEW); + } +} + +const sameTarget = (a: WorkspaceTarget, b: WorkspaceTarget): boolean => + a.kind === b.kind && + a.chatId === b.chatId && + (a.kind !== 'document' || b.kind !== 'document' || a.noteId === b.noteId); + /** * Reads the overlay's URL state. Isolated behind Suspense because * `useSearchParams` de-opts a statically rendered page up to the nearest @@ -48,11 +116,19 @@ function parseChatId(raw: string | null): number | null { */ function AIModeUrlSync({ onChange }: { readonly onChange: (state: AIModeUrlState) => void }) { const searchParams = useSearchParams(); - const isOpen = searchParams.get(AI_MODE_OPEN_PARAM) === '1'; - const chatId = isOpen ? parseChatId(searchParams.get(AI_MODE_CHAT_PARAM)) : null; + const { isOpen, layout, target } = readUrlState(searchParams); + const { chatId } = target; + const noteId = target.kind === 'document' ? target.noteId : null; + // Rebuilt from its parts so the effect runs on a change of state, not on + // every render's fresh object. useEffect(() => { - onChange({ isOpen, chatId }); - }, [isOpen, chatId, onChange]); + onChange({ + isOpen, + layout, + target: + noteId != null ? { kind: 'document', noteId, chatId } : { kind: 'conversation', chatId }, + }); + }, [isOpen, layout, chatId, noteId, onChange]); return null; } @@ -62,21 +138,24 @@ const AIModeOverlay = dynamic( ); /** - * Owns the AI Mode overlay: its open/selected state lives in the URL, so a - * reload or a shared link lands on the same conversation, and any client-side - * navigation to another page naturally drops the params and closes it. + * Owns the AI Mode overlay: what it is open on lives in the URL, so a reload + * or a shared link lands on the same conversation or document, and any + * client-side navigation to another page naturally drops the params and + * closes it. * * Mounted once, globally. The overlay body is lazy-loaded so a session that * never opens it pays nothing. */ export function AIModeProvider({ children }: { readonly children: ReactNode }) { const pathname = usePathname(); - const [state, setState] = useState({ isOpen: false, chatId: null }); - // Closing drops the chat from the URL; reopening from the sidebar in the same - // page session should still return to it. In memory only — a reload starts - // from whatever the URL says. - const lastChatIdRef = useRef(null); - if (state.chatId != null) lastChatIdRef.current = state.chatId; + const [state, setState] = useState(CLOSED); + // Closing drops the target from the URL; reopening from the sidebar in the + // same page session should still return to it. In memory only — a reload + // starts from whatever the URL says. + const lastRef = useRef | null>(null); + if (state.isOpen && !sameTarget(state.target, NEW_CONVERSATION)) { + lastRef.current = { target: state.target, layout: state.layout }; + } const pathnameRef = useRef(pathname); pathnameRef.current = pathname; @@ -85,16 +164,7 @@ export function AIModeProvider({ children }: { readonly children: ReactNode }) { // Event-handler only, so window is available; keeps every unrelated // query param the page already carries. const params = new URLSearchParams(window.location.search); - if (next.isOpen) { - params.set(AI_MODE_OPEN_PARAM, '1'); - } else { - params.delete(AI_MODE_OPEN_PARAM); - } - if (next.isOpen && next.chatId != null) { - params.set(AI_MODE_CHAT_PARAM, String(next.chatId)); - } else { - params.delete(AI_MODE_CHAT_PARAM); - } + writeUrlState(params, next); const query = params.toString(); const hash = window.location.hash; // Native history, not router.replace: the app router keeps @@ -108,28 +178,45 @@ export function AIModeProvider({ children }: { readonly children: ReactNode }) { setState(next); }, []); + const selectTarget = useCallback( + (target: WorkspaceTarget, layout: WorkspaceLayout = defaultLayout(target)) => { + if (sameTarget(target, NEW_CONVERSATION)) lastRef.current = null; + navigate({ isOpen: true, target, layout }); + }, + [navigate] + ); const open = useCallback(() => { - navigate({ isOpen: true, chatId: lastChatIdRef.current }); + const last = lastRef.current; + navigate( + last + ? { isOpen: true, target: last.target, layout: last.layout } + : { isOpen: true, target: NEW_CONVERSATION, layout: 'chat' } + ); }, [navigate]); - const close = useCallback(() => navigate({ isOpen: false, chatId: null }), [navigate]); + const close = useCallback(() => navigate(CLOSED), [navigate]); const selectChat = useCallback( - (chatId: number | null) => { - if (chatId == null) lastChatIdRef.current = null; - navigate({ isOpen: true, chatId }); - }, - [navigate] + (chatId: number | null) => selectTarget({ kind: 'conversation', chatId }), + [selectTarget] + ); + const selectDocument = useCallback( + (noteId: number) => selectTarget({ kind: 'document', noteId, chatId: null }), + [selectTarget] ); const stateRef = useRef(state); stateRef.current = state; + const setLayout = useCallback( + (layout: WorkspaceLayout) => navigate({ ...stateRef.current, isOpen: true, layout }), + [navigate] + ); const toggle = useCallback(() => { if (stateRef.current.isOpen) close(); else open(); }, [open, close]); const value = useMemo( - () => ({ ...state, open, close, toggle, selectChat }), - [state, open, close, toggle, selectChat] + () => ({ ...state, open, close, toggle, selectTarget, selectChat, selectDocument, setLayout }), + [state, open, close, toggle, selectTarget, selectChat, selectDocument, setLayout] ); return ( diff --git a/components/AIMode/AIModeOverlay.tsx b/components/AIMode/AIModeOverlay.tsx index 9393a2a47..554e87944 100644 --- a/components/AIMode/AIModeOverlay.tsx +++ b/components/AIMode/AIModeOverlay.tsx @@ -34,8 +34,9 @@ const DOCUMENT_DEFAULT_SHARE = 0.55; * from inside it still render on top. */ export function AIModeOverlay() { - const { close } = useAIMode(); + const { close, layout, setLayout } = useAIMode(); const state = useAIModeChat(); + const { target } = state; // Below the tablet breakpoint the sidebar lives in a bottom drawer. const [listDrawerOpen, setListDrawerOpen] = useState(false); const closeListDrawer = useCallback(() => setListDrawerOpen(false), []); @@ -66,18 +67,21 @@ export function AIModeOverlay() { defaultWidth: LIST_DEFAULT_WIDTH, anchor: 'left', }); - // Document or details in the right pane; details wants a wider floor. + // Document or details in the document pane; details wants a wider floor. const [documentView, setDocumentView] = useState('document'); const documentMinWidth = documentView === 'details' ? DETAILS_MIN_WIDTH : DOCUMENT_MIN_WIDTH; - // The document may grow until the chat is down to its minimum column. - const documentMaxWidth = Math.max( - documentMinWidth, - viewportWidth - listWidth.width - CHAT_MIN_WIDTH + // The side pane is the document (chat first) or the chat (document first); + // it may grow until the main pane is down to its own minimum column. + const sideIsDocument = layout === 'chat'; + const sideMinWidth = sideIsDocument ? documentMinWidth : CHAT_MIN_WIDTH; + const sideMaxWidth = Math.max( + sideMinWidth, + viewportWidth - listWidth.width - (sideIsDocument ? CHAT_MIN_WIDTH : DOCUMENT_MIN_WIDTH) ); - const documentWidth = useResizableWidth({ + const sideWidth = useResizableWidth({ storageKey: 'ai-mode:document-width', - min: documentMinWidth, - max: documentMaxWidth, + min: sideMinWidth, + max: sideMaxWidth, defaultWidth: (width) => width * DOCUMENT_DEFAULT_SHARE, anchor: 'right', }); @@ -85,15 +89,17 @@ export function AIModeOverlay() { isBelowTabletRef.current = isBelowTablet; // On desktop the document pane opens by itself the moment a conversation - // gains a note. On mobile it never opens by itself — the card in the - // transcript is the way in, and it opens a drawer. Either way the user can - // close it and reopen it from the card or the chat header. + // gains a note, and a document opened from the sidebar shows at once + // everywhere. Otherwise on mobile the card in the transcript is the way in, + // and it opens a drawer. Either way the user can close it and reopen it + // from the card or the chat header. const noteId = state.note?.id ?? null; + const onDocument = target.kind === 'document'; const [documentOpen, setDocumentOpen] = useState(false); useEffect(() => { - setDocumentOpen(noteId != null && !isBelowTabletRef.current); + setDocumentOpen(noteId != null && (onDocument || !isBelowTabletRef.current)); setDocumentView('document'); - }, [noteId]); + }, [noteId, onDocument]); const openDocument = useCallback(() => setDocumentOpen(true), []); const closeDocument = useCallback(() => setDocumentOpen(false), []); @@ -119,8 +125,9 @@ export function AIModeOverlay() { } return null; }, [noteId, state.chat.chat]); + // A document opened from the sidebar is the main pane; no card needed. const documentCard = - noteId != null ? ( + noteId != null && !onDocument ? ( {listBlocked ? ( + ) : chatId == null && onDocument ? ( + ) : chatId == null ? ( - {/* A conversation keeps the composer docked at the bottom; the - new-conversation screen seats it in the middle with the starters. */} - {chatId != null && ( + {/* A conversation keeps the composer docked at the bottom, as does a + document's chat before it starts; the new-conversation screen seats + it in the middle with the intent toggle. */} + {(chatId != null || onDocument) && (
{composer} diff --git a/components/AIMode/chat/DocumentChatEmptyState.tsx b/components/AIMode/chat/DocumentChatEmptyState.tsx new file mode 100644 index 000000000..b91f2c091 --- /dev/null +++ b/components/AIMode/chat/DocumentChatEmptyState.tsx @@ -0,0 +1,19 @@ +import { MessageSquare } from 'lucide-react'; + +/** A document is open and no one has said anything about it yet. */ +export function DocumentChatEmptyState() { + return ( +
+ + +

A new chat about this document

+

+ The assistant can read this document and propose edits you accept or reject. +

+

+ Send a message and this chat is kept under Conversations. +

+
+ ); +} diff --git a/components/AIMode/shell/AIModeHeader.tsx b/components/AIMode/shell/AIModeHeader.tsx index 1cccd8660..63d8be270 100644 --- a/components/AIMode/shell/AIModeHeader.tsx +++ b/components/AIMode/shell/AIModeHeader.tsx @@ -1,6 +1,8 @@ 'use client'; -import { Sparkles, X } from 'lucide-react'; +import { FileText, MessageSquare, Sparkles, X } from 'lucide-react'; +import { ButtonGroup } from '@/components/ui/ButtonGroup'; +import type { WorkspaceLayout } from '../AIModeContext'; import { AI_MODE_NAME } from '../copy'; interface AIModeHeaderProps { @@ -11,16 +13,53 @@ interface AIModeHeaderProps { * into. Absent below the tablet breakpoint, where the drawer keeps them. */ readonly publishControlsRef?: (element: HTMLDivElement | null) => void; + /** Which pane is the main one; absent when there is nothing to swap. */ + readonly layout?: WorkspaceLayout; + readonly onLayoutChange?: (layout: WorkspaceLayout) => 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) { +export function AIModeHeader({ + documentTitle, + publishControlsRef, + layout, + onLayoutChange, + onClose, +}: AIModeHeaderProps) { return (
{documentTitle != null && publishControlsRef && ( diff --git a/components/AIMode/shell/WorkspacePanes.tsx b/components/AIMode/shell/WorkspacePanes.tsx index bb0694c49..15af510e2 100644 --- a/components/AIMode/shell/WorkspacePanes.tsx +++ b/components/AIMode/shell/WorkspacePanes.tsx @@ -5,15 +5,13 @@ import { ResizeHandle } from '@/components/ui/ResizeHandle'; import { SwipeableDrawer } from '@/components/ui/SwipeableDrawer'; import type { useResizableWidth } from '@/hooks/useResizableWidth'; import { cn } from '@/utils/styles'; +import type { WorkspaceLayout } from '../AIModeContext'; /** 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; diff --git a/components/AIMode/sidebar/ConversationsSection.tsx b/components/AIMode/sidebar/ConversationsSection.tsx index 9e310624f..b2a449d91 100644 --- a/components/AIMode/sidebar/ConversationsSection.tsx +++ b/components/AIMode/sidebar/ConversationsSection.tsx @@ -23,7 +23,7 @@ interface ConversationsSectionProps { 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 onSelect: (item: AgentChatListItem) => void; readonly onRename: (chatId: number, title: string) => Promise; readonly onDelete: (chatId: number, options: { deleteNotes: boolean }) => Promise; readonly loadNotes: (chatId: number) => Promise; @@ -92,7 +92,7 @@ export function ConversationsSection({ title={title} meta={formatTimeAgo(item.updated_date)} isActive={isActive} - onSelect={() => onSelect(item.id)} + onSelect={() => onSelect(item)} titleAdornment={ item.has_active_turn && ( diff --git a/components/AIMode/sidebar/DocumentsSection.tsx b/components/AIMode/sidebar/DocumentsSection.tsx new file mode 100644 index 000000000..f5d597639 --- /dev/null +++ b/components/AIMode/sidebar/DocumentsSection.tsx @@ -0,0 +1,120 @@ +'use client'; + +import { useEffect, useMemo } from 'react'; +import { File } from 'lucide-react'; +import { FundingDirectionIcon } from '@/components/Funding/FundingDirectionIcon'; +import { Button } from '@/components/ui/Button'; +import { ConversationListSkeleton } from '@/components/skeletons/AIModeSkeleton'; +import { useOrganizationContext } from '@/contexts/OrganizationContext'; +import { useUser } from '@/contexts/UserContext'; +import { useOrganizationNotes } from '@/hooks/useOrganizationNotes'; +import { getNoteKind, isChangelogNote, NOTE_KIND_LABELS, type Note } from '@/types/note'; +import { SidebarGroupHeading } from './SidebarGroupHeading'; +import { SidebarRow } from './SidebarRow'; + +interface DocumentsSectionProps { + readonly activeNoteId: number | null; + /** A note the open conversation just created; the list fetches again to show it. */ + readonly recentNoteId: number | null; + readonly onSelect: (noteId: number) => void; +} + +/** The kind's mark: the money's direction for a proposal or an RFP, a plain file otherwise. */ +function NoteKindIcon({ note }: { readonly note: Note }) { + const kind = getNoteKind(note); + if (kind === 'rfp') + return ; + if (kind === 'proposal') { + return ; + } + return ( + + ); +} + +/** + * The user's notebook, as the notebook itself lists it: every note they can + * open, newest first, each marked by what it is. Opening one puts the + * document in the main pane with a fresh chat beside it. + */ +export function DocumentsSection({ activeNoteId, recentNoteId, onSelect }: DocumentsSectionProps) { + const { selectedOrg, isLoading: isLoadingOrg } = useOrganizationContext(); + const { user } = useUser(); + const notes = useOrganizationNotes(selectedOrg?.slug, { waiting: isLoadingOrg }); + const isModerator = Boolean(user?.isModerator); + + const rows = useMemo( + () => + notes.notes + .filter( + (note) => + (note.access === 'WORKSPACE' || + note.access === 'SHARED' || + note.access === 'PRIVATE') && + (isModerator || !isChangelogNote(note)) + ) + .sort((a, b) => new Date(b.updatedDate).getTime() - new Date(a.updatedDate).getTime()), + [notes.notes, isModerator] + ); + + // The assistant created a note this list does not have yet. + const { refresh } = notes; + const known = recentNoteId == null || notes.notes.some((note) => note.id === recentNoteId); + useEffect(() => { + if (!known && !notes.isLoading) void refresh(); + // Refetch once per new note, not on every list change. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [recentNoteId, known]); + + return ( +
+ Documents + + {notes.isLoading && } + + {notes.error && !notes.isLoading && ( +
+

Couldn’t load your documents.

+ +
+ )} + + {!notes.isLoading && !notes.error && rows.length === 0 && ( +

No documents yet.

+ )} + + {rows.map((note) => { + const kind = getNoteKind(note); + const kindLabel = kind === 'other' ? 'Note' : NOTE_KIND_LABELS[kind]; + return ( + } + isActive={note.id === activeNoteId} + onSelect={() => onSelect(note.id)} + /> + ); + })} + + {notes.hasMore && ( + + )} +
+ ); +} diff --git a/components/AIMode/sidebar/WorkspaceSidebar.tsx b/components/AIMode/sidebar/WorkspaceSidebar.tsx index b8e419d9b..1e4a7bc78 100644 --- a/components/AIMode/sidebar/WorkspaceSidebar.tsx +++ b/components/AIMode/sidebar/WorkspaceSidebar.tsx @@ -1,9 +1,11 @@ 'use client'; import { Plus } from 'lucide-react'; +import { useAIMode } from '../AIModeContext'; import { cn } from '@/utils/styles'; import type { AIModeChatState } from '../useAIModeChat'; import { ConversationsSection } from './ConversationsSection'; +import { DocumentsSection } from './DocumentsSection'; interface WorkspaceSidebarProps { readonly state: AIModeChatState; @@ -11,9 +13,11 @@ interface WorkspaceSidebarProps { readonly onNavigate?: () => void; } -/** The left column: start a conversation, or pick up one of your own. */ +/** The left column: start a conversation, pick up one of your own, or open a document. */ export function WorkspaceSidebar({ state, onNavigate }: WorkspaceSidebarProps) { - const { chatId, list } = state; + const { selectDocument } = useAIMode(); + const { target, chatId, list } = state; + const onNewConversation = target.kind === 'conversation' && chatId == null; return (
@@ -26,7 +30,7 @@ export function WorkspaceSidebar({ state, onNavigate }: WorkspaceSidebarProps) { }} className={cn( 'flex w-full items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm font-medium text-gray-900 shadow-sm transition-colors hover:bg-gray-50', - chatId == null && 'border-primary-200 bg-primary-50 hover:bg-primary-50' + onNewConversation && 'border-primary-200 bg-primary-50 hover:bg-primary-50' )} >