@@ -36,12 +31,7 @@ function TopBarSearchButtonInner({
className="flex items-center w-full md:!w-60 max-w-md mx-auto h-9 px-4 py-1.5 bg-gray-100 hover:bg-gray-200 rounded-full transition-colors text-left group"
>
-
- {displayText}
- {displayText}
-
+
Search
{shortcutLabel && (
@@ -53,17 +43,3 @@ function TopBarSearchButtonInner({
);
}
-
-function TopBarSearchButtonWithQuery(props: TopBarSearchButtonProps) {
- const pathname = usePathname();
- const searchParams = useSearchParams();
- const currentSearchQuery = pathname === '/search' ? searchParams.get('q') : null;
-
- return
;
-}
-
-export const TopBarSearchButton = (props: TopBarSearchButtonProps) => (
-
}>
-
-
-);
diff --git a/app/layouts/topbar/pageRoutes.tsx b/app/layouts/topbar/pageRoutes.tsx
index 7e6d7ca79..dc3f5306e 100644
--- a/app/layouts/topbar/pageRoutes.tsx
+++ b/app/layouts/topbar/pageRoutes.tsx
@@ -4,7 +4,6 @@ import {
faBookmark as faBookmarkLight,
faCommentsQuestion,
faGrid3 as faGrid3Light,
- faMagnifyingGlass,
} from '@fortawesome/pro-light-svg-icons';
import { ChartNoAxesColumnIncreasing, Shield, Hash, Users, Activity, Settings } from 'lucide-react';
import Image from 'next/image';
@@ -62,13 +61,6 @@ const ROUTE_RULES: RouteRule[] = [
icon:
,
}),
},
- {
- match: (p) => p === '/search',
- getInfo: () => ({
- title: 'Search',
- icon:
,
- }),
- },
{
match: (p) => p === '/notifications',
getInfo: () => ({
diff --git a/app/peer-review/ReviewsPageContent.tsx b/app/peer-review/ReviewsPageContent.tsx
index 4f766f74f..33e747590 100644
--- a/app/peer-review/ReviewsPageContent.tsx
+++ b/app/peer-review/ReviewsPageContent.tsx
@@ -2,7 +2,7 @@
import { BountyFeedItem } from '@/components/Bounty/BountyFeedItem';
import { FeedContent } from '@/components/Feed/FeedContent';
-import { FeedSortDropdown } from '@/components/Feed/FeedSortDropdown';
+import { SortMenu } from '@/components/ui/SortMenu';
import { useBounties } from '@/hooks/useBounties';
const SORT_OPTIONS = [
@@ -34,7 +34,7 @@ export function ReviewsPageContent() {
ordering={sort}
filters={
-
+
}
skeletonVariant="proposalWork"
diff --git a/app/search/SearchPageContent.tsx b/app/search/SearchPageContent.tsx
deleted file mode 100644
index 035fb0b56..000000000
--- a/app/search/SearchPageContent.tsx
+++ /dev/null
@@ -1,185 +0,0 @@
-'use client';
-
-import { useState, useEffect, useMemo } from 'react';
-import { useRouter, useSearchParams } from 'next/navigation';
-import { SearchSortControls } from '@/components/Search/SearchSortControls';
-import { SearchEmptyState } from '@/components/Search/SearchEmptyState';
-import { useSearch } from '@/hooks/useSearch';
-import { PageLayout } from '@/app/layouts/PageLayout';
-import { MainPageHeader } from '@/components/ui/MainPageHeader';
-import { Search as SearchIcon } from 'lucide-react';
-import { FeedContent } from '@/components/Feed/FeedContent';
-
-interface SearchPageContentProps {
- readonly searchParams: {
- readonly q?: string;
- readonly tab?: string;
- readonly sort?: string;
- readonly page?: string;
- readonly [key: string]: string | undefined;
- };
-}
-
-export function SearchPageContent({ searchParams }: SearchPageContentProps) {
- const router = useRouter();
- const urlSearchParams = useSearchParams();
- const [query, setQuery] = useState(searchParams.q || '');
- const [hasSearched, setHasSearched] = useState(false);
-
- const {
- entries,
- isLoading,
- isLoadingMore,
- error,
- hasMore,
- count,
- loadMore,
- stagedFilters,
- sortBy,
- setSortBy,
- search,
- } = useSearch({ pageSize: 40 });
-
- // Initialize from URL params and perform initial search
- useEffect(() => {
- // Perform initial search only when component mounts or URL query changes
- if (searchParams.q?.trim()) {
- setQuery(searchParams.q);
- setHasSearched(true);
- search(searchParams.q, 'documents');
-
- // Scroll to top when query changes (e.g., from shift+enter in modal)
- // Use double requestAnimationFrame to ensure DOM is ready after navigation
- requestAnimationFrame(() => {
- requestAnimationFrame(() => {
- // Try to find the scroll container used by PageLayout
- // The scroll container has overflow-y-auto and flex-1 classes
- const scrollContainer = document.querySelector(
- '.flex-1.flex.flex-col.overflow-y-auto'
- ) as HTMLElement;
- if (scrollContainer) {
- scrollContainer.scrollTo({ top: 0, behavior: 'smooth' });
- } else {
- // Fallback to window scroll
- window.scrollTo({ top: 0, behavior: 'smooth' });
- }
- });
- });
- }
- }, [searchParams.q]);
-
- const handleSearch = (searchQuery: string) => {
- setQuery(searchQuery);
-
- // Trigger the actual search
- if (searchQuery.trim()) {
- setHasSearched(true);
- search(searchQuery, 'documents');
- }
-
- // Update URL
- const newParams = new URLSearchParams(urlSearchParams);
- if (searchQuery.trim()) {
- newParams.set('q', searchQuery);
- } else {
- newParams.delete('q');
- }
- router.push(`/search?${newParams.toString()}`);
- };
-
- const header = (
-
- }
- title="Search"
- subtitle="Find papers, grants, authors, and peer reviews"
- showTitle={false}
- />
-
- );
-
- const resultsHeader = query.trim() && (
-
-
- {!isLoading && count > 0 ? (
- <>{`${count.toLocaleString()} ${count === 1 ? 'result found.' : 'results found.'}`}>
- ) : (
- No results found.
- )}
-
-
-
- );
-
- // Show a blank page (no default search UI) if no query
- if (!query.trim()) {
- return (
-
- {header}
-
- );
- }
-
- // Show error state if API failed
- if (!isLoading && error && query.trim()) {
- return (
-
- {header}
-
-
- {error ?? 'Something went wrong while fetching results. Please try again.'}
-
-
-
- );
- }
-
- // Empty state is now handled by FeedContent's noEntriesElement prop
-
- return (
-
- {header}
-
-
-
- {/* Main content */}
-
- {resultsHeader}
-
- {/* Use FeedContent for consistent rendering and infinite scroll */}
- {hasSearched && (
- {}}
- />
- }
- />
- )}
-
-
-
-
- );
-}
diff --git a/app/search/page.tsx b/app/search/page.tsx
deleted file mode 100644
index c96abed38..000000000
--- a/app/search/page.tsx
+++ /dev/null
@@ -1,31 +0,0 @@
-import { Metadata } from 'next';
-import { redirect } from 'next/navigation';
-import { SearchPageContent } from './SearchPageContent';
-
-export const metadata: Metadata = {
- title: 'Search | ResearchHub',
- description:
- 'Search papers, grants, authors, and peer reviews on ResearchHub. Find the latest research with advanced filtering and sorting options.',
- keywords: 'search, research, papers, grants, authors, peer review, academic search',
- openGraph: {
- title: 'Search ResearchHub',
- description: 'Search papers, grants, authors, and peer reviews on ResearchHub.',
- type: 'website',
- },
-};
-
-interface SearchPageProps {
- readonly searchParams: Promise<{
- readonly q?: string;
- readonly tab?: string;
- readonly sort?: string;
- readonly page?: string;
- readonly [key: string]: string | undefined;
- }>;
-}
-
-export default async function SearchPage({ searchParams }: SearchPageProps) {
- const params = await searchParams;
-
- return
;
-}
diff --git a/components/AIMode/AIModeContext.tsx b/components/AIMode/AIModeContext.tsx
new file mode 100644
index 000000000..f5c7188e4
--- /dev/null
+++ b/components/AIMode/AIModeContext.tsx
@@ -0,0 +1,157 @@
+'use client';
+
+import {
+ createContext,
+ Suspense,
+ useCallback,
+ useContext,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+ type ReactNode,
+} from 'react';
+import dynamic from 'next/dynamic';
+import { usePathname, useSearchParams } from 'next/navigation';
+
+/** `?ai=1` opens the overlay; `?ai=1&aiChat=
` 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 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;
+ // Native history, not router.replace: the app router keeps
+ // useSearchParams in sync with it, and unlike a router navigation it
+ // neither re-fetches nor re-renders the page behind the overlay.
+ window.history.replaceState(
+ window.history.state,
+ '',
+ `${pathnameRef.current}${query ? `?${query}` : ''}${hash}`
+ );
+ setState(next);
+ }, []);
+
+ 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..c72baac15
--- /dev/null
+++ b/components/AIMode/AIModeOverlay.tsx
@@ -0,0 +1,361 @@
+'use client';
+
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { createPortal } from 'react-dom';
+import { PanelRight, Sparkles, X } from 'lucide-react';
+import { cn } from '@/utils/styles';
+import { ResizeHandle } from '@/components/ui/ResizeHandle';
+import { SwipeableDrawer } from '@/components/ui/SwipeableDrawer';
+import { useMediaQuery } from '@/hooks/useMediaQuery';
+import { useResizableWidth } from '@/hooks/useResizableWidth';
+import { useAIMode } from './AIModeContext';
+import { ChatPane } from './ChatPane';
+import { ConversationList } from './ConversationList';
+import { DocumentCard } from './DocumentCard';
+import { DocumentPane } from './DocumentPane';
+import { useAIModeChat } from './useAIModeChat';
+import type { NotebookTab } from '@/components/Notebook/NotebookTabs';
+import type { PublishingDefaultArticleType } from '@/contexts/PublishingHostContext';
+import { useAIModeDocument } from './useAIModeDocument';
+import { AI_MODE_NAME } from './copy';
+
+/** Above the overlay (9500), below BaseModal (9999). */
+const AI_MODE_DRAWER_Z_INDEX = 9600;
+
+const LIST_MIN_WIDTH = 200;
+const LIST_MAX_WIDTH = 440;
+const LIST_DEFAULT_WIDTH = 264;
+const CHAT_MIN_WIDTH = 360;
+const DOCUMENT_MIN_WIDTH = 420;
+/** The details form needs more room than the document: authors, image, funding fields. */
+const DETAILS_MIN_WIDTH = 560;
+/** Share of the viewport the document opens at before the user drags it. */
+const DOCUMENT_DEFAULT_SHARE = 0.55;
+
+/**
+ * A modal that portals outside the overlay (BaseModal, a drawer) is showing.
+ * Closed drawers stay mounted off-screen with `role="dialog"`, so presence in
+ * the DOM is not enough — the box has to intersect the viewport.
+ */
+function isForeignDialogOpen(): boolean {
+ const overlay = document.getElementById('ai-mode-overlay');
+ return Array.from(document.querySelectorAll('[role="dialog"]')).some((el) => {
+ if (overlay?.contains(el)) return false;
+ const rect = el.getBoundingClientRect();
+ return (
+ rect.width > 0 &&
+ rect.height > 0 &&
+ rect.bottom > 0 &&
+ rect.right > 0 &&
+ rect.top < window.innerHeight &&
+ rect.left < window.innerWidth
+ );
+ });
+}
+
+/**
+ * The full-viewport shell: header, conversation list, chat, document. Sits
+ * below BaseModal (9999) and Tooltip (10000) so real modals and tooltips
+ * opened from inside it still render on top.
+ */
+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), []);
+
+ const doc = useAIModeDocument({
+ note: state.note,
+ chat: state.chat.chat,
+ latestExecution: state.chat.latestExecution,
+ });
+
+ // Tailwind's `tablet` breakpoint; the drawer only exists below it.
+ const isBelowTablet = useMediaQuery('(max-width: 767px)') === true;
+
+ // ---- pane widths, claude.ai style: both side panes drag, the chat takes the rest ----
+ const [viewportWidth, setViewportWidth] = useState(() =>
+ typeof window === 'undefined' ? 1440 : window.innerWidth
+ );
+ useEffect(() => {
+ const update = () => setViewportWidth(window.innerWidth);
+ update();
+ window.addEventListener('resize', update);
+ return () => window.removeEventListener('resize', update);
+ }, []);
+ const listWidth = useResizableWidth({
+ storageKey: 'ai-mode:list-width',
+ min: LIST_MIN_WIDTH,
+ max: LIST_MAX_WIDTH,
+ defaultWidth: LIST_DEFAULT_WIDTH,
+ anchor: 'left',
+ });
+ // Document or details in the right pane; details wants a wider floor.
+ const [documentTab, setDocumentTab] = useState('document');
+ const documentMinWidth = documentTab === 'details' ? DETAILS_MIN_WIDTH : DOCUMENT_MIN_WIDTH;
+ // The document may grow until the chat is down to its minimum column.
+ const documentMaxWidth = Math.max(
+ documentMinWidth,
+ viewportWidth - listWidth.width - CHAT_MIN_WIDTH
+ );
+ const documentWidth = useResizableWidth({
+ storageKey: 'ai-mode:document-width',
+ min: documentMinWidth,
+ max: documentMaxWidth,
+ defaultWidth: (width) => width * DOCUMENT_DEFAULT_SHARE,
+ anchor: 'right',
+ });
+ const isBelowTabletRef = useRef(isBelowTablet);
+ isBelowTabletRef.current = isBelowTablet;
+
+ // On desktop the document pane opens by itself the moment a conversation
+ // gains a note. On mobile it never opens by itself — the card in the
+ // transcript is the way in, and it opens a drawer. Either way the user can
+ // close it and reopen it from the card or the chat header.
+ const noteId = state.note?.id ?? null;
+ const [documentOpen, setDocumentOpen] = useState(false);
+ useEffect(() => {
+ setDocumentOpen(noteId != null && !isBelowTabletRef.current);
+ setDocumentTab('document');
+ }, [noteId]);
+
+ // What the conversation set out to write, from its opening message, so the
+ // details form preselects the matching work type for a note that has none.
+ const defaultArticleType = useMemo(() => {
+ const opening = state.chat.chat?.messages.find((message) => message.role === 'user')?.content;
+ if (!opening) return null;
+ if (/request for proposals|\bRFP\b/i.test(opening)) return 'grant';
+ if (/proposal/i.test(opening)) return 'preregistration';
+ return null;
+ }, [state.chat.chat?.messages]);
+ const openDocument = useCallback(() => setDocumentOpen(true), []);
+ const closeDocument = useCallback(() => setDocumentOpen(false), []);
+ const showDocument = noteId != null && documentOpen;
+
+ // The turn that created the document, for seating its card in the transcript.
+ const documentCardExecutionId = useMemo(() => {
+ if (noteId == null) return null;
+ for (const execution of state.chat.chat?.executions ?? []) {
+ const created = (execution.activity ?? []).some(
+ (item) =>
+ item.type === 'tool_call' &&
+ item.tool === 'create_note' &&
+ item.status === 'succeeded' &&
+ item.note_id === noteId
+ );
+ if (created) return execution.id;
+ }
+ return null;
+ }, [noteId, state.chat.chat]);
+ const documentCard =
+ noteId != null ? (
+
+ ) : null;
+
+ // Esc closes, unless something inside already claimed it (a menu, a modal
+ // that portals outside the overlay).
+ useEffect(() => {
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (event.key !== 'Escape' || event.defaultPrevented) return;
+ if (isForeignDialogOpen()) return;
+ close();
+ };
+ document.addEventListener('keydown', handleKeyDown);
+ return () => document.removeEventListener('keydown', handleKeyDown);
+ }, [close]);
+
+ // A real modal: the overlay portals to the body and everything else at
+ // the top level goes inert while it is open, so nothing behind it — a
+ // notebook editor that autofocuses late, say — can take focus or keys.
+ // Layers that mount later (menus, tooltips, modals) append after and stay
+ // live; the overlay's own drawers render inside it.
+ const [rootEl, setRootEl] = useState(null);
+ useEffect(() => {
+ if (!rootEl) return;
+ rootEl.focus();
+ const inerted: Element[] = [];
+ for (const child of Array.from(document.body.children)) {
+ if (child === rootEl || child.tagName === 'SCRIPT' || child.tagName === 'NEXTJS-PORTAL') {
+ continue;
+ }
+ if (child.hasAttribute('inert')) continue;
+ child.setAttribute('inert', '');
+ inerted.push(child);
+ }
+ return () => {
+ for (const child of inerted) child.removeAttribute('inert');
+ };
+ }, [rootEl]);
+
+ // Lock the page behind the overlay.
+ useEffect(() => {
+ const previous = document.body.style.overflow;
+ document.body.style.overflow = 'hidden';
+ return () => {
+ document.body.style.overflow = previous;
+ };
+ }, []);
+
+ const conversationList = (
+ {
+ state.selectChat(chatId);
+ closeListDrawer();
+ }}
+ onNew={() => {
+ state.startNewChat();
+ closeListDrawer();
+ }}
+ onRename={state.rename}
+ onDelete={state.deleteChat}
+ loadNotes={state.notesForChat}
+ onRetry={state.list.refresh}
+ />
+ );
+
+ return createPortal(
+
+
+
+
+ {AI_MODE_NAME}
+
+
+ Esc to close
+
+
+
+
+
+
+
+ setListDrawerOpen(true)}
+ documentCard={documentCard}
+ documentCardExecutionId={documentCardExecutionId}
+ headerActions={
+ noteId != null && (
+
+ )
+ }
+ />
+
+ {/* One editor per note at a time: the pane mounts in the column above
+ the tablet breakpoint and in the drawer below it, never both. */}
+ {showDocument && !isBelowTablet && (
+
+ )}
+
+
+ {/* Drawers portal to the body, so they need to stack above this overlay
+ (z-9500) while staying under BaseModal (9999). */}
+
+ {conversationList}
+
+
+ {showDocument && isBelowTablet && (
+
+ )}
+
+
,
+ document.body
+ );
+}
diff --git a/components/AIMode/ChatPane.tsx b/components/AIMode/ChatPane.tsx
new file mode 100644
index 000000000..4ad066b62
--- /dev/null
+++ b/components/AIMode/ChatPane.tsx
@@ -0,0 +1,304 @@
+'use client';
+
+import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react';
+import { Menu } from 'lucide-react';
+import { ChatComposer } from '@/components/AgentChat/ChatComposer';
+import { ChatTranscript } from '@/components/AgentChat/ChatTranscript';
+import { JumpToLatestButton } from '@/components/AgentChat/JumpToLatestButton';
+import { ModelControls } from '@/components/AgentChat/ModelControls';
+import { useJumpToLatest } from '@/hooks/useJumpToLatest';
+import { ConversationMenu } from './ConversationMenu';
+import { ConversationTitleField } from './ConversationTitleField';
+import { Button } from '@/components/ui/Button';
+import { ChatTranscriptSkeleton } from '@/components/skeletons/AIModeSkeleton';
+import { Logo } from '@/components/ui/Logo';
+import { cn } from '@/utils/styles';
+import type { AIModeChatState } from './useAIModeChat';
+import { aiModeGreeting, AI_MODE_STARTER_PROMPTS } from './copy';
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
+import { useUser } from '@/contexts/UserContext';
+
+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 document's card, and the turn it belongs under. With no matching
+ * turn (activity not loaded for it) the card trails the transcript instead.
+ */
+ readonly documentCard?: ReactNode;
+ readonly documentCardExecutionId?: number | null;
+}
+
+/** The middle pane: transcript, live progress, and the composer. */
+export function ChatPane({
+ state,
+ headerActions,
+ onOpenConversations,
+ documentCard,
+ documentCardExecutionId,
+}: 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, and offers a jump back instead.
+ const { scrollRef, handleScroll, isAtBottom, jumpToLatest, follow } =
+ useJumpToLatest({ resetKey: chatId });
+ useEffect(() => {
+ follow();
+ }, [chat.chat, chat.pendingSend, follow]);
+ // Text types out over many frames without the chat changing, so follow the
+ // content's own growth too.
+ const contentRef = useRef(null);
+ useEffect(() => {
+ const el = contentRef.current;
+ if (!el || typeof ResizeObserver === 'undefined') return;
+ const observer = new ResizeObserver(() => follow());
+ observer.observe(el);
+ return () => observer.disconnect();
+ }, [follow, chatId]);
+
+ // ---- title: inline rename from the header menu ----
+ const [renaming, setRenaming] = useState(false);
+ useEffect(() => {
+ setRenaming(false);
+ }, [chatId]);
+
+ // A starter card is a complete first message: send it and start the
+ // conversation rather than leaving it in the box to be sent by hand.
+ const { user } = useUser();
+ const startFromCard = useCallback(
+ (message: string) => {
+ state.clearNotice();
+ void state.sendText(message);
+ },
+ [state]
+ );
+
+ 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;
+
+ // The listing usually knows the title before the chat itself has loaded,
+ // so a refresh doesn't flash "Untitled" while the transcript is fetched.
+ const listedTitle =
+ chatId == null ? null : (list.chats.find((item) => item.id === chatId)?.title ?? null);
+ const currentTitle =
+ chatId == null ? null : state.titleFor(chatId, chat.chat?.title ?? listedTitle);
+ const titleLoading =
+ chatId != null && currentTitle == null && (chat.chat == null || list.access === 'loading');
+ const title =
+ chatId == null ? 'New conversation' : (currentTitle?.trim() ?? '') || 'Untitled conversation';
+
+ const composer = (
+
+ }
+ />
+ );
+
+ return (
+
+
+ {onOpenConversations && (
+
+ )}
+ {renaming && chatId != null ? (
+ setRenaming(false)}
+ onCommit={(value) => {
+ setRenaming(false);
+ const next = value.trim();
+ if (next && next !== (currentTitle ?? '')) state.rename(chatId, next);
+ }}
+ />
+ ) : titleLoading ? (
+
+ ) : (
+ {title}
+ )}
+ {chatId != null && !renaming && (
+ setRenaming(true)}
+ onDelete={(options) => state.deleteChat(chatId, options)}
+ loadNotes={() => state.notesForChat(chatId)}
+ />
+ )}
+ {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 ? (
+
+
+ execution.id === documentCardExecutionId ? (
+ {documentCard}
+ ) : null
+ : undefined
+ }
+ />
+ {documentCard && documentCardExecutionId == null && (
+ {documentCard}
+ )}
+
+ ) : null}
+
+
+
+
+
+
+
+ {/* A conversation keeps the composer docked at the bottom; the
+ new-conversation screen seats it in the middle with the starters. */}
+ {chatId != 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({
+ composer,
+ greeting,
+ onSelectStarter,
+ disabled,
+}: {
+ readonly composer: ReactNode;
+ readonly greeting: string;
+ readonly onSelectStarter: (message: string) => void;
+ readonly disabled: boolean;
+}) {
+ return (
+
+
+
+
{composer}
+
+ {/* Picking a card starts the conversation with its message. */}
+
+ {AI_MODE_STARTER_PROMPTS.map((prompt) => (
+
+ ))}
+
+
+ );
+}
diff --git a/components/AIMode/ConversationList.tsx b/components/AIMode/ConversationList.tsx
new file mode 100644
index 000000000..81093e1dc
--- /dev/null
+++ b/components/AIMode/ConversationList.tsx
@@ -0,0 +1,196 @@
+'use client';
+
+import { useState } from 'react';
+import { Plus } from 'lucide-react';
+import { Loader } from '@/components/ui/Loader';
+import { ConversationListSkeleton } from '@/components/skeletons/AIModeSkeleton';
+import { Button } from '@/components/ui/Button';
+import { formatTimeAgo } from '@/utils/date';
+import { cn } from '@/utils/styles';
+import type { ChatNoteRef, AgentChatListItem } from '@/types/agentChat';
+import type { ChatListAccess } from '@/hooks/useAgentChat';
+import { ConversationMenu } from './ConversationMenu';
+import { ConversationTitleField } from './ConversationTitleField';
+
+const UNTITLED = 'Untitled conversation';
+
+interface ConversationListProps {
+ readonly chats: AgentChatListItem[];
+ readonly access: ChatListAccess;
+ readonly accessDetail: string | null;
+ readonly activeChatId: number | null;
+ /** Resolves a row's title, showing a rename before the server confirms it. */
+ readonly titleFor: (chatId: number, fallback: string | null) => string | null;
+ readonly onSelect: (chatId: number) => void;
+ readonly onNew: () => void;
+ readonly onRename: (chatId: number, title: string) => Promise;
+ readonly onDelete: (chatId: number, options: { deleteNotes: boolean }) => Promise;
+ readonly loadNotes: (chatId: number) => Promise;
+ readonly onRetry: () => void;
+}
+
+/**
+ * The left pane: the user's assistant conversations, newest activity first
+ * as the server orders them. Rows rename through a menu; nothing deletes,
+ * because the backend has no endpoint for it.
+ */
+export function ConversationList({
+ chats,
+ access,
+ accessDetail,
+ activeChatId,
+ titleFor,
+ onSelect,
+ onNew,
+ onRename,
+ onDelete,
+ loadNotes,
+ onRetry,
+}: ConversationListProps) {
+ const [renamingId, setRenamingId] = useState(null);
+
+ return (
+
+
+
+
+
+
+ {access === 'loading' &&
}
+
+ {access === 'hidden' && (
+
+ {accessDetail ?? 'You don’t have access to the assistant.'}
+
+ )}
+
+ {access === 'error' && (
+
+
+ {accessDetail ?? 'Couldn’t load your conversations.'}
+
+
+
+ )}
+
+ {access === 'ok' && chats.length === 0 && (
+
No conversations yet.
+ )}
+
+ {chats.map((item) => {
+ const isActive = item.id === activeChatId;
+ const title = titleFor(item.id, item.title);
+ return (
+
onSelect(item.id)}
+ onStartRename={() => setRenamingId(item.id)}
+ onCancelRename={() => setRenamingId(null)}
+ onCommitRename={async (value) => {
+ setRenamingId(null);
+ const next = value.trim();
+ if (!next || next === (title ?? '')) return;
+ await onRename(item.id, next);
+ }}
+ onDelete={(options) => onDelete(item.id, options)}
+ loadNotes={() => loadNotes(item.id)}
+ />
+ );
+ })}
+
+
+ );
+}
+
+interface ConversationRowProps {
+ readonly item: AgentChatListItem;
+ readonly title: string;
+ readonly isActive: boolean;
+ readonly renaming: boolean;
+ readonly onSelect: () => void;
+ readonly onStartRename: () => void;
+ readonly onCancelRename: () => void;
+ readonly onCommitRename: (value: string) => void;
+ readonly onDelete: (options: { deleteNotes: boolean }) => void;
+ readonly loadNotes: () => Promise;
+}
+
+function ConversationRow({
+ item,
+ title,
+ isActive,
+ renaming,
+ onSelect,
+ onStartRename,
+ onCancelRename,
+ onCommitRename,
+ onDelete,
+ loadNotes,
+}: ConversationRowProps) {
+ return (
+
+ {renaming ? (
+
+
+
+ ) : (
+
+ )}
+
+ {!renaming && (
+
+
+
+ )}
+
+ );
+}
diff --git a/components/AIMode/ConversationMenu.tsx b/components/AIMode/ConversationMenu.tsx
new file mode 100644
index 000000000..d0e3ed824
--- /dev/null
+++ b/components/AIMode/ConversationMenu.tsx
@@ -0,0 +1,132 @@
+'use client';
+
+import { useEffect, useState } from 'react';
+import { MoreHorizontal, Pencil, Trash2 } from 'lucide-react';
+import type { ChatNoteRef } from '@/types/agentChat';
+import { BaseMenu, BaseMenuItem } from '@/components/ui/form/BaseMenu';
+import { BaseModal } from '@/components/ui/BaseModal';
+import { Button } from '@/components/ui/Button';
+import { cn } from '@/utils/styles';
+
+interface ConversationMenuProps {
+ readonly title: string;
+ readonly onRename: () => void;
+ /** Called only after the user confirms, with whether to delete the notes too. */
+ readonly onDelete: (options: { deleteNotes: boolean }) => void;
+ /** The notes the conversation created; offered for deletion when any exist. */
+ readonly loadNotes?: () => Promise;
+ readonly className?: string;
+}
+
+/**
+ * The ellipsis menu for one conversation — rename inline, or delete behind a
+ * confirmation. Shared by the sidebar rows and the chat header so the two
+ * places offer exactly the same actions.
+ */
+export function ConversationMenu({
+ title,
+ onRename,
+ onDelete,
+ loadNotes,
+ className,
+}: ConversationMenuProps) {
+ const [confirming, setConfirming] = useState(false);
+ // The notes are looked up when the dialog opens, so a row whose detail was
+ // never loaded still gets the offer — and only the offer when there is
+ // something to delete. Off by default: the note is the user's work.
+ const [notes, setNotes] = useState(null);
+ const [deleteNotes, setDeleteNotes] = useState(false);
+ useEffect(() => {
+ if (!confirming) return;
+ setNotes(null);
+ setDeleteNotes(false);
+ let cancelled = false;
+ (loadNotes ? loadNotes() : Promise.resolve([]))
+ .then((loaded) => {
+ if (!cancelled) setNotes(loaded);
+ })
+ .catch(() => {
+ if (!cancelled) setNotes([]);
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [confirming, loadNotes]);
+ const noteTitle = notes?.[0]?.title?.trim();
+
+ return (
+ <>
+
+
+
+ }
+ >
+
+
+ Rename
+
+ setConfirming(true)}
+ className="gap-2 text-red-600 focus:bg-red-50 focus:text-red-700"
+ >
+
+ Delete
+
+
+
+ {/* BaseModal, not Modal: it stacks at 9999, above the AI Mode overlay. */}
+ setConfirming(false)}
+ title="Delete conversation?"
+ size="sm"
+ footer={
+
+
+
+
+ }
+ >
+ “{title}” and its messages will be deleted.
+ {notes && notes.length > 0 ? (
+
+ ) : notes == null && loadNotes ? (
+ Checking for a document…
+ ) : null}
+
+ >
+ );
+}
diff --git a/components/AIMode/ConversationTitleField.tsx b/components/AIMode/ConversationTitleField.tsx
new file mode 100644
index 000000000..fc72c4f23
--- /dev/null
+++ b/components/AIMode/ConversationTitleField.tsx
@@ -0,0 +1,57 @@
+'use client';
+
+import { useEffect, useRef, useState, type KeyboardEvent } from 'react';
+import { MAX_CHAT_TITLE_LENGTH } from '@/types/agentChat';
+import { cn } from '@/utils/styles';
+
+interface ConversationTitleFieldProps {
+ readonly initialValue: string;
+ readonly onCommit: (value: string) => void;
+ readonly onCancel: () => void;
+ readonly className?: string;
+}
+
+/**
+ * Inline title editor: Enter or blur commits, Escape cancels. Escape is
+ * claimed here so the overlay's own Esc handler doesn't close it.
+ */
+export function ConversationTitleField({
+ initialValue,
+ onCommit,
+ onCancel,
+ className,
+}: ConversationTitleFieldProps) {
+ 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') {
+ 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={cn(
+ 'w-full min-w-0 rounded-md border border-primary-300 bg-white px-2 py-1 text-sm text-gray-900 outline-none ring-2 ring-primary-100',
+ className
+ )}
+ />
+ );
+}
diff --git a/components/AIMode/DocumentCard.tsx b/components/AIMode/DocumentCard.tsx
new file mode 100644
index 000000000..1542867c6
--- /dev/null
+++ b/components/AIMode/DocumentCard.tsx
@@ -0,0 +1,72 @@
+'use client';
+
+import { FileText, Lock } from 'lucide-react';
+import { Loader } from '@/components/ui/Loader';
+import { cn } from '@/utils/styles';
+import type { DocumentStatus } from './useAIModeDocument';
+
+interface DocumentCardProps {
+ readonly title: string;
+ readonly status: DocumentStatus;
+ /** The document is showing beside the chat (desktop) or in the drawer (mobile). */
+ readonly open: boolean;
+ readonly onOpen: () => void;
+}
+
+/**
+ * The document's card in the transcript, seated under the turn that created
+ * it. The one place the reader is told the document exists and can reach it
+ * from — the pane beside the chat on desktop, a drawer on mobile.
+ */
+export function DocumentCard({ title, status, open, onOpen }: DocumentCardProps) {
+ const writing = status === 'drafting' || status === 'working';
+ const subtitle = writing
+ ? status === 'drafting'
+ ? 'Writing…'
+ : 'Working…'
+ : status === 'empty'
+ ? 'Nothing written yet'
+ : 'Ready to edit';
+
+ return (
+
+ );
+}
diff --git a/components/AIMode/DocumentPane.tsx b/components/AIMode/DocumentPane.tsx
new file mode 100644
index 000000000..4ae7858ec
--- /dev/null
+++ b/components/AIMode/DocumentPane.tsx
@@ -0,0 +1,288 @@
+'use client';
+
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import type { Editor } from '@tiptap/react';
+import { BlockEditorClientWrapper } from '@/components/Editor/components/BlockEditor/components/BlockEditorClientWrapper';
+import { NoteReviewBanner } from '@/components/Notebook/NoteReview/NoteReviewBanner';
+import { NotebookTabs, type NotebookTab } from '@/components/Notebook/NotebookTabs';
+import { PublishingForm } from '@/components/Notebook/PublishingForm';
+import {
+ PublishingHostProvider,
+ type PublishingDefaultArticleType,
+ type PublishingHost,
+} from '@/contexts/PublishingHostContext';
+import { useNoteDetailsSaver } from '@/hooks/useNoteDetailsSaver';
+import { NoteReviewControls } from '@/components/Notebook/NoteReview/NoteReviewControls';
+import { noteDiffPersistableDoc } from '@/components/Notebook/NoteReview/noteDiffOverlay';
+import { useNoteAgentReview } from '@/components/Notebook/NoteReview/useNoteAgentReview';
+import { Button } from '@/components/ui/Button';
+import { Loader } from '@/components/ui/Loader';
+import { DocumentPaneSkeleton } from '@/components/skeletons/AIModeSkeleton';
+import { useUpdateNote } from '@/hooks/useNote';
+import type { AgentChat } from '@/types/agentChat';
+import { cn } from '@/utils/styles';
+import type { AIModeDocument } from './useAIModeDocument';
+
+/** The page column: shared by the skeleton and the document so they line up. */
+const DOCUMENT_PAGE_CLASS =
+ 'ai-mode-document mx-auto w-full max-w-[860px] px-5 py-6 tablet:!px-8 tablet:!py-8';
+
+interface DocumentPaneProps {
+ readonly document: AIModeDocument;
+ /** The open chat, whose activity is one of the review's version signals. */
+ readonly chat: AgentChat | null;
+ /** Document, or the publishing details form. */
+ readonly tab: NotebookTab;
+ readonly onTabChange: (tab: NotebookTab) => void;
+ /** Work type to preselect in the details form for a note without one. */
+ readonly defaultArticleType?: PublishingDefaultArticleType | null;
+ /** Never editable — the mobile drawer. */
+ readonly readOnly?: boolean;
+ readonly className?: string;
+}
+
+/**
+ * The right pane: the note the assistant is composing, in the real editor.
+ * Each version the assistant writes is spliced into the editor as an in-note
+ * review (highlighted insertions, struck removals, accept/reject), exactly as
+ * in the notebook. The user can edit once the turn has settled; edits
+ * autosave. While a section is being written the streaming prose is appended
+ * below the editor, and a turn with no draft shows an in-progress row so the
+ * page never sits frozen.
+ */
+export function DocumentPane({
+ document,
+ chat,
+ tab,
+ onTabChange,
+ defaultArticleType = null,
+ readOnly = false,
+ className,
+}: DocumentPaneProps) {
+ const { note, content, loading, error, status, draftText, phaseLabel } = document;
+ const noteId = note?.id ?? null;
+ const writing = status === 'drafting' || status === 'working';
+
+ // ---- the editor, its autosave, and the assistant-version review ----
+ const [editor, setEditor] = useState(null);
+ // The assistant names the note when it creates it; unlike the notebook,
+ // the document's first heading is a section, not the title, so saves here
+ // never derive a title from it.
+ const [, updateNote, saveNoteNow] = useUpdateNote(noteId ?? undefined, {
+ // Mid-review the editor holds a merged document; saves must persist it
+ // without the struck (pending-removal) ranges.
+ docToPersist: (instance) => noteDiffPersistableDoc(instance) ?? instance.state.doc,
+ });
+ // Creating the editor dispatches document-changing transactions of its own
+ // (UniqueID stamps ids onto the assistant's blocks, which carry none) and
+ // those arrive here before the instance has even been handed over via
+ // setEditor. Saving them would write an editor-authored version the user
+ // never made — on a brand-new note that also makes the assistant's first
+ // edit_note stale. Only updates to the editor we hold are the user's.
+ const editorRef = useRef(null);
+ useEffect(() => {
+ editorRef.current = editor;
+ }, [editor]);
+ // The editor came up empty for a note that has text: the content didn't
+ // survive the load (a parse the schema rejected, say — tiptap falls back
+ // to an empty document with only a console warning). Treat it as a failed
+ // load rather than an empty document, or the first autosave would write
+ // that emptiness over the real note.
+ const loadedText = content?.plainText?.trim() ?? '';
+ const editorLostContent =
+ editor != null && loadedText.length > 0 && editor.state.doc.textContent.trim().length === 0;
+
+ const handleEditorUpdate = useCallback(
+ (instance: Editor) => {
+ if (editorRef.current !== instance) return;
+ // An empty document is never a save this surface should make: not on a
+ // fresh note (the assistant's first edit_note would go stale), and not
+ // on a written one (it would erase it). Clearing everything on purpose
+ // is the notebook's job.
+ if (instance.state.doc.textContent.trim().length === 0) return;
+ updateNote(instance);
+ },
+ [updateNote]
+ );
+
+ // The details form is the notebook's own, hosted here: it reads the note,
+ // the live editor and the note's single details writer through the host
+ // seam rather than the notebook context.
+ const { saveDetailsSoon, saveDetailsNow } = useNoteDetailsSaver(noteId ?? undefined);
+ const publishingHost = useMemo(
+ () => ({
+ note: content,
+ editor,
+ isLoading: loading,
+ saveDetailsSoon,
+ saveDetailsNow,
+ defaultArticleType,
+ }),
+ [content, editor, loading, saveDetailsSoon, saveDetailsNow, defaultArticleType]
+ );
+
+ const persistEditorState = useCallback(async () => {
+ if (!editor || editor.isDestroyed) return false;
+ return saveNoteNow(editor);
+ }, [editor, saveNoteNow]);
+
+ const loadedNote = useMemo(
+ () => (content && noteId != null ? { id: noteId, versionId: content.versionId } : null),
+ [content, noteId]
+ );
+ const review = useNoteAgentReview({
+ noteId,
+ editor,
+ loadedNote,
+ chat,
+ onPersistEditorState: persistEditorState,
+ });
+
+ // Editable only once the turn has settled: typing while the assistant is
+ // mid-edit would make its next edit_note stale and the review jumpy.
+ const locked = writing || editorLostContent;
+
+ return (
+
+
+
+
+
+ {/* The document is the pane: no gutter, no card, just the page. The
+ editor stays mounted behind the details tab — it holds the review
+ and autosave state, and the form publishes from it. */}
+
+
+
+ {error && content == null ? (
+
+
{error}
+
+
+ ) : loading || content == null ? (
+
+
+
+ ) : (
+
+ {editorLostContent && (
+
+ This document couldn’t be displayed here. Open it in the notebook to view it;
+ nothing has been changed.
+
+ )}
+ {status === 'empty' && review.review == null && (
+
+ )}
+ {status === 'working' && !document.hasWrittenVersion && (
+
+ )}
+
+ {/* Mounted once per note: the editor's content prop is only read on
+ creation, and later versions arrive through the review. */}
+
+
+ {status === 'drafting' && draftText && }
+
+ {status === 'working' && document.hasWrittenVersion && (
+
+ )}
+
+ )}
+
+
+ {tab === 'details' && (
+
+ )}
+
+ {review.review && tab === 'document' && (
+
+
+
+ )}
+
+ );
+}
+
+/**
+ * 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 (
+
+ {active ? (
+ <>
+
+
Starting the document…
+ {label &&
{label}
}
+ >
+ ) : (
+
+ Nothing has been written here yet. You can start typing, or ask the assistant.
+
+ )}
+
+ );
+}
+
+/** 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/copy.ts b/components/AIMode/copy.ts
new file mode 100644
index 000000000..93caea86f
--- /dev/null
+++ b/components/AIMode/copy.ts
@@ -0,0 +1,60 @@
+import type { IconDefinition } from '@fortawesome/fontawesome-svg-core';
+import {
+ faBullhorn,
+ faFileSignature,
+ faMagnifyingGlassDollar,
+} from '@fortawesome/pro-light-svg-icons';
+
+/**
+ * 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 = 'ResearchHub AI';
+
+/** Greeting on the new-conversation screen; the name is filled in at render. */
+export const aiModeGreeting = (firstName: string | null | undefined): string =>
+ firstName?.trim() ? `Welcome, ${firstName.trim()}` : 'Welcome';
+
+export interface StarterPrompt {
+ readonly id: string;
+ readonly title: string;
+ readonly description: string;
+ /** Same icon family as the sidebar's Publish menu. */
+ readonly icon: IconDefinition;
+ /** Sent as the conversation's first message when the card is picked. */
+ readonly message: string;
+}
+
+/**
+ * Starter cards for the new-conversation screen. Picking one starts the
+ * conversation with its message; the backend does not supply suggestions.
+ * Titles and subtext mirror the sidebar's Publish menu.
+ */
+export const AI_MODE_STARTER_PROMPTS: readonly StarterPrompt[] = [
+ {
+ id: 'draft-rfp',
+ title: 'Draft a Request for Proposal',
+ description: 'Fund specific research you care about',
+ icon: faBullhorn,
+ 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.',
+ },
+ {
+ id: 'draft-proposal',
+ title: 'Draft a Research Proposal',
+ description: 'Raise money for your research',
+ icon: faFileSignature,
+ message:
+ '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.',
+ },
+ {
+ id: 'funding',
+ title: 'Find me funding',
+ description: 'Open RFPs that fit your work',
+ icon: faMagnifyingGlassDollar,
+ message:
+ 'Find open RFPs I could apply to based on my expertise, and tell me why each one is a match.',
+ },
+];
diff --git a/components/AIMode/useAIModeChat.ts b/components/AIMode/useAIModeChat.ts
new file mode 100644
index 000000000..bb99f66dd
--- /dev/null
+++ b/components/AIMode/useAIModeChat.ts
@@ -0,0 +1,457 @@
+'use client';
+
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { useAIMode } from './AIModeContext';
+import { assistantChatTransport } from '@/services/chatTransport';
+import {
+ useAgentChat,
+ useAgentChatList,
+ type SendOutcome,
+ type UseAgentChatListResult,
+ type UseAgentChatResult,
+} from '@/hooks/useAgentChat';
+import { useAgentModelSelection, type AgentModelSelection } from '@/hooks/useAgentModelSelection';
+import { useResearchAI } from '@/hooks/useResearchAI';
+import { canSelectAIModel, formatBudgetReset } from '@/types/researchAI';
+import type { ChatNoteRef, AgentChat } from '@/types/agentChat';
+import type { GenerationRequest } from '@/types/agentModels';
+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;
+}
+
+/**
+ * Composer copy for a failed send. Server `detail` is rendered verbatim
+ * wherever it exists; the fallbacks only cover bodies without one. A spent
+ * budget names its reset time when the allowance store knows it.
+ */
+function noticeFromOutcome(
+ outcome: SendOutcome & { ok: false },
+ budgetResetsAt: string | null
+): ComposerNotice {
+ switch (outcome.reason) {
+ case 'account_busy':
+ return {
+ tone: 'warning',
+ text:
+ outcome.detail ??
+ 'Another assistant task of yours is still running elsewhere. Wait for it to finish, then try again.',
+ };
+ case 'busy':
+ return {
+ tone: 'warning',
+ text: outcome.detail ?? 'The assistant is still working on a previous message.',
+ };
+ case 'usage_limit':
+ return {
+ tone: 'warning',
+ text: budgetResetsAt
+ ? `You’ve used today’s assistant budget. It resets at ${formatBudgetReset(budgetResetsAt)}.`
+ : (outcome.detail ?? 'You’ve used today’s assistant budget. Try again after it resets.'),
+ };
+ case 'model_not_allowed':
+ return {
+ tone: 'error',
+ text:
+ outcome.detail ?? 'That model isn’t available to you. Pick another one and try again.',
+ };
+ 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: UseAgentChatListResult;
+ readonly chat: UseAgentChatResult;
+ 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;
+ /**
+ * Sending would be refused: the allowance is unknown or spent, or a tier
+ * that picks its model has no catalog yet to pick from.
+ */
+ readonly sendBlocked: boolean;
+ readonly send: () => Promise;
+ /** Send given text as the user's message — a starter card, sent as-is. */
+ readonly sendText: (text: string) => Promise;
+ readonly stop: () => Promise;
+ /** Rename any conversation, open or not. */
+ readonly rename: (chatId: number, title: string) => Promise;
+ /**
+ * Delete any conversation, optionally with the notes it created; deleting
+ * the open one lands on the new-conversation screen.
+ */
+ readonly deleteChat: (chatId: number, options?: { deleteNotes?: boolean }) => Promise;
+ /**
+ * The notes a conversation created, for the delete confirmation: the open
+ * chat's from what is loaded, any other's from one detail fetch.
+ */
+ readonly notesForChat: (chatId: number) => 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;
+ /**
+ * The title to show for a conversation: a rename the user just made, shown
+ * before the server confirms it, else the given fallback.
+ */
+ readonly titleFor: (chatId: number, fallback: string | null) => string | 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 = useAgentChatList(transport, true);
+ const [initialChat, setInitialChat] = useState(null);
+ const chat = useAgentChat({ transport, chatId, enabled: true, initialChat });
+ const chatRef = useRef(chat.chat);
+ chatRef.current = chat.chat;
+ // User-wide allowances and the model catalog load with the overlay; the
+ // selection hook reads them from the same store rather than fetching again.
+ const researchAI = useResearchAI(true);
+ const hasModelSelection = canSelectAIModel(researchAI.budget?.tier);
+ const modelSelection = useAgentModelSelection({
+ enabled: false,
+ canSelect: hasModelSelection && researchAI.catalog !== null,
+ conversationKey: `assistant:${chatId ?? 'new'}`,
+ locked:
+ (chat.chat?.executions.length ?? 0) > 0 ||
+ (chat.chat?.messages.length ?? 0) > 0 ||
+ chat.pendingSend !== null,
+ pinnedRef: chat.pinnedModelRef,
+ effortPinned: chat.latestExecution != null,
+ pinnedEffort: chat.latestExecution?.effort ?? null,
+ });
+ // A selectable tier must never submit its first turn without an authoritative
+ // model; cached budget and catalog data stay usable through refresh failures.
+ const sendBlocked =
+ researchAI.budget === null ||
+ researchAI.isSubmissionBlocked() ||
+ (hasModelSelection && modelSelection.model === null);
+ const getBudgetSnapshot = researchAI.getSnapshot;
+ const failureNotice = useCallback(
+ (outcome: SendOutcome & { ok: false }): ComposerNotice => {
+ const snapshot = getBudgetSnapshot();
+ return noticeFromOutcome(outcome, snapshot.budget?.resets_at ?? snapshot.limitResetAt);
+ },
+ [getBudgetSnapshot]
+ );
+
+ // ---- 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