From 43c034af6659547e565de806334248f82cbfb4c4 Mon Sep 17 00:00:00 2001 From: Kobe Attias Date: Sun, 6 Sep 2026 08:20:08 -0400 Subject: [PATCH 01/34] Move notebook AgentChat components to components/AgentChat The chat transcript, composer, activity feed and model controls are shared between the notebook assistant and the upcoming AI Mode overlay, so they live one level up instead of under Notebook/. Co-Authored-By: Claude Fable 5.1 --- components/{Notebook => }/AgentChat/ActivityFeed.tsx | 0 components/{Notebook => }/AgentChat/AgentChatPanel.tsx | 6 +++--- components/{Notebook => }/AgentChat/ChatComposer.tsx | 0 components/{Notebook => }/AgentChat/ChatPicker.tsx | 0 components/{Notebook => }/AgentChat/ChatPresets.tsx | 0 components/{Notebook => }/AgentChat/ChatSources.tsx | 0 components/{Notebook => }/AgentChat/ChatTranscript.tsx | 0 components/{Notebook => }/AgentChat/ExecutionProgress.tsx | 0 components/{Notebook => }/AgentChat/MarkdownMessage.tsx | 0 components/{Notebook => }/AgentChat/ModelControls.tsx | 0 components/Notebook/NoteEditorLayout.tsx | 2 +- 11 files changed, 4 insertions(+), 4 deletions(-) rename components/{Notebook => }/AgentChat/ActivityFeed.tsx (100%) rename components/{Notebook => }/AgentChat/AgentChatPanel.tsx (99%) rename components/{Notebook => }/AgentChat/ChatComposer.tsx (100%) rename components/{Notebook => }/AgentChat/ChatPicker.tsx (100%) rename components/{Notebook => }/AgentChat/ChatPresets.tsx (100%) rename components/{Notebook => }/AgentChat/ChatSources.tsx (100%) rename components/{Notebook => }/AgentChat/ChatTranscript.tsx (100%) rename components/{Notebook => }/AgentChat/ExecutionProgress.tsx (100%) rename components/{Notebook => }/AgentChat/MarkdownMessage.tsx (100%) rename components/{Notebook => }/AgentChat/ModelControls.tsx (100%) diff --git a/components/Notebook/AgentChat/ActivityFeed.tsx b/components/AgentChat/ActivityFeed.tsx similarity index 100% rename from components/Notebook/AgentChat/ActivityFeed.tsx rename to components/AgentChat/ActivityFeed.tsx diff --git a/components/Notebook/AgentChat/AgentChatPanel.tsx b/components/AgentChat/AgentChatPanel.tsx similarity index 99% rename from components/Notebook/AgentChat/AgentChatPanel.tsx rename to components/AgentChat/AgentChatPanel.tsx index 5d5f6c7d4..d07e8ef93 100644 --- a/components/Notebook/AgentChat/AgentChatPanel.tsx +++ b/components/AgentChat/AgentChatPanel.tsx @@ -23,8 +23,8 @@ import type { GenerationRequest } from '@/types/notebookModels'; import { ENDOWMENT_PROMO_BANNER_FEATURE } from '@/app/layouts/components/EndowmentPromoBanner'; import { useDismissableFeature } from '@/hooks/useDismissableFeature'; import { useEditorIsEmpty } from '@/hooks/useEditorIsEmpty'; -import { belowMobileTopBar } from '../mobileChromeOffsets'; -import { NoteReviewControls } from '../NoteReview/NoteReviewControls'; +import { belowMobileTopBar } from '@/components/Notebook/mobileChromeOffsets'; +import { NoteReviewControls } from '@/components/Notebook/NoteReview/NoteReviewControls'; import { ChatComposer, type ComposerNotice } from './ChatComposer'; import { ChatPicker } from './ChatPicker'; import { ChatPresets } from './ChatPresets'; @@ -36,7 +36,7 @@ import { beginNoteDiffReview, endNoteDiffReview, resolveNoteDiffReview, -} from '../NoteReview/noteDiffOverlay'; +} from '@/components/Notebook/NoteReview/noteDiffOverlay'; type PanelTab = 'chat' | 'sources'; diff --git a/components/Notebook/AgentChat/ChatComposer.tsx b/components/AgentChat/ChatComposer.tsx similarity index 100% rename from components/Notebook/AgentChat/ChatComposer.tsx rename to components/AgentChat/ChatComposer.tsx diff --git a/components/Notebook/AgentChat/ChatPicker.tsx b/components/AgentChat/ChatPicker.tsx similarity index 100% rename from components/Notebook/AgentChat/ChatPicker.tsx rename to components/AgentChat/ChatPicker.tsx diff --git a/components/Notebook/AgentChat/ChatPresets.tsx b/components/AgentChat/ChatPresets.tsx similarity index 100% rename from components/Notebook/AgentChat/ChatPresets.tsx rename to components/AgentChat/ChatPresets.tsx diff --git a/components/Notebook/AgentChat/ChatSources.tsx b/components/AgentChat/ChatSources.tsx similarity index 100% rename from components/Notebook/AgentChat/ChatSources.tsx rename to components/AgentChat/ChatSources.tsx diff --git a/components/Notebook/AgentChat/ChatTranscript.tsx b/components/AgentChat/ChatTranscript.tsx similarity index 100% rename from components/Notebook/AgentChat/ChatTranscript.tsx rename to components/AgentChat/ChatTranscript.tsx diff --git a/components/Notebook/AgentChat/ExecutionProgress.tsx b/components/AgentChat/ExecutionProgress.tsx similarity index 100% rename from components/Notebook/AgentChat/ExecutionProgress.tsx rename to components/AgentChat/ExecutionProgress.tsx diff --git a/components/Notebook/AgentChat/MarkdownMessage.tsx b/components/AgentChat/MarkdownMessage.tsx similarity index 100% rename from components/Notebook/AgentChat/MarkdownMessage.tsx rename to components/AgentChat/MarkdownMessage.tsx diff --git a/components/Notebook/AgentChat/ModelControls.tsx b/components/AgentChat/ModelControls.tsx similarity index 100% rename from components/Notebook/AgentChat/ModelControls.tsx rename to components/AgentChat/ModelControls.tsx diff --git a/components/Notebook/NoteEditorLayout.tsx b/components/Notebook/NoteEditorLayout.tsx index 660334a2c..ccd5b6442 100644 --- a/components/Notebook/NoteEditorLayout.tsx +++ b/components/Notebook/NoteEditorLayout.tsx @@ -17,7 +17,7 @@ import { PublishedStatusSection } from './PublishingForm/components/PublishedSta import { PublishingForm } from '@/components/Notebook/PublishingForm'; import { ABOVE_MOBILE_NAV } from './mobileChromeOffsets'; -import { AgentChatPanel, type NoteReviewHandle } from './AgentChat/AgentChatPanel'; +import { AgentChatPanel, type NoteReviewHandle } from '@/components/AgentChat/AgentChatPanel'; import { noteDiffPersistableDoc } from './NoteReview/noteDiffOverlay'; import { NoteReviewControls } from './NoteReview/NoteReviewControls'; import { useNotebookContext } from '@/contexts/NotebookContext'; From cd148cc82bb1a00748fc92ff2170b1459c544b7e Mon Sep 17 00:00:00 2001 From: Kobe Attias Date: Sun, 6 Sep 2026 08:22:18 -0400 Subject: [PATCH 02/34] Introduce ChatTransport so chat hooks serve two backend surfaces useNotebookChat, useNotebookChatList and useNotebookChatSocket took a noteId and called NotebookChatService directly. They now take a ChatTransport, an interface over the six REST calls and the socket URL, with notebookChatTransport(noteId) and assistantChatTransport() as the two implementations. The notebook panel builds its transport once per note, so its behaviour is unchanged. Adds AssistantChatService against /api/research_ai/assistant/chats/, the ASSISTANT_CHAT socket route, and the optional notes field the assistant surface returns on a chat. Co-Authored-By: Claude Fable 5.1 --- components/AgentChat/AgentChatPanel.tsx | 8 ++- hooks/useNotebookChat.ts | 64 ++++++++++++------------ hooks/useNotebookChatSocket.ts | 11 +++-- services/assistantChat.service.ts | 55 +++++++++++++++++++++ services/chatTransport.ts | 65 +++++++++++++++++++++++++ services/websocket.ts | 2 + types/notebookChat.ts | 11 +++++ 7 files changed, 178 insertions(+), 38 deletions(-) create mode 100644 services/assistantChat.service.ts create mode 100644 services/chatTransport.ts diff --git a/components/AgentChat/AgentChatPanel.tsx b/components/AgentChat/AgentChatPanel.tsx index d07e8ef93..1e81118bf 100644 --- a/components/AgentChat/AgentChatPanel.tsx +++ b/components/AgentChat/AgentChatPanel.tsx @@ -9,6 +9,7 @@ import { Loader } from '@/components/ui/Loader'; import { cn } from '@/utils/styles'; import { useNotebookContext } from '@/contexts/NotebookContext'; import { useNotebookChat, useNotebookChatList, type SendOutcome } from '@/hooks/useNotebookChat'; +import { notebookChatTransport } from '@/services/chatTransport'; import { useAgentModelSelection } from '@/hooks/useAgentModelSelection'; import { MAX_AGENT_CHAT_WIDTH, MIN_AGENT_CHAT_WIDTH } from '@/hooks/useAgentChatWidth'; import { useNoteVersionSocket } from '@/hooks/useNoteVersionSocket'; @@ -249,7 +250,10 @@ export function AgentChatPanel({ ); const promoBannerVisible = promoStatus === 'checked' && !promoDismissed; - const list = useNotebookChatList(noteId, open); + // One transport per note: the hooks reset on its identity, so it is built + // once per note rather than per render. + const transport = useMemo(() => notebookChatTransport(noteId), [noteId]); + const list = useNotebookChatList(transport, open); // Null is the new-chat screen, and it is where a page visit starts: the // assistant opens on its own opening moves rather than dropping the reader // into the middle of whatever they last asked. Earlier chats stay one click @@ -263,7 +267,7 @@ export function AgentChatPanel({ // note version socket below reports agent edits from any chat, and // reopening (or reselecting) refetches the transcript. const chatState = useNotebookChat({ - noteId, + transport, chatId: selectedChatId, enabled: open, initialChat, diff --git a/hooks/useNotebookChat.ts b/hooks/useNotebookChat.ts index e6fd132f4..8a96ab00b 100644 --- a/hooks/useNotebookChat.ts +++ b/hooks/useNotebookChat.ts @@ -2,11 +2,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { debounce, type DebouncedFunc } from 'lodash-es'; -import { - NotebookChatService, - chatErrorDetail, - chatErrorStatus, -} from '@/services/notebookChat.service'; +import { chatErrorDetail, chatErrorStatus } from '@/services/notebookChat.service'; +import type { ChatTransport } from '@/services/chatTransport'; import { useNotebookChatSocket, type ChatSocketStatus } from '@/hooks/useNotebookChatSocket'; import { isChatStreamSocketEvent, @@ -234,7 +231,12 @@ export function applyStreamEvent( } interface UseNotebookChatOptions { - noteId: string | number | null; + /** + * The surface the chat lives on (notebook note, or the research assistant). + * Must be referentially stable across renders — a new transport resets the + * hook exactly like a chat switch does. + */ + transport: ChatTransport | null; chatId: number | null; /** False while the panel is closed — suspends fetching, polling, and the socket. */ enabled: boolean; @@ -276,7 +278,7 @@ export interface UseNotebookChatResult { * reconnects, and any detected sequence gap repair from REST. */ export function useNotebookChat({ - noteId, + transport, chatId, enabled, initialChat = null, @@ -308,13 +310,13 @@ export function useNotebookChat({ const fetchChat = useCallback( async (mode: 'full' | 'live') => { - if (noteId == null || chatId == null) return; + if (transport == null || chatId == null) return; // A live fetch may omit activity we're expected to already hold — only // safe when we actually hold a cached copy to merge over. const live = mode === 'live' && chatRef.current != null; const seq = ++seqRef.current; try { - const data = await NotebookChatService.getChat(noteId, chatId, { live }); + const data = await transport.getChat(chatId, { live }); if (seq !== seqRef.current) return; setChat((prev) => { const merged = mergeLiveChat(live ? prev : null, data); @@ -337,7 +339,7 @@ export function useNotebookChat({ } } }, - [noteId, chatId] + [transport, chatId] ); // Reset + initial load whenever the target chat changes or the panel opens. @@ -346,7 +348,7 @@ export function useNotebookChat({ epochRef.current += 1; // …and any pending send/cancel/rename continuation streamRepairRef.current = 'idle'; setPendingSend(null); - if (!enabled || noteId == null || chatId == null) { + if (!enabled || transport == null || chatId == null) { setChat(null); setAccess('loading'); return; @@ -361,7 +363,7 @@ export function useNotebookChat({ setAccess('loading'); fetchChat('full'); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [noteId, chatId, enabled, fetchChat]); + }, [transport, chatId, enabled, fetchChat]); const latestExecution = useMemo( () => (chat && chat.executions.length > 0 ? chat.executions[chat.executions.length - 1] : null), @@ -471,7 +473,7 @@ export function useNotebookChat({ }, [fetchChat]); const socketStatus = useNotebookChatSocket({ - noteId, + transport, chatId, // "Connect after the chat exists": wait for the first successful GET. enabled: enabled && access === 'ok', @@ -481,11 +483,11 @@ export function useNotebookChat({ const send = useCallback( async (text: string, generation?: GenerationRequest): Promise => { - if (noteId == null || chatId == null) return { ok: false, reason: 'error' }; + if (transport == null || chatId == null) return { ok: false, reason: 'error' }; const epoch = epochRef.current; setPendingSend({ text, executionId: null }); try { - const response = await NotebookChatService.sendMessage(noteId, chatId, text, generation); + const response = await transport.sendMessage(chatId, text, generation); if (epoch === epochRef.current) { setPendingSend({ text, executionId: response.execution_id }); fetchChat('live'); @@ -511,27 +513,27 @@ export function useNotebookChat({ return outcome; } }, - [noteId, chatId, fetchChat] + [transport, chatId, fetchChat] ); const cancel = useCallback(async () => { - if (noteId == null || chatId == null) return; + if (transport == null || chatId == null) return; const epoch = epochRef.current; try { // Idempotent by design — "nothing was running" resolves, not throws. - await NotebookChatService.cancelTurn(noteId, chatId); + await transport.cancelTurn(chatId); } catch { // Fall through: the refetch below renders whatever actually happened. } if (epoch === epochRef.current) fetchChat('live'); - }, [noteId, chatId, fetchChat]); + }, [transport, chatId, fetchChat]); const rename = useCallback( async (title: string): Promise => { - if (noteId == null || chatId == null) return false; + if (transport == null || chatId == null) return false; const epoch = epochRef.current; try { - const response = await NotebookChatService.renameChat(noteId, chatId, title); + const response = await transport.renameChat(chatId, title); if (epoch === epochRef.current) { setChat((prev) => (prev ? { ...prev, title: response.title } : prev)); } @@ -540,7 +542,7 @@ export function useNotebookChat({ return false; } }, - [noteId, chatId] + [transport, chatId] ); const refetch = useCallback(() => { @@ -578,7 +580,7 @@ export interface UseNotebookChatListResult { * authoritative and the UI entry point simply disappears. */ export function useNotebookChatList( - noteId: string | number | null, + transport: ChatTransport | null, enabled: boolean ): UseNotebookChatListResult { const [chats, setChats] = useState([]); @@ -589,10 +591,10 @@ export function useNotebookChatList( const epochRef = useRef(0); const refresh = useCallback(async () => { - if (noteId == null) return; + if (transport == null) return; const seq = ++seqRef.current; try { - const items = await NotebookChatService.listChats(noteId); + const items = await transport.listChats(); if (seq !== seqRef.current) return; setChats(items); setAccess('ok'); @@ -605,24 +607,24 @@ export function useNotebookChatList( setAccess((prev) => (prev === 'ok' ? 'ok' : 'error')); } } - }, [noteId]); + }, [transport]); useEffect(() => { seqRef.current += 1; epochRef.current += 1; setChats([]); setAccess('loading'); - if (enabled && noteId != null) { + if (enabled && transport != null) { refresh(); } - }, [noteId, enabled, refresh]); + }, [transport, enabled, refresh]); const createChat = useCallback( async (title?: string): Promise => { - if (noteId == null) return null; + if (transport == null) return null; const epoch = epochRef.current; try { - const chat = await NotebookChatService.createChat(noteId, title); + const chat = await transport.createChat(title); if (epoch === epochRef.current) refresh(); return chat; } catch (err) { @@ -633,7 +635,7 @@ export function useNotebookChatList( return null; } }, - [noteId, refresh] + [transport, refresh] ); return { chats, access, refresh, createChat }; diff --git a/hooks/useNotebookChatSocket.ts b/hooks/useNotebookChatSocket.ts index fa2f12f6d..71f1ac513 100644 --- a/hooks/useNotebookChatSocket.ts +++ b/hooks/useNotebookChatSocket.ts @@ -1,7 +1,7 @@ 'use client'; import { useMemo } from 'react'; -import { WS_ROUTES } from '@/services/websocket'; +import type { ChatTransport } from '@/services/chatTransport'; import { isChatSocketEvent, type ChatSocketEvent } from '@/types/notebookChat'; import { useReconnectingSocket, type SocketStatus } from './useReconnectingSocket'; @@ -16,7 +16,8 @@ const FATAL_CLOSE_CODES: ReadonlySet = new Set([4401, 4403, 4404]); export type ChatSocketStatus = SocketStatus; interface UseNotebookChatSocketOptions { - noteId: string | number | null; + /** The surface the chat lives on; null while there is nothing to connect to. */ + transport: ChatTransport | null; chatId: string | number | null; /** Connect only while the chat exists and is open on screen. */ enabled: boolean; @@ -31,15 +32,15 @@ interface UseNotebookChatSocketOptions { /** One WebSocket per open chat for lifecycle nudges and transient output. */ export function useNotebookChatSocket({ - noteId, + transport, chatId, enabled, onEvent, onReconnect, }: UseNotebookChatSocketOptions): ChatSocketStatus { const url = useMemo( - () => (noteId != null && chatId != null ? WS_ROUTES.NOTEBOOK_CHAT(noteId, chatId) : null), - [noteId, chatId] + () => (transport != null && chatId != null ? transport.socketUrl(chatId) : null), + [transport, chatId] ); return useReconnectingSocket({ diff --git a/services/assistantChat.service.ts b/services/assistantChat.service.ts new file mode 100644 index 000000000..f33624913 --- /dev/null +++ b/services/assistantChat.service.ts @@ -0,0 +1,55 @@ +import { ApiClient } from './client'; +import type { + CancelTurnResponse, + NotebookChat, + NotebookChatListItem, + SendMessageResponse, +} from '@/types/notebookChat'; +import type { GenerationRequest } from '@/types/notebookModels'; +import { ID } from '@/types/root'; + +const BASE_PATH = '/api/research_ai/assistant/chats/'; + +/** + * REST layer for the research assistant chat — the notebook chat without a + * note. Same representation and semantics as {@link NotebookChatService}; + * only the URL prefix differs, plus the `notes` field the assistant surface + * adds to a chat (the documents the agent created from it). + */ +export class AssistantChatService { + static async listChats(): Promise { + const response = await ApiClient.get<{ chats: NotebookChatListItem[] }>(BASE_PATH); + return response.chats ?? []; + } + + static async createChat(title?: string): Promise { + return ApiClient.post(BASE_PATH, title ? { title } : {}); + } + + static async getChat(chatId: ID, options?: { live?: boolean }): Promise { + const suffix = options?.live ? '?activity=live' : ''; + return ApiClient.get(`${BASE_PATH}${chatId}/${suffix}`); + } + + static async sendMessage( + chatId: ID, + message: string, + generation?: GenerationRequest + ): Promise { + return ApiClient.post(`${BASE_PATH}${chatId}/messages/`, { + message, + ...generation, + }); + } + + static async renameChat( + chatId: ID, + title: string + ): Promise<{ conversation_id: number; title: string }> { + return ApiClient.patch(`${BASE_PATH}${chatId}/`, { title }); + } + + static async cancelTurn(chatId: ID): Promise { + return ApiClient.post(`${BASE_PATH}${chatId}/cancel/`); + } +} diff --git a/services/chatTransport.ts b/services/chatTransport.ts new file mode 100644 index 000000000..c14532277 --- /dev/null +++ b/services/chatTransport.ts @@ -0,0 +1,65 @@ +import { AssistantChatService } from './assistantChat.service'; +import { NotebookChatService } from './notebookChat.service'; +import { WS_ROUTES } from './websocket'; +import type { + CancelTurnResponse, + NotebookChat, + NotebookChatListItem, + SendMessageResponse, +} from '@/types/notebookChat'; +import type { GenerationRequest } from '@/types/notebookModels'; + +type ChatId = string | number; + +/** + * Everything the chat hooks need from a backend surface. The notebook chat + * (scoped to a note) and the research assistant (no note) share one wire + * contract and one state machine; only the URLs differ, and this is where + * that difference lives. + * + * Construct one per surface and keep it referentially stable (memoize on its + * inputs) — the hooks reset their state whenever the transport changes. + */ +export interface ChatTransport { + /** Identifies the surface + scope; the hooks key their resets on it. */ + readonly key: string; + listChats(): Promise; + createChat(title?: string): Promise; + getChat(chatId: ChatId, options?: { live?: boolean }): Promise; + sendMessage( + chatId: ChatId, + message: string, + generation?: GenerationRequest + ): Promise; + renameChat(chatId: ChatId, title: string): Promise<{ conversation_id: number; title: string }>; + cancelTurn(chatId: ChatId): Promise; + socketUrl(chatId: ChatId): string; +} + +export function notebookChatTransport(noteId: ChatId): ChatTransport { + return { + key: `notebook:${noteId}`, + listChats: () => NotebookChatService.listChats(noteId), + createChat: (title) => NotebookChatService.createChat(noteId, title), + getChat: (chatId, options) => NotebookChatService.getChat(noteId, chatId, options), + sendMessage: (chatId, message, generation) => + NotebookChatService.sendMessage(noteId, chatId, message, generation), + renameChat: (chatId, title) => NotebookChatService.renameChat(noteId, chatId, title), + cancelTurn: (chatId) => NotebookChatService.cancelTurn(noteId, chatId), + socketUrl: (chatId) => WS_ROUTES.NOTEBOOK_CHAT(noteId, chatId), + }; +} + +export function assistantChatTransport(): ChatTransport { + return { + key: 'assistant', + listChats: () => AssistantChatService.listChats(), + createChat: (title) => AssistantChatService.createChat(title), + getChat: (chatId, options) => AssistantChatService.getChat(chatId, options), + sendMessage: (chatId, message, generation) => + AssistantChatService.sendMessage(chatId, message, generation), + renameChat: (chatId, title) => AssistantChatService.renameChat(chatId, title), + cancelTurn: (chatId) => AssistantChatService.cancelTurn(chatId), + socketUrl: (chatId) => WS_ROUTES.ASSISTANT_CHAT(chatId), + }; +} diff --git a/services/websocket.ts b/services/websocket.ts index fe13f33b8..c0b8e9b3c 100644 --- a/services/websocket.ts +++ b/services/websocket.ts @@ -5,6 +5,8 @@ export const WS_ROUTES = { NOTEBOOK_CHAT: (noteId: string | number, chatId: string | number) => `${getWebSocketBaseUrl()}/notebook/notes/${noteId}/chats/${chatId}/`, NOTE_VERSIONS: (noteId: string | number) => `${getWebSocketBaseUrl()}/notebook/notes/${noteId}/`, + ASSISTANT_CHAT: (chatId: string | number) => + `${getWebSocketBaseUrl()}/assistant/chats/${chatId}/`, }; function getWebSocketBaseUrl(): string { diff --git a/types/notebookChat.ts b/types/notebookChat.ts index 87ec89fde..c9a242f38 100644 --- a/types/notebookChat.ts +++ b/types/notebookChat.ts @@ -183,12 +183,23 @@ export interface ChatMessage { execution_id: number | null; } +/** A note the assistant surface created from a chat. */ +export interface ChatNoteRef { + id: number; + title: string; +} + export interface NotebookChat { conversation_id: number; title: string | null; messages: ChatMessage[]; /** Ordered oldest → newest. */ executions: ChatExecution[]; + /** + * Assistant surface only: notes this chat created via `create_note`, + * oldest first. Absent on the notebook surface, which is scoped to a note. + */ + notes?: ChatNoteRef[]; } export interface NotebookChatListItem { From 2f5205e69fda13a972cd5a39c63faab7dc46164a Mon Sep 17 00:00:00 2001 From: Kobe Attias Date: Sun, 6 Sep 2026 08:31:16 -0400 Subject: [PATCH 03/34] Add the AI Mode overlay shell, driven by URL state AIModeProvider owns the overlay: ?ai=1 opens it and ?ai=1&aiChat= selects a conversation, so a reload lands on the same place and any client-side navigation closes it by dropping the params. The overlay body is lazy-loaded, sits at z-9500 below modals and tooltips, locks body scroll, and closes on Esc unless a real modal is showing. The sidebar gains an Assistant item that toggles the overlay in place. The three panes are placeholders for now. Co-Authored-By: Claude Fable 5.1 --- app/layouts/Navigation.tsx | 56 +++++++- components/AIMode/AIModeContext.tsx | 157 +++++++++++++++++++++++ components/AIMode/AIModeOverlay.tsx | 103 +++++++++++++++ components/AIMode/copy.ts | 31 +++++ components/providers/ClientProviders.tsx | 5 +- 5 files changed, 345 insertions(+), 7 deletions(-) create mode 100644 components/AIMode/AIModeContext.tsx create mode 100644 components/AIMode/AIModeOverlay.tsx create mode 100644 components/AIMode/copy.ts diff --git a/app/layouts/Navigation.tsx b/app/layouts/Navigation.tsx index 3b34e7e9c..7b280be24 100644 --- a/app/layouts/Navigation.tsx +++ b/app/layouts/Navigation.tsx @@ -8,7 +8,9 @@ import { IconName } from '@/components/ui/icons/Icon'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faHouse as faHouseSolid } from '@fortawesome/pro-solid-svg-icons'; import { faHouse as faHouseLight } from '@fortawesome/pro-light-svg-icons'; -import { Sprout, Star } from 'lucide-react'; +import { Sparkles, Sprout, Star } from 'lucide-react'; +import { useOptionalAIMode } from '@/components/AIMode/AIModeContext'; +import { AI_MODE_NAME } from '@/components/AIMode/copy'; import { isHomeTabPath } from '@/hooks/useFundTabs'; import { cn } from '@/utils/styles'; @@ -30,6 +32,8 @@ interface NavigationItem { isLucideStar?: boolean; isLucideSprout?: boolean; isHome?: boolean; + /** Toggles the AI Mode overlay in place instead of navigating. */ + isAIMode?: boolean; } interface NavigationProps { @@ -103,11 +107,16 @@ export const Navigation: React.FC = ({ isLucideSprout: true, description: 'Learn about the ResearchHub Endowment', }, + { + label: AI_MODE_NAME, + href: '#', + isAIMode: true, + requiresAuth: true, + description: 'Chat with the research assistant', + }, ]; - const getButtonStyles = (path: string, isHome?: boolean) => { - const isActive = isPathActive(path, isHome); - + const getButtonStyles = (isActive: boolean) => { return cn( 'flex w-full items-center rounded-lg px-3 py-2.5 text-[15px] font-medium transition-colors', forceMinimize @@ -149,8 +158,9 @@ export const Navigation: React.FC = ({ }> = ({ item, onUnimplementedFeature }) => { const { executeAuthenticatedAction } = useAuthenticatedAction(); const router = useRouter(); - const buttonStyles = getButtonStyles(item.href, item.isHome); - const isActive = isPathActive(item.href, item.isHome); + const aiMode = useOptionalAIMode(); + const isActive = item.isAIMode ? Boolean(aiMode?.isOpen) : isPathActive(item.href, item.isHome); + const buttonStyles = getButtonStyles(isActive); const iconColor = isActive ? '#3971ff' : '#404040'; @@ -186,6 +196,40 @@ export const Navigation: React.FC = ({ ? 'hidden' : 'flex w-full min-w-0 items-center tablet:max-sidebar-compact:!hidden'; + if (item.isAIMode) { + // Same row as the links, but it is a toggle: the overlay opens in place + // and the URL only gains a query param. The icon carries a soft tint so + // it reads as a mode rather than a destination. + return ( + + ); + } + return ( ` selects a conversation. */ +export const AI_MODE_OPEN_PARAM = 'ai'; +export const AI_MODE_CHAT_PARAM = 'aiChat'; + +interface AIModeUrlState { + isOpen: boolean; + chatId: number | null; +} + +export interface AIModeContextValue extends AIModeUrlState { + /** Open on the last selected conversation, or the new-conversation screen. */ + open: () => void; + close: () => void; + toggle: () => void; + /** Select a conversation (null = the new-conversation screen), opening if needed. */ + selectChat: (chatId: number | null) => void; +} + +const AIModeContext = createContext(null); + +function parseChatId(raw: string | null): number | null { + if (raw == null) return null; + const parsed = Number.parseInt(raw, 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : null; +} + +/** + * Reads the overlay's URL state. Isolated behind Suspense because + * `useSearchParams` de-opts a statically rendered page up to the nearest + * boundary; the provider itself stays synchronous so nothing above it is + * affected. + */ +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; + useEffect(() => { + onChange({ isOpen, chatId }); + }, [isOpen, chatId, onChange]); + return null; +} + +const AIModeOverlay = dynamic( + () => import('./AIModeOverlay').then((module) => module.AIModeOverlay), + { ssr: false } +); + +/** + * 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. + * + * 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 router = useRouter(); + 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 pathnameRef = useRef(pathname); + pathnameRef.current = pathname; + + const navigate = useCallback( + (next: AIModeUrlState) => { + // 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); + } + const query = params.toString(); + const hash = window.location.hash; + router.replace(`${pathnameRef.current}${query ? `?${query}` : ''}${hash}`, { + scroll: false, + }); + // Reflect immediately rather than waiting for the router round-trip. + setState(next); + }, + [router] + ); + + const open = useCallback(() => { + navigate({ isOpen: true, chatId: lastChatIdRef.current }); + }, [navigate]); + const close = useCallback(() => navigate({ isOpen: false, chatId: null }), [navigate]); + const selectChat = useCallback( + (chatId: number | null) => { + if (chatId == null) lastChatIdRef.current = null; + navigate({ isOpen: true, chatId }); + }, + [navigate] + ); + + const stateRef = useRef(state); + stateRef.current = state; + 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] + ); + + return ( + + {children} + + + + {state.isOpen && } + + ); +} + +export function useAIMode(): AIModeContextValue { + const context = useContext(AIModeContext); + if (context == null) { + throw new Error('useAIMode must be used within AIModeProvider'); + } + return context; +} + +/** Same as {@link useAIMode} but tolerates rendering outside the provider. */ +export function useOptionalAIMode(): AIModeContextValue | null { + return useContext(AIModeContext); +} diff --git a/components/AIMode/AIModeOverlay.tsx b/components/AIMode/AIModeOverlay.tsx new file mode 100644 index 000000000..7a97e45ab --- /dev/null +++ b/components/AIMode/AIModeOverlay.tsx @@ -0,0 +1,103 @@ +'use client'; + +import { useEffect } from 'react'; +import { Sparkles, X } from 'lucide-react'; +import { useAIMode } from './AIModeContext'; +import { AI_MODE_NAME } from './copy'; + +/** + * 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. + */ +export function AIModeOverlay() { + const { close } = useAIMode(); + + // 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]); + + // Lock the page behind the overlay. + useEffect(() => { + const previous = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + return () => { + document.body.style.overflow = previous; + }; + }, []); + + return ( + + ); +} + +function PanePlaceholder({ label }: { readonly label: string }) { + return ( +
{label}
+ ); +} diff --git a/components/AIMode/copy.ts b/components/AIMode/copy.ts new file mode 100644 index 000000000..561c2f29a --- /dev/null +++ b/components/AIMode/copy.ts @@ -0,0 +1,31 @@ +/** + * User-facing copy for AI Mode, in one place so the product name and the + * empty-state wording can change without touching components. + */ +export const AI_MODE_NAME = 'Assistant'; + +export const AI_MODE_EMPTY_HEADING = 'What do you want to work on?'; + +export const AI_MODE_EMPTY_SUBHEADING = + 'Describe what you need. The assistant will ask a few questions, then write it up as a document you keep in your notebook.'; + +/** + * Static starter prompts for the empty state. They only prefill the composer; + * the backend does not supply suggestions. Placeholder wording — to be + * replaced by product copy. + */ +export const AI_MODE_STARTER_PROMPTS: readonly { title: string; message: string }[] = [ + { + title: 'Write a request for proposals', + message: 'Help me write a request for proposals to fund research on ', + }, + { + title: 'Scope a funding program', + message: + 'I want to fund research in a specific area. Help me decide what to ask for and how to judge applications. The area is ', + }, + { + title: 'Summarize the literature', + message: 'Give me a short, cited overview of the current evidence on ', + }, +]; diff --git a/components/providers/ClientProviders.tsx b/components/providers/ClientProviders.tsx index 27e1a756c..a777a6481 100644 --- a/components/providers/ClientProviders.tsx +++ b/components/providers/ClientProviders.tsx @@ -26,6 +26,7 @@ import { UserListsProvider } from '@/components/UserList/lib/UserListsContext'; import { LeaderboardProvider } from '@/contexts/LeaderboardContext'; import { DismissedFeaturesProvider } from '@/contexts/DismissedFeaturesContext'; import { PendingCountsProvider } from '@/components/Moderators/PendingCountsContext'; +import { AIModeProvider } from '@/components/AIMode/AIModeContext'; interface ClientProvidersProps { readonly children: ReactNode; @@ -58,7 +59,9 @@ export function ClientProviders({ children, session }: ClientProvidersProps) { - {children} + + {children} + From e13899e2dec6deff83428c10c22b5f6b58c72aaa Mon Sep 17 00:00:00 2001 From: Kobe Attias Date: Sun, 6 Sep 2026 08:35:59 -0400 Subject: [PATCH 04/34] AI Mode: conversation list and chat pane The left pane lists assistant conversations newest first with a spinner on running turns, a Document badge for chats whose detail has been loaded, and rename behind a row menu. The middle pane composes ChatTranscript and ChatComposer with the shared model controls, an empty state with static starter prompts, and autoscroll that stays put once the reader scrolls up. A conversation is only created on the first send. The list polls every 5s while any row reports an active turn and refreshes when the open chat's turn status or title changes. Below the tablet breakpoint the list opens in a bottom drawer. Send outcomes now carry the error code and body, so a 429 shows the budget reset time and a 409 usage_work_in_progress gets its own copy. The list hook exposes the server's detail on access failures, rendered verbatim instead of hiding the surface. Co-Authored-By: Claude Fable 5.1 --- components/AIMode/AIModeOverlay.tsx | 39 ++- components/AIMode/ChatPane.tsx | 211 +++++++++++++++ components/AIMode/ConversationList.tsx | 264 ++++++++++++++++++ components/AIMode/useAIModeChat.ts | 345 ++++++++++++++++++++++++ components/AgentChat/AgentChatPanel.tsx | 5 + hooks/useNotebookChat.ts | 32 ++- services/assistantChat.service.ts | 17 ++ services/notebookChat.service.ts | 8 + 8 files changed, 910 insertions(+), 11 deletions(-) create mode 100644 components/AIMode/ChatPane.tsx create mode 100644 components/AIMode/ConversationList.tsx create mode 100644 components/AIMode/useAIModeChat.ts diff --git a/components/AIMode/AIModeOverlay.tsx b/components/AIMode/AIModeOverlay.tsx index 7a97e45ab..052ac7362 100644 --- a/components/AIMode/AIModeOverlay.tsx +++ b/components/AIMode/AIModeOverlay.tsx @@ -1,8 +1,12 @@ 'use client'; -import { useEffect } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { Sparkles, X } from 'lucide-react'; +import { SwipeableDrawer } from '@/components/ui/SwipeableDrawer'; import { useAIMode } from './AIModeContext'; +import { ChatPane } from './ChatPane'; +import { ConversationList } from './ConversationList'; +import { useAIModeChat } from './useAIModeChat'; import { AI_MODE_NAME } from './copy'; /** @@ -33,6 +37,10 @@ function isForeignDialogOpen(): boolean { */ export function AIModeOverlay() { const { close } = useAIMode(); + const state = useAIModeChat(); + // Below the tablet breakpoint the list lives in a bottom drawer. + const [listDrawerOpen, setListDrawerOpen] = useState(false); + const closeListDrawer = useCallback(() => setListDrawerOpen(false), []); // Esc closes, unless something inside already claimed it (a menu, a modal // that portals outside the overlay). @@ -55,6 +63,27 @@ export function AIModeOverlay() { }; }, []); + const conversationList = ( + { + state.selectChat(chatId); + closeListDrawer(); + }} + onNew={() => { + state.startNewChat(); + closeListDrawer(); + }} + onRename={state.rename} + onRetry={state.list.refresh} + /> + ); + return (
- + setListDrawerOpen(true)} />
+ + + {conversationList} + ); } diff --git a/components/AIMode/ChatPane.tsx b/components/AIMode/ChatPane.tsx new file mode 100644 index 000000000..02f59814a --- /dev/null +++ b/components/AIMode/ChatPane.tsx @@ -0,0 +1,211 @@ +'use client'; + +import { useCallback, useEffect, useRef, type ReactNode } from 'react'; +import { Menu } from 'lucide-react'; +import { ChatComposer } from '@/components/AgentChat/ChatComposer'; +import { ChatTranscript } from '@/components/AgentChat/ChatTranscript'; +import { ModelControls } from '@/components/AgentChat/ModelControls'; +import { Button } from '@/components/ui/Button'; +import { Loader } from '@/components/ui/Loader'; +import { Logo } from '@/components/ui/Logo'; +import { cn } from '@/utils/styles'; +import type { AIModeChatState } from './useAIModeChat'; +import { AI_MODE_EMPTY_HEADING, AI_MODE_EMPTY_SUBHEADING, AI_MODE_STARTER_PROMPTS } from './copy'; + +/** How close to the bottom the transcript has to be for new content to pull it down. */ +const NEAR_BOTTOM_PX = 90; + +interface ChatPaneProps { + readonly state: AIModeChatState; + /** Header controls seated right of the title — the document toggle. */ + readonly headerActions?: ReactNode; + /** Below the tablet breakpoint the list is a drawer; this opens it. */ + readonly onOpenConversations?: () => void; +} + +/** The middle pane: transcript, live progress, and the composer. */ +export function ChatPane({ state, headerActions, onOpenConversations }: ChatPaneProps) { + const { chatId, list, chat, modelSelection, draft, setDraft, notice, creatingChat } = state; + const composerRef = useRef(null); + + // ---- transcript auto-scroll ---- + // Follows new content while the reader is at the bottom; never yanks the + // view down once they have scrolled up to re-read. + const scrollRef = useRef(null); + const nearBottomRef = useRef(true); + const handleScroll = () => { + const el = scrollRef.current; + if (!el) return; + nearBottomRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < NEAR_BOTTOM_PX; + }; + useEffect(() => { + nearBottomRef.current = true; + }, [chatId]); + useEffect(() => { + const el = scrollRef.current; + if (el && nearBottomRef.current) { + el.scrollTop = el.scrollHeight; + } + }, [chat.chat, chat.pendingSend]); + + const applyStarter = useCallback( + (message: string) => { + state.clearNotice(); + setDraft(message); + const textarea = composerRef.current; + if (!textarea) return; + textarea.focus(); + textarea.setSelectionRange(message.length, message.length); + }, + [state, setDraft] + ); + + const listBlocked = list.access === 'hidden'; + const chatUnavailable = + chatId != null && (chat.access === 'not_found' || chat.access === 'unauthorized'); + const composerDisabled = + listBlocked || chatUnavailable || (chatId != null && chat.access === 'loading'); + const composerBusy = chat.isBusy || creatingChat; + // Stop must only be offered when there is a turn to cancel server-side. + const canStop = chat.latestExecution != null && chat.isBusy && chat.pendingSend == null; + + const title = + chatId == null + ? 'New conversation' + : (chat.chat?.title?.trim() ?? '') || 'Untitled conversation'; + + return ( +
+
+ {onOpenConversations && ( + + )} +

{title}

+ {headerActions} +
+ +
+
+ {listBlocked ? ( + + ) : chatId == null ? ( + + ) : chat.access === 'loading' && chat.chat == null ? ( +
+ +
+ ) : chat.access === 'not_found' ? ( +

+ This conversation is no longer available. +

+ ) : chat.access === 'unauthorized' ? ( + + ) : chat.access === 'error' && chat.chat == null ? ( +
+

Couldn’t load this conversation.

+ +
+ ) : chat.chat ? ( + + ) : null} +
+
+ +
+
+ + } + /> +
+
+
+ ); +} + +function AccessBlocked({ detail }: { readonly detail: string | null }) { + return ( +
+

The assistant isn’t available to you yet.

+

+ {detail ?? 'Your account doesn’t have access to this feature.'} +

+
+ ); +} + +function EmptyState({ + onSelectStarter, + disabled, +}: { + readonly onSelectStarter: (message: string) => void; + readonly disabled: boolean; +}) { + return ( +
+
+
+ +
+
+

+ {AI_MODE_EMPTY_HEADING} +

+

+ {AI_MODE_EMPTY_SUBHEADING} +

+
+
+ +
+

+ Starting points · fills in the box below for you to edit +

+ {AI_MODE_STARTER_PROMPTS.map((prompt) => ( + + ))} +
+
+ ); +} diff --git a/components/AIMode/ConversationList.tsx b/components/AIMode/ConversationList.tsx new file mode 100644 index 000000000..1d69d3bda --- /dev/null +++ b/components/AIMode/ConversationList.tsx @@ -0,0 +1,264 @@ +'use client'; + +import { useEffect, useRef, useState, type KeyboardEvent } from 'react'; +import { FileText, MoreHorizontal, Pencil, Plus } from 'lucide-react'; +import { BaseMenu, BaseMenuItem } from '@/components/ui/form/BaseMenu'; +import { Loader } from '@/components/ui/Loader'; +import { Button } from '@/components/ui/Button'; +import { formatTimeAgo } from '@/utils/date'; +import { cn } from '@/utils/styles'; +import { + MAX_CHAT_TITLE_LENGTH, + type ChatNoteRef, + type NotebookChatListItem, +} from '@/types/notebookChat'; +import type { ChatListAccess } from '@/hooks/useNotebookChat'; + +const UNTITLED = 'Untitled conversation'; + +interface ConversationListProps { + readonly chats: NotebookChatListItem[]; + readonly access: ChatListAccess; + readonly accessDetail: string | null; + readonly activeChatId: number | null; + /** Live title of the open chat — fresher than the listing after renames and derives. */ + readonly activeTitle: string | null; + readonly notesByChat: ReadonlyMap; + readonly onSelect: (chatId: number) => void; + readonly onNew: () => void; + readonly onRename: (chatId: number, title: string) => 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, + activeTitle, + notesByChat, + onSelect, + onNew, + onRename, + 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 = (isActive ? activeTitle : null) ?? item.title; + const note = notesByChat.get(item.id); + 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); + }} + /> + ); + })} +
+
+ ); +} + +interface ConversationRowProps { + readonly item: NotebookChatListItem; + readonly title: string; + readonly isActive: boolean; + readonly note: ChatNoteRef | null; + readonly renaming: boolean; + readonly onSelect: () => void; + readonly onStartRename: () => void; + readonly onCancelRename: () => void; + readonly onCommitRename: (value: string) => void; +} + +function ConversationRow({ + item, + title, + isActive, + note, + renaming, + onSelect, + onStartRename, + onCancelRename, + onCommitRename, +}: ConversationRowProps) { + return ( +
+ {renaming ? ( + + ) : ( + + )} + + {!renaming && ( +
+ + + + } + > + + + +
+ )} +
+ ); +} + +function RenameField({ + initialValue, + onCommit, + onCancel, +}: { + readonly initialValue: string; + readonly onCommit: (value: string) => void; + readonly onCancel: () => void; +}) { + const [value, setValue] = useState(initialValue); + const inputRef = useRef(null); + useEffect(() => { + inputRef.current?.focus(); + inputRef.current?.select(); + }, []); + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Enter') { + event.preventDefault(); + onCommit(value); + } else if (event.key === 'Escape') { + // Claimed here so the overlay's own Esc handler doesn't close it. + event.preventDefault(); + event.stopPropagation(); + onCancel(); + } + }; + + return ( +
+ setValue(event.target.value)} + onKeyDown={handleKeyDown} + onBlur={() => onCommit(value)} + maxLength={MAX_CHAT_TITLE_LENGTH} + aria-label="Conversation title" + className="w-full rounded-md border border-primary-300 bg-white px-2 py-1 text-sm text-gray-900 outline-none ring-2 ring-primary-100" + /> +
+ ); +} diff --git a/components/AIMode/useAIModeChat.ts b/components/AIMode/useAIModeChat.ts new file mode 100644 index 000000000..220ffce5d --- /dev/null +++ b/components/AIMode/useAIModeChat.ts @@ -0,0 +1,345 @@ +'use client'; + +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useAIMode } from './AIModeContext'; +import { assistantChatTransport } from '@/services/chatTransport'; +import { AssistantChatService } from '@/services/assistantChat.service'; +import { + useNotebookChat, + useNotebookChatList, + type SendOutcome, + type UseNotebookChatListResult, + type UseNotebookChatResult, +} from '@/hooks/useNotebookChat'; +import { useAgentModelSelection, type AgentModelSelection } from '@/hooks/useAgentModelSelection'; +import type { ChatNoteRef, NotebookChat } from '@/types/notebookChat'; +import type { GenerationRequest } from '@/types/notebookModels'; +import type { ComposerNotice } from '@/components/AgentChat/ChatComposer'; + +/** Matches the chat hook's own poll cadence, so a background turn's spinner clears as fast as the open one. */ +const LIST_POLL_INTERVAL_MS = 5000; + +interface QueuedMessage { + text: string; + generation: GenerationRequest; +} + +function formatResetTime(iso: unknown): string | null { + if (typeof iso !== 'string') return null; + const date = new Date(iso); + if (Number.isNaN(date.getTime())) return null; + return date.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' }); +} + +/** + * Composer copy for a failed send. Server `detail` is rendered verbatim + * wherever it exists; the fallbacks only cover bodies without one. + */ +function noticeFromOutcome(outcome: SendOutcome & { ok: false }): ComposerNotice { + switch (outcome.reason) { + case 'busy': + if (outcome.code === 'usage_work_in_progress') { + return { + tone: 'warning', + text: + outcome.detail ?? + 'Another assistant task of yours is still running elsewhere. Wait for it to finish, then try again.', + }; + } + return { + tone: 'warning', + text: outcome.detail ?? 'The assistant is still working on a previous message.', + }; + case 'limit': { + const resetsAt = formatResetTime(outcome.body?.resets_at); + return { + tone: 'warning', + text: resetsAt + ? `You’ve used today’s assistant budget. It resets at ${resetsAt}.` + : (outcome.detail ?? 'You’ve used today’s assistant budget. Try again after it resets.'), + }; + } + case 'invalid': + return { tone: 'error', text: outcome.detail ?? 'That message can’t be sent.' }; + case 'not_found': + return { tone: 'error', text: 'This conversation is no longer available.' }; + case 'unauthorized': + return { + tone: 'error', + text: outcome.detail ?? 'You don’t have access to the assistant.', + }; + default: + return { + tone: 'error', + text: outcome.detail ?? 'Something went wrong — your message wasn’t sent.', + }; + } +} + +export interface AIModeChatState { + readonly chatId: number | null; + readonly list: UseNotebookChatListResult; + readonly chat: UseNotebookChatResult; + readonly modelSelection: AgentModelSelection; + readonly draft: string; + readonly setDraft: (value: string) => void; + readonly notice: ComposerNotice | null; + readonly clearNotice: () => void; + /** A brand-new chat is being created for the first message. */ + readonly creatingChat: boolean; + readonly send: () => Promise; + readonly stop: () => Promise; + /** Rename any conversation, open or not. */ + readonly rename: (chatId: number, title: string) => Promise; + readonly selectChat: (chatId: number | null) => void; + readonly startNewChat: () => void; + /** The first note of every conversation whose detail this session has loaded. */ + readonly notesByChat: ReadonlyMap; + /** The active conversation's document, if it has one. */ + readonly note: ChatNoteRef | null; +} + +/** + * Orchestration for the overlay: the list, the open chat, model selection, + * per-chat drafts, and the send path. A conversation is only created on the + * first send, so abandoned "new conversation" screens leave nothing behind. + */ +export function useAIModeChat(): AIModeChatState { + const { chatId, selectChat: selectChatInUrl } = useAIMode(); + const transport = useMemo(() => assistantChatTransport(), []); + + const list = useNotebookChatList(transport, true); + const [initialChat, setInitialChat] = useState(null); + const chat = useNotebookChat({ transport, chatId, enabled: true, initialChat }); + const modelSelection = useAgentModelSelection({ + enabled: true, + pinnedRef: chat.pinnedModelRef, + }); + + // ---- drafts (per chat, surviving switches and failed sends) ---- + const draftsRef = useRef(new Map()); + const draftKey = chatId == null ? 'new' : String(chatId); + const [draft, setDraftState] = useState(''); + const [notice, setNotice] = useState(null); + const [queuedMessage, setQueuedMessage] = useState(null); + const [creatingChat, setCreatingChat] = useState(false); + const creationSeqRef = useRef(0); + + const setDraft = useCallback( + (value: string) => { + draftsRef.current.set(draftKey, value); + setDraftState(value); + }, + [draftKey] + ); + + const prevDraftKeyRef = useRef(draftKey); + useEffect(() => { + if (prevDraftKeyRef.current === draftKey) return; + prevDraftKeyRef.current = draftKey; + setDraftState(draftsRef.current.get(draftKey) ?? ''); + setNotice(null); + }, [draftKey]); + + // ---- selection ---- + const selectChat = useCallback( + (next: number | null) => { + setInitialChat(null); + selectChatInUrl(next); + }, + [selectChatInUrl] + ); + const startNewChat = useCallback(() => selectChat(null), [selectChat]); + + // ---- keep the listing fresh ---- + // Derived titles land after the first turn; previews and spinners change as + // turns settle. Refresh on those transitions of the open chat... + const latestStatus = chat.latestExecution?.status ?? null; + const chatTitle = chat.chat?.title ?? null; + const refreshList = list.refresh; + const prevListSignalRef = useRef<{ status: string | null; title: string | null }>({ + status: null, + title: null, + }); + useEffect(() => { + const prev = prevListSignalRef.current; + const changed = prev.status !== latestStatus || prev.title !== chatTitle; + prevListSignalRef.current = { status: latestStatus, title: chatTitle }; + if (changed) refreshList(); + }, [latestStatus, chatTitle, refreshList]); + + // ...and poll while any other conversation has a turn running, so its row + // spinner clears without the user having to open it. + const anyTurnActive = list.chats.some((item) => item.has_active_turn); + useEffect(() => { + if (!anyTurnActive) return; + const timer = setInterval(() => { + refreshList(); + }, LIST_POLL_INTERVAL_MS); + return () => clearInterval(timer); + }, [anyTurnActive, refreshList]); + + // ---- document refs for the list badges ---- + const [notesByChat, setNotesByChat] = useState>(() => new Map()); + const firstNote = chat.chat?.notes?.[0] ?? null; + const firstNoteId = firstNote?.id ?? null; + const firstNoteTitle = firstNote?.title ?? null; + const loadedChatId = chat.chat?.conversation_id ?? null; + useEffect(() => { + if (loadedChatId == null || firstNoteId == null || firstNoteTitle == null) return; + setNotesByChat((prev) => { + const existing = prev.get(loadedChatId); + if (existing?.id === firstNoteId && existing.title === firstNoteTitle) return prev; + const next = new Map(prev); + next.set(loadedChatId, { id: firstNoteId, title: firstNoteTitle }); + return next; + }); + }, [loadedChatId, firstNoteId, firstNoteTitle]); + + // ---- sending ---- + // Async continuations compare against the live target and discard results + // that raced a chat switch instead of applying them to the new one. + const targetRef = useRef(chatId); + targetRef.current = chatId; + const isCurrentTarget = useCallback((target: number | null) => targetRef.current === target, []); + + const send = useCallback(async () => { + const text = draft.trim(); + if (!text) return; + setNotice(null); + const target = targetRef.current; + const generation = modelSelection.request; + + if (chatId == null) { + const creationSeq = ++creationSeqRef.current; + setCreatingChat(true); + const created = await list.createChat(); + if (creationSeqRef.current === creationSeq) setCreatingChat(false); + if (!isCurrentTarget(target)) return; + if (!created) { + setNotice({ + tone: 'error', + text: list.accessDetail ?? 'Couldn’t start a conversation. Please try again.', + }); + return; + } + draftsRef.current.delete('new'); + setInitialChat(created); + selectChatInUrl(created.conversation_id); + setQueuedMessage({ text, generation }); + return; + } + + const outcome = await chat.send(text, generation); + if (outcome.ok) { + if (isCurrentTarget(target)) { + setDraft(''); + } else { + draftsRef.current.delete(String(target)); + } + } else if (isCurrentTarget(target)) { + setNotice(noticeFromOutcome(outcome)); + } + }, [ + draft, + chatId, + list, + chat, + modelSelection.request, + setDraft, + isCurrentTarget, + selectChatInUrl, + ]); + + // Fire the queued first message once the freshly created chat is live. + const sendToChat = chat.send; + useEffect(() => { + if (queuedMessage == null || chatId == null || chat.access !== 'ok') return; + const { text, generation } = queuedMessage; + const target = targetRef.current; + setQueuedMessage(null); + sendToChat(text, generation).then((outcome) => { + if (outcome.ok) return; + if (isCurrentTarget(target)) { + setNotice(noticeFromOutcome(outcome)); + setDraft(text); + } else { + draftsRef.current.set(String(target), text); + } + }); + }, [queuedMessage, chatId, chat.access, sendToChat, setDraft, isCurrentTarget]); + + const stop = chat.cancel; + + const rename = useCallback( + async (target: number, title: string): Promise => { + if (target === targetRef.current) { + // The open chat's hook keeps its own copy of the title in sync. + const renamed = await chat.rename(title); + if (renamed) refreshList(); + return renamed; + } + try { + await transport.renameChat(target, title); + refreshList(); + return true; + } catch { + return false; + } + }, + [chat, transport, refreshList] + ); + + const clearNotice = useCallback(() => setNotice(null), []); + + // Surface the budget reset time on a 429 even when the body lacked it. + useEffect(() => { + if ( + notice?.tone !== 'warning' || + !notice.text.includes('budget') || + notice.text.includes('resets at') + ) { + return; + } + let cancelled = false; + AssistantChatService.getUsageBudget() + .then((budget) => { + const resetsAt = formatResetTime(budget.resets_at); + if (cancelled || !resetsAt) return; + setNotice({ + tone: 'warning', + text: `You’ve used today’s assistant budget. It resets at ${resetsAt}.`, + }); + }) + .catch(() => { + // The notice already says the budget is spent; the reset time is a bonus. + }); + return () => { + cancelled = true; + }; + }, [notice]); + + const note = useMemo(() => { + if (chatId == null) return null; + return firstNote ?? notesByChat.get(chatId) ?? null; + }, [chatId, firstNote, notesByChat]); + + return { + chatId, + list, + chat, + modelSelection, + draft, + setDraft, + notice, + clearNotice, + creatingChat, + send, + stop, + rename, + selectChat, + startNewChat, + notesByChat, + note, + }; +} diff --git a/components/AgentChat/AgentChatPanel.tsx b/components/AgentChat/AgentChatPanel.tsx index 1e81118bf..f223efc5c 100644 --- a/components/AgentChat/AgentChatPanel.tsx +++ b/components/AgentChat/AgentChatPanel.tsx @@ -66,6 +66,11 @@ function noticeFromOutcome(outcome: SendOutcome & { ok: false }): ComposerNotice return { tone: 'error', text: 'This chat is no longer available.' }; case 'unauthorized': return { tone: 'error', text: 'You no longer have access to the assistant.' }; + case 'limit': + return { + tone: 'warning', + text: outcome.detail ?? 'You’ve used today’s assistant budget. Try again after it resets.', + }; default: return { tone: 'error', text: 'Something went wrong — your message wasn’t sent.' }; } diff --git a/hooks/useNotebookChat.ts b/hooks/useNotebookChat.ts index 8a96ab00b..26e24664c 100644 --- a/hooks/useNotebookChat.ts +++ b/hooks/useNotebookChat.ts @@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { debounce, type DebouncedFunc } from 'lodash-es'; -import { chatErrorDetail, chatErrorStatus } from '@/services/notebookChat.service'; +import { chatErrorBody, chatErrorDetail, chatErrorStatus } from '@/services/notebookChat.service'; import type { ChatTransport } from '@/services/chatTransport'; import { useNotebookChatSocket, type ChatSocketStatus } from '@/hooks/useNotebookChatSocket'; import { @@ -41,25 +41,35 @@ export type SendOutcome = | { ok: true } | { ok: false; - reason: 'busy' | 'invalid' | 'not_found' | 'unauthorized' | 'error'; + /** `limit` is a 429: the user's daily Research AI budget is spent. */ + reason: 'busy' | 'invalid' | 'not_found' | 'unauthorized' | 'limit' | 'error'; detail?: string; + /** Machine code from the error body, e.g. `usage_work_in_progress`. */ + code?: string; + /** The raw error body, for fields beyond `detail` (a 429's budget status). */ + body?: Record; }; /** Maps a failed send POST to its outcome; the state side-effects stay in `send`. */ function sendFailureOutcome(err: unknown): Extract { const detail = chatErrorDetail(err); + const body = chatErrorBody(err); + const code = typeof body?.code === 'string' ? body.code : undefined; + const extra = { detail, code, body }; switch (chatErrorStatus(err)) { case 409: - return { ok: false, reason: 'busy', detail }; + return { ok: false, reason: 'busy', ...extra }; case 400: - return { ok: false, reason: 'invalid', detail }; + return { ok: false, reason: 'invalid', ...extra }; case 401: case 403: - return { ok: false, reason: 'unauthorized', detail }; + return { ok: false, reason: 'unauthorized', ...extra }; case 404: - return { ok: false, reason: 'not_found', detail }; + return { ok: false, reason: 'not_found', ...extra }; + case 429: + return { ok: false, reason: 'limit', ...extra }; default: - return { ok: false, reason: 'error', detail }; + return { ok: false, reason: 'error', ...extra }; } } @@ -570,6 +580,8 @@ export type ChatListAccess = 'loading' | 'ok' | 'hidden' | 'error'; export interface UseNotebookChatListResult { chats: NotebookChatListItem[]; access: ChatListAccess; + /** The server's `detail` copy behind a `hidden` or `error` access state. */ + accessDetail: string | null; refresh: () => Promise; createChat: (title?: string) => Promise; } @@ -585,6 +597,7 @@ export function useNotebookChatList( ): UseNotebookChatListResult { const [chats, setChats] = useState([]); const [access, setAccess] = useState('loading'); + const [accessDetail, setAccessDetail] = useState(null); const seqRef = useRef(0); // Same stale-continuation guard as the chat hook: a createChat bound to a // previous note must not refresh (or hide) the current note's listing. @@ -598,9 +611,11 @@ export function useNotebookChatList( if (seq !== seqRef.current) return; setChats(items); setAccess('ok'); + setAccessDetail(null); } catch (err) { if (seq !== seqRef.current) return; const status = chatErrorStatus(err); + setAccessDetail(chatErrorDetail(err) ?? null); if (status === 401 || status === 403 || status === 404) { setAccess('hidden'); } else { @@ -614,6 +629,7 @@ export function useNotebookChatList( epochRef.current += 1; setChats([]); setAccess('loading'); + setAccessDetail(null); if (enabled && transport != null) { refresh(); } @@ -638,5 +654,5 @@ export function useNotebookChatList( [transport, refresh] ); - return { chats, access, refresh, createChat }; + return { chats, access, accessDetail, refresh, createChat }; } diff --git a/services/assistantChat.service.ts b/services/assistantChat.service.ts index f33624913..d74cf52d9 100644 --- a/services/assistantChat.service.ts +++ b/services/assistantChat.service.ts @@ -10,6 +10,19 @@ import { ID } from '@/types/root'; const BASE_PATH = '/api/research_ai/assistant/chats/'; +/** `GET /api/research_ai/usage-budget/` — the user's daily Research AI budget. */ +export interface UsageBudget { + tier: string; + daily_budget: string; + spent_today: string; + remaining: string; + turns_used: number; + turn_cap: number; + /** ISO timestamp of the next daily reset. */ + resets_at: string; + credits?: { daily_limit: number; used: number; remaining: number }; +} + /** * REST layer for the research assistant chat — the notebook chat without a * note. Same representation and semantics as {@link NotebookChatService}; @@ -52,4 +65,8 @@ export class AssistantChatService { static async cancelTurn(chatId: ID): Promise { return ApiClient.post(`${BASE_PATH}${chatId}/cancel/`); } + + static async getUsageBudget(): Promise { + return ApiClient.get('/api/research_ai/usage-budget/'); + } } diff --git a/services/notebookChat.service.ts b/services/notebookChat.service.ts index 36bf24a64..e8a1de3f0 100644 --- a/services/notebookChat.service.ts +++ b/services/notebookChat.service.ts @@ -87,6 +87,14 @@ export function chatErrorStatus(error: unknown): number | undefined { return error instanceof ApiError ? error.status : undefined; } +/** The parsed error body of a thrown service error, if there was one. */ +export function chatErrorBody(error: unknown): Record | undefined { + if (error instanceof ApiError && error.errors != null && typeof error.errors === 'object') { + return error.errors as unknown as Record; + } + return undefined; +} + /** * User-facing detail from a DRF error response (`{"detail": "..."}`), e.g. the * 409 "assistant is still working" copy. Falls back to the generic message. From bd0af49a173cb034e2c1476f965daf59f3b86e17 Mon Sep 17 00:00:00 2001 From: Kobe Attias Date: Sun, 6 Sep 2026 08:38:52 -0400 Subject: [PATCH 05/34] AI Mode: document pane with live drafting The right pane opens by itself when a conversation gains a note (notes[0] on the representation, or a succeeded create_note in the activity) and renders the note read-only in the block editor. It refetches when either signal reports a newer version: a succeeded edit_note carrying note_version_id, or a note_version_created frame on the note's socket. Version ids are compared, never assumed ordered, so duplicate and reordered events are harmless. While the model composes an edit_note the stream's tool_draft prose is appended below the settled content as the section being written; when a turn runs with no draft streaming, an in-progress row shows phase.label so the page never sits frozen. The badge counts level 1 and 2 headings plus one for an open draft. An Open in notebook link deep-links via the note's organization slug. Below the tablet breakpoint the pane is a bottom drawer. Co-Authored-By: Claude Fable 5.1 --- components/AIMode/AIModeOverlay.tsx | 71 ++++++-- components/AIMode/DocumentPane.tsx | 146 +++++++++++++++++ components/AIMode/useAIModeDocument.ts | 217 +++++++++++++++++++++++++ 3 files changed, 423 insertions(+), 11 deletions(-) create mode 100644 components/AIMode/DocumentPane.tsx create mode 100644 components/AIMode/useAIModeDocument.ts diff --git a/components/AIMode/AIModeOverlay.tsx b/components/AIMode/AIModeOverlay.tsx index 052ac7362..9726c987c 100644 --- a/components/AIMode/AIModeOverlay.tsx +++ b/components/AIMode/AIModeOverlay.tsx @@ -1,12 +1,16 @@ 'use client'; import { useCallback, useEffect, useState } from 'react'; -import { Sparkles, X } from 'lucide-react'; +import { FileText, Sparkles, X } from 'lucide-react'; +import { cn } from '@/utils/styles'; import { SwipeableDrawer } from '@/components/ui/SwipeableDrawer'; +import { useMediaQuery } from '@/hooks/useMediaQuery'; import { useAIMode } from './AIModeContext'; import { ChatPane } from './ChatPane'; import { ConversationList } from './ConversationList'; +import { DocumentPane } from './DocumentPane'; import { useAIModeChat } from './useAIModeChat'; +import { useAIModeDocument } from './useAIModeDocument'; import { AI_MODE_NAME } from './copy'; /** @@ -42,6 +46,25 @@ export function AIModeOverlay() { const [listDrawerOpen, setListDrawerOpen] = useState(false); const closeListDrawer = useCallback(() => setListDrawerOpen(false), []); + const doc = useAIModeDocument({ + note: state.note, + chat: state.chat.chat, + latestExecution: state.chat.latestExecution, + }); + + // The document pane opens by itself the moment a conversation gains a note + // and stays closed until then. The user can close it and reopen it from the + // chat header. Below the tablet breakpoint the same content is a drawer. + const noteId = state.note?.id ?? null; + const [documentOpen, setDocumentOpen] = useState(false); + useEffect(() => { + setDocumentOpen(noteId != null); + }, [noteId]); + const closeDocument = useCallback(() => setDocumentOpen(false), []); + const showDocument = noteId != null && documentOpen; + // Tailwind's `tablet` breakpoint; the drawer only exists below it. + const isBelowTablet = useMediaQuery('(max-width: 767px)') === true; + // Esc closes, unless something inside already claimed it (a menu, a modal // that portals outside the overlay). useEffect(() => { @@ -115,22 +138,48 @@ export function AIModeOverlay() { {conversationList}
- setListDrawerOpen(true)} /> + setListDrawerOpen(true)} + headerActions={ + noteId != null && ( + + ) + } + />
- + {showDocument && ( + + )} {conversationList} + + + ); } - -function PanePlaceholder({ label }: { readonly label: string }) { - return ( -
{label}
- ); -} diff --git a/components/AIMode/DocumentPane.tsx b/components/AIMode/DocumentPane.tsx new file mode 100644 index 000000000..8caacc91a --- /dev/null +++ b/components/AIMode/DocumentPane.tsx @@ -0,0 +1,146 @@ +'use client'; + +import { ExternalLink, FileText, X } from 'lucide-react'; +import { BlockEditorClientWrapper } from '@/components/Editor/components/BlockEditor/components/BlockEditorClientWrapper'; +import { Button } from '@/components/ui/Button'; +import { Loader } from '@/components/ui/Loader'; +import { cn } from '@/utils/styles'; +import type { AIModeDocument } from './useAIModeDocument'; + +interface DocumentPaneProps { + readonly document: AIModeDocument; + readonly onClose: () => void; + readonly className?: string; +} + +/** + * The right pane: the note the assistant is composing. Settled content + * renders read-only in the real editor; while a section is being written the + * streaming prose is appended below it, and when a turn runs with no draft + * an in-progress row says what the assistant is doing instead of leaving the + * page frozen. + */ +export function DocumentPane({ document, onClose, className }: DocumentPaneProps) { + const { note, content, loading, error, status, draftText, phaseLabel, sectionCount } = document; + const title = content?.title?.trim() || note?.title?.trim() || 'Document'; + const writing = status === 'drafting' || status === 'working'; + + return ( +
+
+
+ +
+ {error && content == null ? ( +
+

{error}

+ +
+ ) : loading && content == null ? ( +
+ +
+ ) : ( +
+ {content?.contentJson && content.versionId > 0 ? ( + + ) : status === 'drafting' ? null : ( + + )} + + {status === 'drafting' && draftText && } + + {status === 'working' && content != null && content.versionId > 0 && ( + + )} +
+ )} +
+
+ ); +} + +function EmptyDocument({ label }: { readonly label: string | null }) { + return ( +
+ +

Starting the document…

+ {label &&

{label}

} +
+ ); +} + +/** The section being written, appended below the settled content. */ +function DraftSection({ text }: { readonly text: string }) { + const paragraphs = text.split(/\n{2,}/).filter((paragraph) => paragraph.trim().length > 0); + return ( +
+

+ + Writing +

+ {paragraphs.map((paragraph, index) => ( +

+ {paragraph} + {index === paragraphs.length - 1 && ( + + )} +

+ ))} +
+ ); +} + +/** No draft is streaming, but a turn is running: say what it's doing. */ +function InProgressRow({ label }: { readonly label: string }) { + return ( +
+ + {label}… +
+ ); +} diff --git a/components/AIMode/useAIModeDocument.ts b/components/AIMode/useAIModeDocument.ts new file mode 100644 index 000000000..d38c28296 --- /dev/null +++ b/components/AIMode/useAIModeDocument.ts @@ -0,0 +1,217 @@ +'use client'; + +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { NoteService } from '@/services/note.service'; +import { useNoteVersionSocket } from '@/hooks/useNoteVersionSocket'; +import { NOTE_VERSION_CREATED, type NoteVersionEvent, type NoteWithContent } from '@/types/note'; +import { + isActiveExecutionStatus, + type ChatExecution, + type ChatNoteRef, + type NotebookChat, +} from '@/types/notebookChat'; + +export type DocumentStatus = + /** No note on this conversation: the pane has nothing to show. */ + | 'absent' + /** The note exists but the agent hasn't written a version yet. */ + | 'empty' + /** A turn is running and the model is composing an `edit_note` right now. */ + | 'drafting' + /** A turn is running with no draft streaming (other providers, or between edits). */ + | 'working' + /** No turn running: content only. */ + | 'settled'; + +export interface AIModeDocument { + readonly note: ChatNoteRef | null; + readonly content: NoteWithContent | null; + readonly loading: boolean; + readonly error: string | null; + readonly status: DocumentStatus; + /** Prose of the `edit_note` call being composed, paragraphs split by blank lines. */ + readonly draftText: string | null; + /** What the assistant is doing, for the in-progress row when there is no draft. */ + readonly phaseLabel: string | null; + /** Heading count in the settled document, plus one for an open draft. */ + readonly sectionCount: number; + /** Deep link to the note in the notebook, once its organization is known. */ + readonly notebookHref: string | null; + readonly refetch: () => void; +} + +interface UseAIModeDocumentOptions { + readonly note: ChatNoteRef | null; + readonly chat: NotebookChat | null; + readonly latestExecution: ChatExecution | null; +} + +/** Highest note version any succeeded `edit_note` in the chat reports. */ +function maxEditedVersion(chat: NotebookChat | null): number | null { + let max: number | null = null; + for (const execution of chat?.executions ?? []) { + for (const item of execution.activity ?? []) { + if ( + item.type === 'tool_call' && + item.status === 'succeeded' && + item.note_version_id != null + ) { + max = max == null ? item.note_version_id : Math.max(max, item.note_version_id); + } + } + } + return max; +} + +/** The `edit_note` draft the active turn is composing, if any. */ +function currentEditDraft(execution: ChatExecution | null): string | null { + if (execution == null || !isActiveExecutionStatus(execution.status)) return null; + const items = execution.stream?.items ?? []; + for (let index = items.length - 1; index >= 0; index -= 1) { + const item = items[index]; + if (item.type === 'tool_draft' && item.tool === 'edit_note') { + return item.text.length > 0 ? item.text : null; + } + } + return null; +} + +/** Level 1 and 2 headings are sections; deeper ones are their subdivisions. */ +const SECTION_HEADING_LEVELS = new Set([1, 2]); + +function countSections(contentJson: string | undefined): number { + if (!contentJson) return 0; + try { + const parsed: unknown = JSON.parse(contentJson); + const blocks = + parsed != null && + typeof parsed === 'object' && + Array.isArray((parsed as { content?: unknown }).content) + ? ((parsed as { content: unknown[] }).content as { + type?: string; + attrs?: { level?: number }; + }[]) + : []; + return blocks.filter( + (block) => block?.type === 'heading' && SECTION_HEADING_LEVELS.has(block.attrs?.level ?? 1) + ).length; + } catch { + return 0; + } +} + +/** + * The document behind a conversation: its content, kept current from both + * the chat's own activity (succeeded `edit_note` versions) and the note's + * version socket, plus the live draft while a section is being written. + */ +export function useAIModeDocument({ + note, + chat, + latestExecution, +}: UseAIModeDocumentOptions): AIModeDocument { + const noteId = note?.id ?? null; + const [content, setContent] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + // Version we hold, and the newest we've heard exists — comparisons read the + // refs so socket bursts and activity merges don't race the render. + const heldVersionRef = useRef(0); + const seqRef = useRef(0); + + const fetchNote = useCallback(async () => { + if (noteId == null) return; + const seq = ++seqRef.current; + setLoading(true); + try { + const fetched = await NoteService.getNote(String(noteId)); + if (seq !== seqRef.current) return; + // A stale response (older version than one already applied) must not + // roll the document back. + if (fetched.versionId >= heldVersionRef.current) { + heldVersionRef.current = fetched.versionId; + setContent(fetched); + } + setError(null); + } catch (err) { + if (seq !== seqRef.current) return; + setError(err instanceof Error ? err.message : 'Couldn’t load the document.'); + } finally { + if (seq === seqRef.current) setLoading(false); + } + }, [noteId]); + + // Reset and load whenever the note changes. + useEffect(() => { + seqRef.current += 1; + heldVersionRef.current = 0; + setContent(null); + setError(null); + setLoading(noteId != null); + if (noteId != null) fetchNote(); + }, [noteId, fetchNote]); + + const refetchIfNewer = useCallback( + (versionId: number | null | undefined) => { + if (versionId == null) return; + if (versionId > heldVersionRef.current) fetchNote(); + }, + [fetchNote] + ); + + // Signal 1: the chat's durable activity reports a newer edited version. + const editedVersion = maxEditedVersion(chat); + useEffect(() => { + refetchIfNewer(editedVersion); + }, [editedVersion, refetchIfNewer]); + + // Signal 2: the note's own version socket, whoever wrote the version. + const handleVersionEvent = useCallback( + (event: NoteVersionEvent) => { + if (event.type !== NOTE_VERSION_CREATED || event.note_id !== noteId) return; + refetchIfNewer(event.version_id); + }, + [noteId, refetchIfNewer] + ); + useNoteVersionSocket({ + noteId, + enabled: noteId != null, + onEvent: handleVersionEvent, + onReconnect: fetchNote, + }); + + const draftText = currentEditDraft(latestExecution); + const turnActive = latestExecution != null && isActiveExecutionStatus(latestExecution.status); + const phaseLabel = turnActive ? (latestExecution?.phase?.label ?? null) : null; + + const status: DocumentStatus = useMemo(() => { + if (noteId == null) return 'absent'; + if (draftText != null) return 'drafting'; + if (turnActive) return 'working'; + if (content != null && content.versionId === 0) return 'empty'; + return 'settled'; + }, [noteId, draftText, turnActive, content]); + + const sectionCount = useMemo( + () => countSections(content?.contentJson) + (draftText != null ? 1 : 0), + [content?.contentJson, draftText] + ); + + const notebookHref = useMemo(() => { + const slug = content?.organization?.slug; + return slug && noteId != null ? `/notebook/${slug}/${noteId}` : null; + }, [content?.organization?.slug, noteId]); + + return { + note, + content, + loading, + error, + status, + draftText, + phaseLabel, + sectionCount, + notebookHref, + refetch: fetchNote, + }; +} From e7b7ae13ad83c2cc0b8b5821187f46359a01c097 Mon Sep 17 00:00:00 2001 From: Kobe Attias Date: Sun, 6 Sep 2026 08:44:10 -0400 Subject: [PATCH 06/34] AI Mode: idle empty-document copy, mount the mobile document drawer lazily Co-Authored-By: Claude Fable 5.1 --- components/AIMode/AIModeOverlay.tsx | 4 +++- components/AIMode/DocumentPane.tsx | 26 +++++++++++++++++++++----- 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/components/AIMode/AIModeOverlay.tsx b/components/AIMode/AIModeOverlay.tsx index 9726c987c..4af051745 100644 --- a/components/AIMode/AIModeOverlay.tsx +++ b/components/AIMode/AIModeOverlay.tsx @@ -178,7 +178,9 @@ export function AIModeOverlay() { showCloseButton={false} className="tablet:!hidden" > - + {showDocument && ( + + )} ); diff --git a/components/AIMode/DocumentPane.tsx b/components/AIMode/DocumentPane.tsx index 8caacc91a..4bf577b27 100644 --- a/components/AIMode/DocumentPane.tsx +++ b/components/AIMode/DocumentPane.tsx @@ -85,7 +85,7 @@ export function DocumentPane({ document, onClose, className }: DocumentPaneProps editable={false} /> ) : status === 'drafting' ? null : ( - + )} {status === 'drafting' && draftText && } @@ -100,12 +100,28 @@ export function DocumentPane({ document, onClose, className }: DocumentPaneProps ); } -function EmptyDocument({ label }: { readonly label: string | null }) { +/** + * The note exists but has no version yet. Spins only while a turn is + * running; a settled conversation that never wrote anything says so plainly. + */ +function EmptyDocument({ + label, + active, +}: { + readonly label: string | null; + readonly active: boolean; +}) { return (
- -

Starting the document…

- {label &&

{label}

} + {active ? ( + <> + +

Starting the document…

+ {label &&

{label}

} + + ) : ( +

Nothing has been written to this document yet.

+ )}
); } From 5d1e200e0363e27dfd012da5decac4ad274a5112 Mon Sep 17 00:00:00 2001 From: Kobe Attias Date: Sun, 6 Sep 2026 09:01:23 -0400 Subject: [PATCH 07/34] AI Mode: starter prompts for drafting an RFP or a proposal Mirrors the notebook's two writing presets so a funder can start a request for proposals and a researcher a proposal from the empty state, alongside the research and funding-search starters. Each prefills the composer. Co-Authored-By: Claude Fable 5.1 --- components/AIMode/ChatPane.tsx | 42 +++++++++++++++----------- components/AIMode/copy.ts | 54 ++++++++++++++++++++++++++++------ 2 files changed, 70 insertions(+), 26 deletions(-) diff --git a/components/AIMode/ChatPane.tsx b/components/AIMode/ChatPane.tsx index 02f59814a..8ba6ff6ca 100644 --- a/components/AIMode/ChatPane.tsx +++ b/components/AIMode/ChatPane.tsx @@ -185,26 +185,34 @@ function EmptyState({ -
+

