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' )} >