Starting points · fills in the box below for you to edit

- {AI_MODE_STARTER_PROMPTS.map((prompt) => ( - - ))} +
+ {AI_MODE_STARTER_PROMPTS.map((prompt) => ( + + ))} +
); diff --git a/components/AIMode/copy.ts b/components/AIMode/copy.ts index 561c2f29a..774b42524 100644 --- a/components/AIMode/copy.ts +++ b/components/AIMode/copy.ts @@ -1,3 +1,6 @@ +import type { ComponentType } from 'react'; +import { HandCoins, Megaphone, PenLine, Telescope } from 'lucide-react'; + /** * User-facing copy for AI Mode, in one place so the product name and the * empty-state wording can change without touching components. @@ -9,23 +12,56 @@ export const AI_MODE_EMPTY_HEADING = 'What do you want to work on?'; export const AI_MODE_EMPTY_SUBHEADING = 'Describe what you need. The assistant will ask a few questions, then write it up as a document you keep in your notebook.'; +export interface StarterPrompt { + readonly id: string; + readonly title: string; + readonly description: string; + readonly icon: ComponentType<{ className?: string }>; + /** Loaded into the composer as an editable starting point, never sent as-is. */ + readonly message: string; +} + /** * Static starter prompts for the empty state. They only prefill the composer; - * the backend does not supply suggestions. Placeholder wording — to be - * replaced by product copy. + * the backend does not supply suggestions. The two writing prompts mirror the + * notebook's own presets: a funder drafting a request for proposals, and a + * researcher drafting a proposal. Placeholder wording, to be replaced by + * product copy. */ -export const AI_MODE_STARTER_PROMPTS: readonly { title: string; message: string }[] = [ +export const AI_MODE_STARTER_PROMPTS: readonly StarterPrompt[] = [ { - title: 'Write a request for proposals', - message: 'Help me write a request for proposals to fund research on ', + id: 'draft-rfp', + title: 'Draft a request for proposals', + description: 'Fund specific research you care about', + icon: Megaphone, + message: + 'Help me draft a request for proposals. Ask me for anything you still need to know about ' + + 'the work I want to fund, then create a note and write the RFP into it.', }, { - title: 'Scope a funding program', + id: 'draft-proposal', + title: 'Draft a proposal', + description: 'Raise money for your research', + icon: PenLine, message: - 'I want to fund research in a specific area. Help me decide what to ask for and how to judge applications. The area is ', + 'Help me draft a research proposal. Ask me for anything you still need to know about the ' + + 'work, then create a note and write the proposal into it, starting with three hypotheses.', }, { - title: 'Summarize the literature', - message: 'Give me a short, cited overview of the current evidence on ', + id: 'research', + title: 'Help me research', + description: 'Cited overview of the literature', + icon: Telescope, + message: + 'Help me research a topic. Search the web and the scholarly literature for the most ' + + 'relevant work and summarise what I should know, with sources. The topic is ', + }, + { + id: 'funding', + title: 'Find me funding', + description: 'Open RFPs that fit your work', + icon: HandCoins, + message: + 'Find open RFPs I could apply to based on my expertise, and tell me why each one is a match.', }, ]; From eb3e1a0eed28242a3d064b8daddbf76a068948fb Mon Sep 17 00:00:00 2001 From: Kobe Attias Date: Sun, 6 Sep 2026 13:04:59 -0400 Subject: [PATCH 08/34] Extract the assistant-version review into useNoteAgentReview The notebook chat panel owned ~400 lines that turn a newer agent-authored note version into an in-note diff review: hearing versions from the note socket, the chat's activity and a reconnect probe; fetching the pinned version; building or folding the overlay; persisting the accept or reject projection; and the locks and epochs guarding all of it across note switches. That logic now lives in useNoteAgentReview, keyed on the note, its editor, the loaded version and the open chat, so the upcoming AI Mode document pane can run the same review over its own editor. The failure banner moves to NoteReviewBanner for the same reason. The panel keeps the controls placement and the onReviewChange handoff to the note page; behaviour is unchanged. Co-Authored-By: Claude Fable 5.1 --- components/AgentChat/AgentChatPanel.tsx | 561 +--------------- .../Notebook/NoteReview/NoteReviewBanner.tsx | 47 ++ .../Notebook/NoteReview/useNoteAgentReview.ts | 601 ++++++++++++++++++ 3 files changed, 666 insertions(+), 543 deletions(-) create mode 100644 components/Notebook/NoteReview/NoteReviewBanner.tsx create mode 100644 components/Notebook/NoteReview/useNoteAgentReview.ts diff --git a/components/AgentChat/AgentChatPanel.tsx b/components/AgentChat/AgentChatPanel.tsx index f223efc5c..a751345dd 100644 --- a/components/AgentChat/AgentChatPanel.tsx +++ b/components/AgentChat/AgentChatPanel.tsx @@ -1,9 +1,7 @@ 'use client'; import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react'; -import type { Editor } from '@tiptap/core'; import { Check, MessageSquarePlus, Pencil, X } from 'lucide-react'; -import { DOMParser as ProseMirrorDOMParser, type Node as ProseMirrorNode } from '@tiptap/pm/model'; import { Button } from '@/components/ui/Button'; import { Loader } from '@/components/ui/Loader'; import { cn } from '@/utils/styles'; @@ -12,9 +10,7 @@ import { useNotebookChat, useNotebookChatList, type SendOutcome } from '@/hooks/ import { notebookChatTransport } from '@/services/chatTransport'; import { useAgentModelSelection } from '@/hooks/useAgentModelSelection'; import { MAX_AGENT_CHAT_WIDTH, MIN_AGENT_CHAT_WIDTH } from '@/hooks/useAgentChatWidth'; -import { useNoteVersionSocket } from '@/hooks/useNoteVersionSocket'; -import { NoteService } from '@/services/note.service'; -import { isRfpNote, NOTE_VERSION_CREATED } from '@/types/note'; +import { isRfpNote } from '@/types/note'; import { isActiveExecutionStatus, MAX_CHAT_TITLE_LENGTH, @@ -26,6 +22,8 @@ import { useDismissableFeature } from '@/hooks/useDismissableFeature'; import { useEditorIsEmpty } from '@/hooks/useEditorIsEmpty'; import { belowMobileTopBar } from '@/components/Notebook/mobileChromeOffsets'; import { NoteReviewControls } from '@/components/Notebook/NoteReview/NoteReviewControls'; +import { NoteReviewBanner } from '@/components/Notebook/NoteReview/NoteReviewBanner'; +import { useNoteAgentReview } from '@/components/Notebook/NoteReview/useNoteAgentReview'; import { ChatComposer, type ComposerNotice } from './ChatComposer'; import { ChatPicker } from './ChatPicker'; import { ChatPresets } from './ChatPresets'; @@ -33,11 +31,6 @@ import { ChatSources, collectChatSources } from './ChatSources'; import { ChatTranscript } from './ChatTranscript'; import { ModelControls } from './ModelControls'; import { Logo } from '@/components/ui/Logo'; -import { - beginNoteDiffReview, - endNoteDiffReview, - resolveNoteDiffReview, -} from '@/components/Notebook/NoteReview/noteDiffOverlay'; type PanelTab = 'chat' | 'sources'; @@ -76,107 +69,6 @@ function noticeFromOutcome(outcome: SendOutcome & { ok: false }): ComposerNotice } } -/** Highest note version produced by a succeeded edit_note in one chat. */ -function maxAgentNoteVersion(chat: NotebookChat | null): number | null { - let max: number | null = null; - for (const execution of chat?.executions ?? []) { - for (const item of execution.activity ?? []) { - if (item.type === 'tool_call' && item.note_version_id != null) { - max = max == null ? item.note_version_id : Math.max(max, item.note_version_id); - } - } - } - return max; -} - -/** Parse a version's JSON document, falling back to its HTML source. */ -function parseVersionContent( - contentJson: string | undefined, - contentSrc: string | undefined -): string | object { - if (contentJson) { - try { - return JSON.parse(contentJson); - } catch { - // Malformed JSON — fall back to the HTML source. - } - } - return contentSrc ?? ''; -} - -/** - * Fetch the version a reload should apply: the pinned version when the banner - * promised a specific assistant version, otherwise the note's latest. Both - * serializations come back — which one the editor gets is decided against its - * schema (see parseVersionForEditor). - */ -async function fetchReloadContent( - noteId: string, - pinnedVersionId: number | null -): Promise<{ contentJson?: string; contentSrc?: string; versionId: number | null }> { - if (pinnedVersionId != null) { - const version = await NoteService.getNoteVersion(pinnedVersionId); - return { contentJson: version.json, contentSrc: version.src, versionId: pinnedVersionId }; - } - const note = await NoteService.getNote(noteId); - return { - contentJson: note.contentJson, - contentSrc: note.content, - versionId: note.versionId ?? null, - }; -} - -/** - * Applying a fetched version isn't a user edit — emitUpdate=false so it never - * triggers the notebook autosave. - */ -function applyEditorContent(editor: Editor | null, content: string | object): void { - if (!editor || editor.isDestroyed) return; - editor.commands.setContent(content, { emitUpdate: false }); -} - -/** Parse fetched version content into a schema node for diffing; null when unparseable. */ -function parseIncomingNode(editor: Editor, content: string | object): ProseMirrorNode | null { - try { - if (typeof content === 'object') return editor.schema.nodeFromJSON(content); - const container = document.createElement('div'); - container.innerHTML = content; - return ProseMirrorDOMParser.fromSchema(editor.schema).parse(container); - } catch { - return null; - } -} - -/** - * Parse a version for the editor: the schema node for diffing plus the - * content a verbatim apply should use. JSON the schema rejects — an unknown - * node from a newer serializer, say — falls back to the HTML source, which - * the DOM parser degrades around where nodeFromJSON refuses outright. - */ -function parseVersionForEditor( - editor: Editor, - contentJson: string | undefined, - contentSrc: string | undefined -): { node: ProseMirrorNode | null; content: string | object } { - const content = parseVersionContent(contentJson, contentSrc); - const node = parseIncomingNode(editor, content); - if (node != null || typeof content === 'string' || !contentSrc) return { node, content }; - return { node: parseIncomingNode(editor, contentSrc), content: contentSrc }; -} - -/** - * Verbatim-apply a fetched version: JSON when the schema accepts it, else - * the HTML source (see parseVersionForEditor). Never a user edit. - */ -function applyVersionContent( - editor: Editor | null, - contentJson: string | undefined, - contentSrc: string | undefined -): void { - if (!editor || editor.isDestroyed) return; - applyEditorContent(editor, parseVersionForEditor(editor, contentJson, contentSrc).content); -} - /** * A running in-note review session, handed to the host so the accept/reject * controls can live on the note page rather than in the chat panel. @@ -492,302 +384,21 @@ export function AgentChatPanel({ setRenaming(false); }, [noteId]); - // ---- note refresh when the agent edits the note ---- - const heldVersionRef = useRef(null); - const [noteReloadFailed, setNoteReloadFailed] = useState(false); - // A choice-persisting save failed — the editor shows what the user picked, - // but the server's newest version is still someone else's. - const [persistFailed, setPersistFailed] = useState(false); - const [isPersisting, setIsPersisting] = useState(false); - // Owner token of the running choice-persisting save, mirroring - // reloadLockRef: cleanup runs only while still owned, so a stale settle - // (previous note) can't re-enable the banner buttons under a newer save. - const persistLockRef = useRef(null); - const [isReloadingNote, setIsReloadingNote] = useState(false); - /** - * Owner token of the running reload/review fetch. Callers take the lock by - * storing a fresh object and clean up only while they still own it, so a - * stale settle (previous note, superseded request) can neither free a newer - * request's lock nor stop its spinner. - */ - const reloadLockRef = useRef(null); - - // Newest agent-authored note version heard from any source — the note - // version socket, the selected chat's activity, or the reconnect probe. - // The ref is what comparisons read; the state is what re-runs the effect. - const latestAgentVersionRef = useRef(null); - const [agentVersionSignal, setAgentVersionSignal] = useState(null); - - // Newest version known to exist server-side, whoever wrote it. A pinned - // reload compares against this to tell whether the version it applied is - // still the server's newest — if not, the applied choice must be persisted - // or it would vanish on the next load (see reloadNoteContent). - const serverHeadRef = useRef(null); - - const recordServerHead = useCallback((versionId: number) => { - serverHeadRef.current = Math.max(serverHeadRef.current ?? 0, versionId); - }, []); - - const recordAgentVersion = useCallback( - (versionId: number) => { - recordServerHead(versionId); - const prev = latestAgentVersionRef.current; - if (prev != null && versionId <= prev) return; - latestAgentVersionRef.current = versionId; - setAgentVersionSignal(versionId); - }, - [recordServerHead] + // ---- assistant-version review of the note ---- + // The editor is the notebook's own; the hook keeps it in step with the + // versions the assistant writes and hands back the review to render. + const loadedNote = useMemo( + () => (currentNote ? { id: currentNote.id, versionId: currentNote.versionId } : null), + [currentNote] ); - - // In-note review: the editor document becomes the merge of both versions — - // the assistant's version with the overwritten content spliced back in as - // struck, still-editable text. Everything stays editable; Accept/Reject - // resolve positionally, so edits made during the review survive with the - // section they touched. - const [review, setReview] = useState<{ - versionId: number; - changeCount: number; - } | null>(null); - // Identity of the live review. Change-count callbacks arrive on microtasks - // and can outlive the review that scheduled them (an overlay folded into a - // newer one, or just resolved) — a bump makes every earlier callback stale. - const reviewEpochRef = useRef(0); - // Re-runs the auto-review effect once a fetch lock frees, so a version that - // arrived while another was being fetched still gets reviewed. - const [reviewNudge, setReviewNudge] = useState(0); - // A version whose review fetch failed — retried via the banner or a newer - // version, never auto-looped by the nudge. - const lastFailedReviewVersionRef = useRef(null); - - // A new note's version stream starts clean — signals recorded for the - // previous note must never compare against the new note's held version. - useEffect(() => { - latestAgentVersionRef.current = null; - serverHeadRef.current = null; - lastFailedReviewVersionRef.current = null; - setAgentVersionSignal(null); - // Release the previous note's reload and persist locks: its fetches must - // not block this note's first refresh (a blocked signal never re-fires) - // or keep its banner buttons disabled, and once disowned their settles - // won't touch the spinners either. - reloadLockRef.current = null; - setIsReloadingNote(false); - persistLockRef.current = null; - setIsPersisting(false); - }, [noteId]); - - // The overlay lives on the editor instance, and mid-review the document - // holds merged content — fold it to the accept-projection (the same thing - // saves have been persisting) when the note or editor goes away mid-review. - useEffect(() => { - setReview(null); - return () => { - reviewEpochRef.current++; - resolveNoteDiffReview(editor, 'accept'); - }; - }, [editor, noteId]); - - useEffect(() => { - heldVersionRef.current = currentNote?.versionId ?? null; - setNoteReloadFailed(false); - setPersistFailed(false); - }, [currentNote?.id, currentNote?.versionId]); - - /** - * Escape hatch for when building the in-note review fails: fetch the - * assistant's version and apply it verbatim, no overlay. Fetches the exact - * promised version when it's known — fetching latest could return a newer - * local autosave that buried it, silently handing the user their own - * content back. - */ - const reloadNoteContent = useCallback(async () => { - if (reloadLockRef.current != null) return; - const lock = {}; - reloadLockRef.current = lock; - setIsReloadingNote(true); - setNoteReloadFailed(false); - setPersistFailed(false); - try { - const pinnedVersionId = latestAgentVersionRef.current; - const { - contentJson, - contentSrc, - versionId: nextVersionId, - } = await fetchReloadContent(noteId, pinnedVersionId); - // Navigated away mid-fetch: this content and version belong to the - // previous note and must not touch the current note's tracking. - if (targetRef.current.noteId !== noteId) return; - // A verbatim apply replaces the whole document; any half-merged review - // content goes with it, so the overlay must not outlive it. - reviewEpochRef.current++; - endNoteDiffReview(editor); - setReview(null); - if (nextVersionId != null) recordServerHead(nextVersionId); - applyVersionContent(editor, contentJson, contentSrc); - heldVersionRef.current = nextVersionId ?? heldVersionRef.current; - lastFailedReviewVersionRef.current = null; - // A pinned version older than the server head means newer saves buried - // the assistant's version. The editor now shows the chosen content, - // but applying it emitted no update — without a re-save the choice - // would silently vanish on the next load, so persist it now. - if (pinnedVersionId != null && (serverHeadRef.current ?? 0) > pinnedVersionId) { - const persisted = (await onPersistEditorState?.()) ?? true; - if (targetRef.current.noteId !== noteId) return; - if (!persisted) setPersistFailed(true); - } - } catch { - // Same stale-note guard as the success path: a failure from the - // previous note must not flash an error banner over the current one. - if (targetRef.current.noteId !== noteId) return; - setNoteReloadFailed(true); - } finally { - // Owner-only cleanup — see reloadLockRef. - if (reloadLockRef.current === lock) { - reloadLockRef.current = null; - setIsReloadingNote(false); - // A newer agent version may have landed while this ran. - setReviewNudge((nudge) => nudge + 1); - } - } - }, [noteId, editor, recordServerHead, onPersistEditorState]); - - /** - * A change-count report from the overlay: the user edited whole regions - * away (or a late microtask from a resolved review, which the epoch check - * drops). Zero left means the review resolved itself organically — the - * edits that did it were ordinary editor updates, already on their way to - * autosave. - */ - const handleLiveChangeCount = useCallback( - (epoch: number, count: number) => { - if (reviewEpochRef.current !== epoch) return; - if (count <= 0) { - reviewEpochRef.current++; - endNoteDiffReview(editor); - setReview(null); - return; - } - setReview((prev) => (prev == null ? prev : { ...prev, changeCount: count })); - }, - [editor] - ); - - /** - * Turn the newest agent version into an in-note review, immediately: the - * document becomes the assistant's version with whatever it overwrote — - * including unsaved local edits — spliced back in as struck, editable - * text. No banner, no interposed click; Accept/Reject (or just editing) - * resolve it. Runs whether the editor was clean or dirty, and folds an - * already-open review into the newer version. - */ - const startDiffReview = useCallback(async () => { - const pinnedVersionId = latestAgentVersionRef.current; - if (!editor || editor.isDestroyed || pinnedVersionId == null) return; - if (reloadLockRef.current != null) return; - const lock = {}; - reloadLockRef.current = lock; - setIsReloadingNote(true); - setNoteReloadFailed(false); - setPersistFailed(false); - try { - const version = await NoteService.getNoteVersion(pinnedVersionId); - // Same stale-note guard as reloadNoteContent. - if (targetRef.current.noteId !== noteId) return; - if (editor.isDestroyed) return; - recordServerHead(pinnedVersionId); - const { node: incoming, content } = parseVersionForEditor(editor, version.json, version.src); - const epoch = ++reviewEpochRef.current; - let changeCount = 0; - if (incoming) { - changeCount = beginNoteDiffReview(editor, incoming, { - onChangeCountUpdate: (count) => handleLiveChangeCount(epoch, count), - }); - } else { - // Neither serialization yielded a schema node — verbatim apply, - // letting setContent's own parser do what it can. - applyEditorContent(editor, content); - } - heldVersionRef.current = pinnedVersionId; - lastFailedReviewVersionRef.current = null; - setReview(changeCount > 0 ? { versionId: pinnedVersionId, changeCount } : null); - // Newer saves outrank the version just reviewed (an autosave buried - // it). Saves strip the struck ranges, so this persists the - // accept-projection — re-promoting the assistant's content to the - // server's newest without touching the open review. - if ((serverHeadRef.current ?? 0) > pinnedVersionId) { - const persisted = (await onPersistEditorState?.()) ?? true; - if (targetRef.current.noteId !== noteId) return; - if (editor.isDestroyed) return; - if (!persisted) setPersistFailed(true); - } - } catch (error) { - // The banner only says "couldn't load" — the cause (fetch, parse, - // schema) is visible nowhere but here. - console.error('Applying the assistant version failed', error); - if (targetRef.current.noteId !== noteId) return; - lastFailedReviewVersionRef.current = pinnedVersionId; - setNoteReloadFailed(true); - } finally { - // Owner-only cleanup — see reloadLockRef. - if (reloadLockRef.current === lock) { - reloadLockRef.current = null; - setIsReloadingNote(false); - // A newer agent version may have landed while this ran — nudge the - // auto-review effect now that the lock is free. - setReviewNudge((nudge) => nudge + 1); - } - } - }, [editor, noteId, recordServerHead, onPersistEditorState, handleLiveChangeCount]); - - /** - * Keep the assistant's side: delete the struck ranges, keep everything - * else — including anything typed during the review. The result is exactly - * what saves have been persisting all along, so no extra save is needed; - * edits made mid-review reached autosave as ordinary updates. - */ - const acceptReview = useCallback(() => { - reviewEpochRef.current++; - resolveNoteDiffReview(editor, 'accept'); - setReview(null); - }, [editor]); - - /** - * Keep the reader's side: delete the inserted ranges, keep everything else - * — struck content becomes plain again, and text typed inside it stays. - * The server's newest is the assistant's version, so persist immediately; - * the resolution itself emits no update and would otherwise never save. - */ - const rejectReview = useCallback(async () => { - if (!review) return; - const { versionId } = review; - reviewEpochRef.current++; - const resolved = resolveNoteDiffReview(editor, 'reject'); - setReview(null); - if (!resolved) return; - const persistLock = {}; - persistLockRef.current = persistLock; - setIsPersisting(true); - const noteAtCall = targetRef.current.noteId; - try { - const persisted = (await onPersistEditorState?.()) ?? true; - if (targetRef.current.noteId !== noteAtCall) return; - if (persisted) { - const held = heldVersionRef.current; - heldVersionRef.current = held == null ? versionId : Math.max(held, versionId); - setPersistFailed(false); - } else { - // The editor shows the user's choice, but the server's newest is - // still the assistant's version — say so instead of claiming done. - setPersistFailed(true); - } - } finally { - // Owner-only cleanup — see persistLockRef. - if (persistLockRef.current === persistLock) { - persistLockRef.current = null; - setIsPersisting(false); - } - } - }, [review, editor, onPersistEditorState]); + const noteReview = useNoteAgentReview({ + noteId, + editor, + loadedNote, + chat: chatState.chat, + onPersistEditorState, + }); + const { review, accept: acceptReview, reject: rejectReview } = noteReview; // The accept/reject controls render on the note page, next to the content // they decide about — hand the host the current session, and null when it @@ -802,105 +413,6 @@ export function AgentChatPanel({ return () => onReviewChange(null); }, [review, onReviewChange, acceptReview, rejectReview]); - // After a socket drop, events were missed — the head version says whether - // the newest commit is agent-authored and newer than what the editor holds, - // the one catch-up case this flow owns. An editor-authored head is our own - // (or another tab's) save, where local-wins is the long-standing behavior. - const probeNoteHead = useCallback(async () => { - try { - const note = await NoteService.getNote(noteId); - if (targetRef.current.noteId !== noteId) return; - if (note.versionId) recordServerHead(note.versionId); - if (note.versionCreatedVia === 'agent' && note.versionId) { - recordAgentVersion(note.versionId); - } - } catch { - // Advisory probe — the chat activity fallback still covers the - // selected chat, and any later event resyncs. - } - }, [noteId, recordServerHead, recordAgentVersion]); - - // The per-note version channel: the backend pushes ids whenever any writer - // commits a version, so agent edits surface no matter which chat (or tab) - // produced them. Editor-authored events are this editor's own autosave - // echoes — or another tab's, unchanged semantics — and system writers have - // their own refresh flows; both are ignored here. - useNoteVersionSocket({ - noteId, - enabled: true, - onEvent: (event) => { - if (event.type !== NOTE_VERSION_CREATED) return; - if (String(event.note_id) !== String(noteId)) return; - // Every event advances the known server head, whoever wrote it. - recordServerHead(event.version_id); - if (event.created_via !== 'agent') return; - recordAgentVersion(event.version_id); - }, - onReconnect: probeNoteHead, - }); - - // Belt and braces alongside the socket: the selected chat's activity also - // carries note_version_id on succeeded edit_note calls (REST stays the - // source of truth; the socket is droppable by contract). - const chatAgentVersion = useMemo(() => maxAgentNoteVersion(chatState.chat), [chatState.chat]); - useEffect(() => { - if (chatAgentVersion != null) recordAgentVersion(chatAgentVersion); - }, [chatAgentVersion, recordAgentVersion]); - - // A newer agent-authored version exists than what the editor holds: start - // (or fold into) an in-note review immediately, clean or dirty — the diff - // itself is the ask. The nudge re-runs this once a fetch lock frees; a - // version that already failed to load waits for the banner's retry. - useEffect(() => { - if (agentVersionSignal == null) return; - // The signal can outrun the note load during a note switch — held still - // belongs to the previous note until the current one lands. - if (currentNote == null || String(currentNote.id) !== String(noteId)) return; - if (reloadLockRef.current != null) return; - const latestAgent = latestAgentVersionRef.current; - const held = heldVersionRef.current; - if (latestAgent == null || held == null || latestAgent <= held) return; - const lastFailed = lastFailedReviewVersionRef.current; - if (lastFailed != null && latestAgent <= lastFailed) return; - startDiffReview(); - }, [agentVersionSignal, reviewNudge, currentNote, noteId, startDiffReview]); - - const persistCurrentDoc = async () => { - // A choice-persisting save failed and the banner offered a retry: the - // editor already shows what the user picked, so persisting it as the - // newest server version is all that's left. Acknowledge the assistant's - // version only once that save succeeds. - const persistLock = {}; - persistLockRef.current = persistLock; - setIsPersisting(true); - setNoteReloadFailed(false); - setPersistFailed(false); - const noteAtCall = targetRef.current.noteId; - // Captured with the payload: an agent version that lands while the save - // is in flight postdates what this save persists, and acknowledging it - // would let the auto-review guard skip its review. - const coveredAgentVersion = latestAgentVersionRef.current; - try { - const persisted = (await onPersistEditorState?.()) ?? true; - if (targetRef.current.noteId !== noteAtCall) return; - if (!persisted) { - setPersistFailed(true); - return; - } - const held = heldVersionRef.current; - if (coveredAgentVersion != null) { - heldVersionRef.current = - held == null ? coveredAgentVersion : Math.max(held, coveredAgentVersion); - } - } finally { - // Owner-only cleanup — see persistLockRef. - if (persistLockRef.current === persistLock) { - persistLockRef.current = null; - setIsPersisting(false); - } - } - }; - // ---- chat / sources tabs ---- const sources = useMemo(() => collectChatSources(chatState.chat), [chatState.chat]); const [activeTab, setActiveTab] = useState('chat'); @@ -1167,44 +679,7 @@ export function AgentChatPanel({ {activeTab === 'sources' ? : renderBody()} - {(noteReloadFailed || persistFailed) && review == null && ( -
-

- {persistFailed ? 'Couldn’t save the note.' : 'Couldn’t load the assistant’s update.'} -

-
- {persistFailed ? ( - - ) : ( - <> - - - - )} -
-
- )} + +

+ {persistFailed ? 'Couldn’t save the note.' : 'Couldn’t load the assistant’s update.'} +

+
+ {persistFailed ? ( + + ) : ( + <> + + + + )} +
+ + ); +} diff --git a/components/Notebook/NoteReview/useNoteAgentReview.ts b/components/Notebook/NoteReview/useNoteAgentReview.ts new file mode 100644 index 000000000..f8852d2f6 --- /dev/null +++ b/components/Notebook/NoteReview/useNoteAgentReview.ts @@ -0,0 +1,601 @@ +'use client'; + +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import type { Editor } from '@tiptap/core'; +import { DOMParser as ProseMirrorDOMParser, type Node as ProseMirrorNode } from '@tiptap/pm/model'; +import { useNoteVersionSocket } from '@/hooks/useNoteVersionSocket'; +import { NoteService } from '@/services/note.service'; +import { NOTE_VERSION_CREATED } from '@/types/note'; +import type { NotebookChat } from '@/types/notebookChat'; +import { beginNoteDiffReview, endNoteDiffReview, resolveNoteDiffReview } from './noteDiffOverlay'; + +/** Highest note version produced by a succeeded edit_note in one chat. */ +function maxAgentNoteVersion(chat: NotebookChat | null): number | null { + let max: number | null = null; + for (const execution of chat?.executions ?? []) { + for (const item of execution.activity ?? []) { + if (item.type === 'tool_call' && item.note_version_id != null) { + max = max == null ? item.note_version_id : Math.max(max, item.note_version_id); + } + } + } + return max; +} + +/** Parse a version's JSON document, falling back to its HTML source. */ +function parseVersionContent( + contentJson: string | undefined, + contentSrc: string | undefined +): string | object { + if (contentJson) { + try { + return JSON.parse(contentJson); + } catch { + // Malformed JSON — fall back to the HTML source. + } + } + return contentSrc ?? ''; +} + +/** + * Fetch the version a reload should apply: the pinned version when the banner + * promised a specific assistant version, otherwise the note's latest. Both + * serializations come back — which one the editor gets is decided against its + * schema (see parseVersionForEditor). + */ +async function fetchReloadContent( + noteId: string, + pinnedVersionId: number | null +): Promise<{ contentJson?: string; contentSrc?: string; versionId: number | null }> { + if (pinnedVersionId != null) { + const version = await NoteService.getNoteVersion(pinnedVersionId); + return { contentJson: version.json, contentSrc: version.src, versionId: pinnedVersionId }; + } + const note = await NoteService.getNote(noteId); + return { + contentJson: note.contentJson, + contentSrc: note.content, + versionId: note.versionId ?? null, + }; +} + +/** + * Applying a fetched version isn't a user edit — emitUpdate=false so it never + * triggers the notebook autosave. + */ +function applyEditorContent(editor: Editor | null, content: string | object): void { + if (!editor || editor.isDestroyed) return; + editor.commands.setContent(content, { emitUpdate: false }); +} + +/** Parse fetched version content into a schema node for diffing; null when unparseable. */ +function parseIncomingNode(editor: Editor, content: string | object): ProseMirrorNode | null { + try { + if (typeof content === 'object') return editor.schema.nodeFromJSON(content); + const container = document.createElement('div'); + container.innerHTML = content; + return ProseMirrorDOMParser.fromSchema(editor.schema).parse(container); + } catch { + return null; + } +} + +/** + * Parse a version for the editor: the schema node for diffing plus the + * content a verbatim apply should use. JSON the schema rejects — an unknown + * node from a newer serializer, say — falls back to the HTML source, which + * the DOM parser degrades around where nodeFromJSON refuses outright. + */ +function parseVersionForEditor( + editor: Editor, + contentJson: string | undefined, + contentSrc: string | undefined +): { node: ProseMirrorNode | null; content: string | object } { + const content = parseVersionContent(contentJson, contentSrc); + const node = parseIncomingNode(editor, content); + if (node != null || typeof content === 'string' || !contentSrc) return { node, content }; + return { node: parseIncomingNode(editor, contentSrc), content: contentSrc }; +} + +/** + * Verbatim-apply a fetched version: JSON when the schema accepts it, else + * the HTML source (see parseVersionForEditor). Never a user edit. + */ +function applyVersionContent( + editor: Editor | null, + contentJson: string | undefined, + contentSrc: string | undefined +): void { + if (!editor || editor.isDestroyed) return; + applyEditorContent(editor, parseVersionForEditor(editor, contentJson, contentSrc).content); +} + +/** A running in-note review: the assistant version under decision and its live change count. */ +export interface NoteAgentReview { + readonly versionId: number; + readonly changeCount: number; +} + +export interface UseNoteAgentReviewOptions { + /** The note the editor shows; null suspends everything. */ + readonly noteId: string | number | null; + /** The live editor instance for that note, once mounted. */ + readonly editor: Editor | null; + /** + * The note as loaded into the editor — its id and the version it was + * loaded with. The review compares agent versions against this held + * version, and a new load resets the comparison. + */ + readonly loadedNote: { readonly id: string | number; readonly versionId: number } | null; + /** + * The open chat, if any: its activity carries `note_version_id` on + * succeeded `edit_note` calls, a belt-and-braces signal beside the socket. + */ + readonly chat: NotebookChat | null; + /** + * Persist the editor's current document as a new server version, now, and + * resolve with whether it reached the server. Needed when the user chose + * "Keep mine" over an assistant version, or a reload applied an assistant + * version that newer saves had buried — applying content programmatically + * emits no editor update, so nothing else would save it. + */ + readonly onPersistEditorState?: () => Promise; +} + +export interface UseNoteAgentReviewResult { + readonly review: NoteAgentReview | null; + /** Keep the assistant's side: deletes the struck ranges, keeps the rest. */ + readonly accept: () => void; + /** Keep the reader's side: deletes the inserted ranges and persists it. */ + readonly reject: () => Promise; + /** Retry building the review for the newest assistant version. */ + readonly retryReview: () => Promise; + /** Escape hatch: apply the assistant version verbatim, no overlay. */ + readonly reloadWithoutReview: () => Promise; + /** Retry the choice-persisting save that failed. */ + readonly persistCurrentDoc: () => Promise; + /** The assistant's version couldn't be loaded or applied. */ + readonly reloadFailed: boolean; + /** The editor shows the user's choice but the save behind it failed. */ + readonly persistFailed: boolean; + readonly isReloading: boolean; + readonly isPersisting: boolean; +} + +/** + * Keeps an editor in step with the versions an assistant writes to its note. + * + * A newer agent-authored version — heard from the note's version socket, the + * open chat's activity, or a reconnect probe — becomes an in-note review + * immediately: the document turns into the merge of both versions with the + * overwritten content spliced back in as struck, still-editable text, and + * Accept/Reject (or simply editing) resolve it. Saves made meanwhile go + * through `noteDiffPersistableDoc`, so the server only ever sees the + * accept-projection. + * + * Shared by the notebook's chat panel and the AI Mode document pane; the host + * renders the controls and the failure banner wherever fits its layout. + */ +export function useNoteAgentReview({ + noteId, + editor, + loadedNote, + chat, + onPersistEditorState, +}: UseNoteAgentReviewOptions): UseNoteAgentReviewResult { + // Live mirror of the target note. Async continuations compare against it + // and discard results that raced a note switch instead of applying them to + // the note the editor now shows. + const noteIdRef = useRef(noteId); + noteIdRef.current = noteId; + + // ---- note refresh when the agent edits the note ---- + const heldVersionRef = useRef(null); + const [noteReloadFailed, setNoteReloadFailed] = useState(false); + // A choice-persisting save failed — the editor shows what the user picked, + // but the server's newest version is still someone else's. + const [persistFailed, setPersistFailed] = useState(false); + const [isPersisting, setIsPersisting] = useState(false); + // Owner token of the running choice-persisting save, mirroring + // reloadLockRef: cleanup runs only while still owned, so a stale settle + // (previous note) can't re-enable the banner buttons under a newer save. + const persistLockRef = useRef(null); + const [isReloadingNote, setIsReloadingNote] = useState(false); + /** + * Owner token of the running reload/review fetch. Callers take the lock by + * storing a fresh object and clean up only while they still own it, so a + * stale settle (previous note, superseded request) can neither free a newer + * request's lock nor stop its spinner. + */ + const reloadLockRef = useRef(null); + + // Newest agent-authored note version heard from any source — the note + // version socket, the selected chat's activity, or the reconnect probe. + // The ref is what comparisons read; the state is what re-runs the effect. + const latestAgentVersionRef = useRef(null); + const [agentVersionSignal, setAgentVersionSignal] = useState(null); + + // Newest version known to exist server-side, whoever wrote it. A pinned + // reload compares against this to tell whether the version it applied is + // still the server's newest — if not, the applied choice must be persisted + // or it would vanish on the next load (see reloadNoteContent). + const serverHeadRef = useRef(null); + + const recordServerHead = useCallback((versionId: number) => { + serverHeadRef.current = Math.max(serverHeadRef.current ?? 0, versionId); + }, []); + + const recordAgentVersion = useCallback( + (versionId: number) => { + recordServerHead(versionId); + const prev = latestAgentVersionRef.current; + if (prev != null && versionId <= prev) return; + latestAgentVersionRef.current = versionId; + setAgentVersionSignal(versionId); + }, + [recordServerHead] + ); + + // In-note review: the editor document becomes the merge of both versions — + // the assistant's version with the overwritten content spliced back in as + // struck, still-editable text. Everything stays editable; Accept/Reject + // resolve positionally, so edits made during the review survive with the + // section they touched. + const [review, setReview] = useState<{ + versionId: number; + changeCount: number; + } | null>(null); + // Identity of the live review. Change-count callbacks arrive on microtasks + // and can outlive the review that scheduled them (an overlay folded into a + // newer one, or just resolved) — a bump makes every earlier callback stale. + const reviewEpochRef = useRef(0); + // Re-runs the auto-review effect once a fetch lock frees, so a version that + // arrived while another was being fetched still gets reviewed. + const [reviewNudge, setReviewNudge] = useState(0); + // A version whose review fetch failed — retried via the banner or a newer + // version, never auto-looped by the nudge. + const lastFailedReviewVersionRef = useRef(null); + + // A new note's version stream starts clean — signals recorded for the + // previous note must never compare against the new note's held version. + useEffect(() => { + latestAgentVersionRef.current = null; + serverHeadRef.current = null; + lastFailedReviewVersionRef.current = null; + setAgentVersionSignal(null); + // Release the previous note's reload and persist locks: its fetches must + // not block this note's first refresh (a blocked signal never re-fires) + // or keep its banner buttons disabled, and once disowned their settles + // won't touch the spinners either. + reloadLockRef.current = null; + setIsReloadingNote(false); + persistLockRef.current = null; + setIsPersisting(false); + }, [noteId]); + + // The overlay lives on the editor instance, and mid-review the document + // holds merged content — fold it to the accept-projection (the same thing + // saves have been persisting) when the note or editor goes away mid-review. + useEffect(() => { + setReview(null); + return () => { + reviewEpochRef.current++; + resolveNoteDiffReview(editor, 'accept'); + }; + }, [editor, noteId]); + + useEffect(() => { + heldVersionRef.current = loadedNote?.versionId ?? null; + setNoteReloadFailed(false); + setPersistFailed(false); + }, [loadedNote?.id, loadedNote?.versionId]); + + /** + * Escape hatch for when building the in-note review fails: fetch the + * assistant's version and apply it verbatim, no overlay. Fetches the exact + * promised version when it's known — fetching latest could return a newer + * local autosave that buried it, silently handing the user their own + * content back. + */ + const reloadNoteContent = useCallback(async () => { + if (noteId == null || reloadLockRef.current != null) return; + const lock = {}; + reloadLockRef.current = lock; + setIsReloadingNote(true); + setNoteReloadFailed(false); + setPersistFailed(false); + try { + const pinnedVersionId = latestAgentVersionRef.current; + const { + contentJson, + contentSrc, + versionId: nextVersionId, + } = await fetchReloadContent(String(noteId), pinnedVersionId); + // Navigated away mid-fetch: this content and version belong to the + // previous note and must not touch the current note's tracking. + if (noteIdRef.current !== noteId) return; + // A verbatim apply replaces the whole document; any half-merged review + // content goes with it, so the overlay must not outlive it. + reviewEpochRef.current++; + endNoteDiffReview(editor); + setReview(null); + if (nextVersionId != null) recordServerHead(nextVersionId); + applyVersionContent(editor, contentJson, contentSrc); + heldVersionRef.current = nextVersionId ?? heldVersionRef.current; + lastFailedReviewVersionRef.current = null; + // A pinned version older than the server head means newer saves buried + // the assistant's version. The editor now shows the chosen content, + // but applying it emitted no update — without a re-save the choice + // would silently vanish on the next load, so persist it now. + if (pinnedVersionId != null && (serverHeadRef.current ?? 0) > pinnedVersionId) { + const persisted = (await onPersistEditorState?.()) ?? true; + if (noteIdRef.current !== noteId) return; + if (!persisted) setPersistFailed(true); + } + } catch { + // Same stale-note guard as the success path: a failure from the + // previous note must not flash an error banner over the current one. + if (noteIdRef.current !== noteId) return; + setNoteReloadFailed(true); + } finally { + // Owner-only cleanup — see reloadLockRef. + if (reloadLockRef.current === lock) { + reloadLockRef.current = null; + setIsReloadingNote(false); + // A newer agent version may have landed while this ran. + setReviewNudge((nudge) => nudge + 1); + } + } + }, [noteId, editor, recordServerHead, onPersistEditorState]); + + /** + * A change-count report from the overlay: the user edited whole regions + * away (or a late microtask from a resolved review, which the epoch check + * drops). Zero left means the review resolved itself organically — the + * edits that did it were ordinary editor updates, already on their way to + * autosave. + */ + const handleLiveChangeCount = useCallback( + (epoch: number, count: number) => { + if (reviewEpochRef.current !== epoch) return; + if (count <= 0) { + reviewEpochRef.current++; + endNoteDiffReview(editor); + setReview(null); + return; + } + setReview((prev) => (prev == null ? prev : { ...prev, changeCount: count })); + }, + [editor] + ); + + /** + * Turn the newest agent version into an in-note review, immediately: the + * document becomes the assistant's version with whatever it overwrote — + * including unsaved local edits — spliced back in as struck, editable + * text. No banner, no interposed click; Accept/Reject (or just editing) + * resolve it. Runs whether the editor was clean or dirty, and folds an + * already-open review into the newer version. + */ + const startDiffReview = useCallback(async () => { + const pinnedVersionId = latestAgentVersionRef.current; + if (!editor || editor.isDestroyed || pinnedVersionId == null) return; + if (reloadLockRef.current != null) return; + const lock = {}; + reloadLockRef.current = lock; + setIsReloadingNote(true); + setNoteReloadFailed(false); + setPersistFailed(false); + try { + const version = await NoteService.getNoteVersion(pinnedVersionId); + // Same stale-note guard as reloadNoteContent. + if (noteIdRef.current !== noteId) return; + if (editor.isDestroyed) return; + recordServerHead(pinnedVersionId); + const { node: incoming, content } = parseVersionForEditor(editor, version.json, version.src); + const epoch = ++reviewEpochRef.current; + let changeCount = 0; + if (incoming) { + changeCount = beginNoteDiffReview(editor, incoming, { + onChangeCountUpdate: (count) => handleLiveChangeCount(epoch, count), + }); + } else { + // Neither serialization yielded a schema node — verbatim apply, + // letting setContent's own parser do what it can. + applyEditorContent(editor, content); + } + heldVersionRef.current = pinnedVersionId; + lastFailedReviewVersionRef.current = null; + setReview(changeCount > 0 ? { versionId: pinnedVersionId, changeCount } : null); + // Newer saves outrank the version just reviewed (an autosave buried + // it). Saves strip the struck ranges, so this persists the + // accept-projection — re-promoting the assistant's content to the + // server's newest without touching the open review. + if ((serverHeadRef.current ?? 0) > pinnedVersionId) { + const persisted = (await onPersistEditorState?.()) ?? true; + if (noteIdRef.current !== noteId) return; + if (editor.isDestroyed) return; + if (!persisted) setPersistFailed(true); + } + } catch (error) { + // The banner only says "couldn't load" — the cause (fetch, parse, + // schema) is visible nowhere but here. + console.error('Applying the assistant version failed', error); + if (noteIdRef.current !== noteId) return; + lastFailedReviewVersionRef.current = pinnedVersionId; + setNoteReloadFailed(true); + } finally { + // Owner-only cleanup — see reloadLockRef. + if (reloadLockRef.current === lock) { + reloadLockRef.current = null; + setIsReloadingNote(false); + // A newer agent version may have landed while this ran — nudge the + // auto-review effect now that the lock is free. + setReviewNudge((nudge) => nudge + 1); + } + } + }, [editor, noteId, recordServerHead, onPersistEditorState, handleLiveChangeCount]); + + /** + * Keep the assistant's side: delete the struck ranges, keep everything + * else — including anything typed during the review. The result is exactly + * what saves have been persisting all along, so no extra save is needed; + * edits made mid-review reached autosave as ordinary updates. + */ + const acceptReview = useCallback(() => { + reviewEpochRef.current++; + resolveNoteDiffReview(editor, 'accept'); + setReview(null); + }, [editor]); + + /** + * Keep the reader's side: delete the inserted ranges, keep everything else + * — struck content becomes plain again, and text typed inside it stays. + * The server's newest is the assistant's version, so persist immediately; + * the resolution itself emits no update and would otherwise never save. + */ + const rejectReview = useCallback(async () => { + if (!review) return; + const { versionId } = review; + reviewEpochRef.current++; + const resolved = resolveNoteDiffReview(editor, 'reject'); + setReview(null); + if (!resolved) return; + const persistLock = {}; + persistLockRef.current = persistLock; + setIsPersisting(true); + const noteAtCall = noteIdRef.current; + try { + const persisted = (await onPersistEditorState?.()) ?? true; + if (noteIdRef.current !== noteAtCall) return; + if (persisted) { + const held = heldVersionRef.current; + heldVersionRef.current = held == null ? versionId : Math.max(held, versionId); + setPersistFailed(false); + } else { + // The editor shows the user's choice, but the server's newest is + // still the assistant's version — say so instead of claiming done. + setPersistFailed(true); + } + } finally { + // Owner-only cleanup — see persistLockRef. + if (persistLockRef.current === persistLock) { + persistLockRef.current = null; + setIsPersisting(false); + } + } + }, [review, editor, onPersistEditorState]); + + // After a socket drop, events were missed — the head version says whether + // the newest commit is agent-authored and newer than what the editor holds, + // the one catch-up case this flow owns. An editor-authored head is our own + // (or another tab's) save, where local-wins is the long-standing behavior. + const probeNoteHead = useCallback(async () => { + if (noteId == null) return; + try { + const note = await NoteService.getNote(String(noteId)); + if (noteIdRef.current !== noteId) return; + if (note.versionId) recordServerHead(note.versionId); + if (note.versionCreatedVia === 'agent' && note.versionId) { + recordAgentVersion(note.versionId); + } + } catch { + // Advisory probe — the chat activity fallback still covers the + // selected chat, and any later event resyncs. + } + }, [noteId, recordServerHead, recordAgentVersion]); + + // The per-note version channel: the backend pushes ids whenever any writer + // commits a version, so agent edits surface no matter which chat (or tab) + // produced them. Editor-authored events are this editor's own autosave + // echoes — or another tab's, unchanged semantics — and system writers have + // their own refresh flows; both are ignored here. + useNoteVersionSocket({ + noteId, + enabled: noteId != null, + onEvent: (event) => { + if (event.type !== NOTE_VERSION_CREATED) return; + if (String(event.note_id) !== String(noteId)) return; + // Every event advances the known server head, whoever wrote it. + recordServerHead(event.version_id); + if (event.created_via !== 'agent') return; + recordAgentVersion(event.version_id); + }, + onReconnect: probeNoteHead, + }); + + // Belt and braces alongside the socket: the selected chat's activity also + // carries note_version_id on succeeded edit_note calls (REST stays the + // source of truth; the socket is droppable by contract). + const chatAgentVersion = useMemo(() => maxAgentNoteVersion(chat), [chat]); + useEffect(() => { + if (chatAgentVersion != null) recordAgentVersion(chatAgentVersion); + }, [chatAgentVersion, recordAgentVersion]); + + // A newer agent-authored version exists than what the editor holds: start + // (or fold into) an in-note review immediately, clean or dirty — the diff + // itself is the ask. The nudge re-runs this once a fetch lock frees; a + // version that already failed to load waits for the banner's retry. + useEffect(() => { + if (agentVersionSignal == null) return; + // The signal can outrun the note load during a note switch — held still + // belongs to the previous note until the current one lands. + if (loadedNote == null || noteId == null || String(loadedNote.id) !== String(noteId)) return; + if (reloadLockRef.current != null) return; + const latestAgent = latestAgentVersionRef.current; + const held = heldVersionRef.current; + if (latestAgent == null || held == null || latestAgent <= held) return; + const lastFailed = lastFailedReviewVersionRef.current; + if (lastFailed != null && latestAgent <= lastFailed) return; + startDiffReview(); + }, [agentVersionSignal, reviewNudge, loadedNote, noteId, startDiffReview]); + + const persistCurrentDoc = useCallback(async () => { + // A choice-persisting save failed and the banner offered a retry: the + // editor already shows what the user picked, so persisting it as the + // newest server version is all that's left. Acknowledge the assistant's + // version only once that save succeeds. + const persistLock = {}; + persistLockRef.current = persistLock; + setIsPersisting(true); + setNoteReloadFailed(false); + setPersistFailed(false); + const noteAtCall = noteIdRef.current; + // Captured with the payload: an agent version that lands while the save + // is in flight postdates what this save persists, and acknowledging it + // would let the auto-review guard skip its review. + const coveredAgentVersion = latestAgentVersionRef.current; + try { + const persisted = (await onPersistEditorState?.()) ?? true; + if (noteIdRef.current !== noteAtCall) return; + if (!persisted) { + setPersistFailed(true); + return; + } + const held = heldVersionRef.current; + if (coveredAgentVersion != null) { + heldVersionRef.current = + held == null ? coveredAgentVersion : Math.max(held, coveredAgentVersion); + } + } finally { + // Owner-only cleanup — see persistLockRef. + if (persistLockRef.current === persistLock) { + persistLockRef.current = null; + setIsPersisting(false); + } + } + }, [onPersistEditorState]); + + return { + review, + accept: acceptReview, + reject: rejectReview, + retryReview: startDiffReview, + reloadWithoutReview: reloadNoteContent, + persistCurrentDoc, + reloadFailed: noteReloadFailed, + persistFailed, + isReloading: isReloadingNote, + isPersisting, + }; +} From bc07e152078a97753cfff2457e30b3bb5f255ea9 Mon Sep 17 00:00:00 2001 From: Kobe Attias Date: Sun, 6 Sep 2026 13:06:04 -0400 Subject: [PATCH 09/34] Editor: optional autofocus, no window.editor global, text menu respects read-only BlockEditor gains an autofocus prop (default: editable) so a second editor on the page, like the AI Mode document, doesn't steal focus from the chat composer. useBlockEditor no longer assigns window.editor, which clobbered the notebook's editor whenever another editor mounted; nothing read it. The text bubble menu no longer shows while the editor is read-only via setEditable(false). Co-Authored-By: Claude Fable 5.1 --- .../Editor/components/BlockEditor/BlockEditor.tsx | 4 ++++ .../menus/TextMenu/hooks/useTextmenuStates.ts | 2 +- components/Editor/hooks/useBlockEditor.ts | 12 ++++-------- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/components/Editor/components/BlockEditor/BlockEditor.tsx b/components/Editor/components/BlockEditor/BlockEditor.tsx index 40d71b876..374b34be2 100644 --- a/components/Editor/components/BlockEditor/BlockEditor.tsx +++ b/components/Editor/components/BlockEditor/BlockEditor.tsx @@ -19,6 +19,8 @@ export interface BlockEditorProps { onUpdate?: (editor: Editor) => void; editable?: boolean; setEditor?: (editor: Editor | null) => void; + /** Focus the editor on mount. Defaults to `editable`. */ + autofocus?: boolean; } export const BlockEditor: React.FC = ({ @@ -28,6 +30,7 @@ export const BlockEditor: React.FC = ({ setEditor, isLoading = false, editable = true, + autofocus, }) => { const menuContainerRef = useRef(null); @@ -36,6 +39,7 @@ export const BlockEditor: React.FC = ({ contentJson, onUpdate, editable, + autofocus, }); useEffect(() => { diff --git a/components/Editor/components/menus/TextMenu/hooks/useTextmenuStates.ts b/components/Editor/components/menus/TextMenu/hooks/useTextmenuStates.ts index b7f33e040..dd6405a05 100644 --- a/components/Editor/components/menus/TextMenu/hooks/useTextmenuStates.ts +++ b/components/Editor/components/menus/TextMenu/hooks/useTextmenuStates.ts @@ -29,7 +29,7 @@ export const useTextmenuStates = (editor: Editor) => { const shouldShow = useCallback( ({ view, from }: ShouldShowProps) => { - if (!view || editor.view.dragging) { + if (!view || editor.view.dragging || !editor.isEditable) { return false; } diff --git a/components/Editor/hooks/useBlockEditor.ts b/components/Editor/hooks/useBlockEditor.ts index fff0465b5..2b845ed7c 100644 --- a/components/Editor/hooks/useBlockEditor.ts +++ b/components/Editor/hooks/useBlockEditor.ts @@ -1,4 +1,3 @@ -import { useEffect } from 'react'; import { useEditor } from '@tiptap/react'; import type { AnyExtension, Editor } from '@tiptap/core'; import { Document } from '@tiptap/extension-document'; @@ -29,6 +28,7 @@ export const useBlockEditor = ({ onUpdate, customClass, includeTitle = false, + autofocus = editable, }: { aiToken?: string; userId?: string; @@ -39,13 +39,15 @@ export const useBlockEditor = ({ onUpdate?: (editor: Editor) => void; customClass?: string; includeTitle?: boolean; + /** Focus the editor on mount. Defaults to editable; false when another control owns focus. */ + autofocus?: boolean; }) => { const editor = useEditor( { editable, immediatelyRender: false, shouldRerenderOnTransaction: false, - autofocus: editable, + autofocus, extensions: [ ...ExtensionKit({ customDocument: editable ? CustomDocument : undefined, @@ -109,11 +111,5 @@ export const useBlockEditor = ({ [content, contentJson, editable, customClass, includeTitle] ); - useEffect(() => { - if (typeof window !== 'undefined' && editor) { - window.editor = editor; - } - }, [editor]); - return { editor }; }; From 0f39b4488ed5e10e4098eb9f17254a917ae74843 Mon Sep 17 00:00:00 2001 From: Kobe Attias Date: Sun, 6 Sep 2026 13:08:39 -0400 Subject: [PATCH 10/34] AI Mode: editable document with the notebook's in-note review The document pane now mounts the real editor editable, autosaving through useUpdateNote with the review's persistable projection, and runs useNoteAgentReview over it: every version the assistant writes is spliced into the live editor as highlighted insertions and struck removals with accept/reject controls, exactly as in the notebook. The editor is read-only while a turn is drafting or working, so a user edit can't make the assistant's next edit_note stale mid-turn, and editable once settled. The section badge counts headings in the live editor document. The note is loaded once per note for the editor's initial content; later versions reach it through the review rather than a reload. Below the tablet breakpoint the pane mounts in the drawer only, read-only, so a note never has two editors open at once. Co-Authored-By: Claude Fable 5.1 --- components/AIMode/AIModeOverlay.tsx | 19 +++- components/AIMode/DocumentPane.tsx | 152 +++++++++++++++++++++---- components/AIMode/useAIModeDocument.ts | 111 +++--------------- components/Editor/styles/index.css | 6 + 4 files changed, 163 insertions(+), 125 deletions(-) diff --git a/components/AIMode/AIModeOverlay.tsx b/components/AIMode/AIModeOverlay.tsx index 4af051745..5fe345bc2 100644 --- a/components/AIMode/AIModeOverlay.tsx +++ b/components/AIMode/AIModeOverlay.tsx @@ -48,7 +48,6 @@ export function AIModeOverlay() { const doc = useAIModeDocument({ note: state.note, - chat: state.chat.chat, latestExecution: state.chat.latestExecution, }); @@ -161,9 +160,11 @@ export function AIModeOverlay() { } /> - {showDocument && ( -