diff --git a/app/journal/JournalPageContent.tsx b/app/journal/JournalPageContent.tsx index 3891c7af5..75c0bc5f3 100644 --- a/app/journal/JournalPageContent.tsx +++ b/app/journal/JournalPageContent.tsx @@ -2,7 +2,7 @@ import { usePathname, useRouter, useSearchParams } from 'next/navigation'; import { FeedContent } from '@/components/Feed/FeedContent'; -import { FeedSortDropdown } from '@/components/Feed/FeedSortDropdown'; +import { SortMenu } from '@/components/ui/SortMenu'; import { useFeed } from '@/hooks/useFeed'; import { JournalV2FeedEntryItem } from '@/components/Journal/JournalV2FeedEntryItem'; @@ -56,7 +56,7 @@ export function JournalPageContent() { loadMore={loadMore} filters={
- changeJournalSort(getJournalSort(sort))} diff --git a/app/layouts/MobileBottomNav.tsx b/app/layouts/MobileBottomNav.tsx index 42d18eb1c..5cc23ab25 100644 --- a/app/layouts/MobileBottomNav.tsx +++ b/app/layouts/MobileBottomNav.tsx @@ -13,7 +13,7 @@ import { faBars, } from '@fortawesome/pro-light-svg-icons'; import { faXTwitter, faDiscord, faGithub, faLinkedin } from '@fortawesome/free-brands-svg-icons'; -import { Sprout, Star } from 'lucide-react'; +import { Sparkles, Sprout, Star } from 'lucide-react'; import { ChangelogLink } from '@/components/changelog/ChangelogLink'; import { FundingPowerBar } from '@/components/Funding/FundingPowerBar'; import { Icon } from '@/components/ui/icons'; @@ -24,6 +24,9 @@ import { useAuthenticatedAction } from '@/contexts/AuthModalContext'; import { useCurrencyPreference } from '@/contexts/CurrencyPreferenceContext'; import { useScrollContainer } from '@/contexts/ScrollContainerContext'; import { isHomeTabPath } from '@/hooks/useFundTabs'; +import { useUser } from '@/contexts/UserContext'; +import { useOptionalAIMode } from '@/components/AIMode/AIModeContext'; +import { isHubEditorOrModerator } from '@/utils/permissions'; interface NavItem { label: string; @@ -32,6 +35,8 @@ interface NavItem { isMore?: boolean; requiresAuth?: boolean; isHome?: boolean; + /** Toggles the AI Mode overlay in place instead of navigating. */ + isAIMode?: boolean; } // Additional navigation items not in the bottom bar @@ -74,11 +79,17 @@ export const MobileBottomNav: React.FC = () => { const { executeAuthenticatedAction } = useAuthenticatedAction(); const { showUSD, toggleCurrency } = useCurrencyPreference(); const scrollContainerRef = useScrollContainer(); + const { user } = useUser(); + const aiMode = useOptionalAIMode(); + // Moderators and hub editors, the only users the assistant admits, get it + // in the bar where Peer Review sits for everyone else. const mainNavItems: NavItem[] = [ { label: 'Home', href: '/', iconKey: 'home', isHome: true }, { label: 'My Funding', href: '/my-funding', iconKey: 'fund', requiresAuth: true }, - { label: 'Peer Review', href: '/peer-review', iconKey: 'peer-review' }, + isHubEditorOrModerator(user) + ? { label: 'Assistant', iconKey: 'assistant', isAIMode: true } + : { label: 'Peer Review', href: '/peer-review', iconKey: 'peer-review' }, { label: 'Wallet', href: '/researchcoin', iconKey: 'wallet' }, { label: 'More', isMore: true, iconKey: 'more' }, ]; @@ -109,6 +120,10 @@ export const MobileBottomNav: React.FC = () => { setIsMoreOpen(true); return; } + if (item.isAIMode) { + aiMode?.toggle(); + return; + } if (item.requiresAuth) { executeAuthenticatedAction(() => router.push(item.href!)); @@ -139,6 +154,15 @@ export const MobileBottomNav: React.FC = () => { color={iconColor} /> ); + case 'assistant': + return ( + + ); case 'peer-review': return ( { {mainNavItems.map((item) => { const isActive = item.isMore ? isMoreActive || isMoreOpen - : item.href - ? isPathActive(item.href, pathname, item.isHome) - : false; + : item.isAIMode + ? Boolean(aiMode?.isOpen) + : item.href + ? isPathActive(item.href, pathname, item.isHome) + : false; return ( + ); + } + return ( = ({ )} >
- {navigationItems.map((item) => ( - - ))} + {navigationItems + .filter((item) => !item.isAIMode || canUseAssistant) + .map((item) => ( + + ))}
); diff --git a/app/layouts/topbar/TopBarSearchButton.tsx b/app/layouts/topbar/TopBarSearchButton.tsx index 8f430edef..c495465a9 100644 --- a/app/layouts/topbar/TopBarSearchButton.tsx +++ b/app/layouts/topbar/TopBarSearchButton.tsx @@ -1,7 +1,6 @@ 'use client'; -import { Suspense, useLayoutEffect, useState } from 'react'; -import { usePathname, useSearchParams } from 'next/navigation'; +import { useLayoutEffect, useState } from 'react'; import { Search as SearchIcon } from 'lucide-react'; function getSearchShortcutLabel(): string { @@ -22,12 +21,8 @@ interface TopBarSearchButtonProps { onClick: () => void; } -function TopBarSearchButtonInner({ - onClick, - currentSearchQuery, -}: TopBarSearchButtonProps & { currentSearchQuery: string | null }) { +export function TopBarSearchButton({ onClick }: TopBarSearchButtonProps) { const shortcutLabel = useSearchShortcutLabel(); - const displayText = currentSearchQuery || 'Search'; return (
@@ -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( + , + 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 && ( +
+
+ {composer} +
+
+ )} +
+ ); +} + +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 ( +
+
+
+ +
+

{greeting}

+
+ +
{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 ( + <> + + + + } + > + + + setConfirming(true)} + className="gap-2 text-red-600 focus:bg-red-50 focus:text-red-700" + > + + + + {/* 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>(() => 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 sendText = useCallback( + async (rawText: string) => { + const text = rawText.trim(); + if (!text) return; + setNotice(null); + const target = targetRef.current; + const generation = modelSelection.request; + // The box empties the moment the user sends, as the message is already + // theirs; it only comes back if the send fails and they need to retry. + setDraft(''); + + 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) { + setDraft(text); + setNotice({ + tone: 'error', + text: list.accessDetail ?? 'Couldn’t start a conversation. Please try again.', + }); + return; + } + draftsRef.current.delete('new'); + // A rejected first attempt must retry with the same model and settings. + modelSelection.adoptConversation(`assistant:${created.conversation_id}`, generation); + setInitialChat(created); + selectChatInUrl(created.conversation_id); + setQueuedMessage({ text, generation }); + return; + } + + const outcome = await chat.send(text, generation); + if (!outcome.ok && isCurrentTarget(target)) { + setDraft(text); + setNotice(failureNotice(outcome)); + } + }, + [ + chatId, + list, + chat, + modelSelection.request, + modelSelection.adoptConversation, + setDraft, + isCurrentTarget, + selectChatInUrl, + failureNotice, + ] + ); + + const send = useCallback(() => sendText(draft), [sendText, draft]); + + // 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(failureNotice(outcome)); + setDraft(text); + } else { + draftsRef.current.set(String(target), text); + } + }); + }, [queuedMessage, chatId, chat.access, sendToChat, setDraft, isCurrentTarget, failureNotice]); + + const stop = chat.cancel; + + // ---- renames, shown before the server confirms them ---- + // A rename is the user's own words; making them wait for the PATCH just + // flashes the old title back at them. The override shows at once and is + // dropped when the listing catches up, or rolled back if the save fails. + const [pendingTitles, setPendingTitles] = useState>(() => new Map()); + const setPendingTitle = useCallback((target: number, title: string | null) => { + setPendingTitles((prev) => { + if (title == null ? !prev.has(target) : prev.get(target) === title) return prev; + const next = new Map(prev); + if (title == null) next.delete(target); + else next.set(target, title); + return next; + }); + }, []); + useEffect(() => { + // Retire each override once the listing shows the confirmed title. + for (const item of list.chats) { + const pending = pendingTitles.get(item.id); + if (pending != null && item.title === pending) setPendingTitle(item.id, null); + } + }, [list.chats, pendingTitles, setPendingTitle]); + const titleFor = useCallback( + (target: number, fallback: string | null) => pendingTitles.get(target) ?? fallback, + [pendingTitles] + ); + + const rename = useCallback( + async (target: number, title: string): Promise => { + setPendingTitle(target, title); + let renamed: boolean; + if (target === targetRef.current) { + // The open chat's hook keeps its own copy of the title in sync. + renamed = await chat.rename(title); + } else { + try { + await transport.renameChat(target, title); + renamed = true; + } catch { + renamed = false; + } + } + if (renamed) refreshList(); + else setPendingTitle(target, null); + return renamed; + }, + [chat, transport, refreshList, setPendingTitle] + ); + + const notesForChat = useCallback( + async (target: number): Promise => { + if (target === targetRef.current && chatRef.current?.conversation_id === target) { + return chatRef.current.notes ?? []; + } + try { + return (await transport.getChat(target)).notes ?? []; + } catch { + return []; + } + }, + [transport] + ); + + const deleteChat = useCallback( + async (target: number, options?: { deleteNotes?: boolean }): Promise => { + if (!transport.deleteChat) return false; + try { + await transport.deleteChat(target, options); + } catch { + return false; + } + draftsRef.current.delete(String(target)); + if (targetRef.current === target) selectChat(null); + refreshList(); + return true; + }, + [transport, selectChat, refreshList] + ); + + const clearNotice = useCallback(() => setNotice(null), []); + + // A spent-budget notice raised before the allowance store had a reset time + // picks it up once the store's post-429 refresh lands. + const budgetResetsAt = researchAI.budget?.resets_at ?? researchAI.limitResetAt ?? null; + useEffect(() => { + if (!budgetResetsAt) return; + setNotice((current) => + current?.tone === 'warning' && + current.text.includes('budget') && + !current.text.includes('resets at') + ? { + tone: 'warning', + text: `You’ve used today’s assistant budget. It resets at ${formatBudgetReset(budgetResetsAt)}.`, + } + : current + ); + }, [budgetResetsAt]); + + 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, + sendBlocked, + send, + sendText, + stop, + rename, + deleteChat, + notesForChat, + selectChat, + startNewChat, + notesByChat, + note, + titleFor, + }; +} diff --git a/components/AIMode/useAIModeDocument.ts b/components/AIMode/useAIModeDocument.ts new file mode 100644 index 000000000..71ecaa4c4 --- /dev/null +++ b/components/AIMode/useAIModeDocument.ts @@ -0,0 +1,153 @@ +'use client'; + +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { NoteService } from '@/services/note.service'; +import type { NoteWithContent } from '@/types/note'; +import { + isActiveExecutionStatus, + type ChatExecution, + type ChatNoteRef, + type AgentChat, +} from '@/types/agentChat'; + +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; + /** + * The note as loaded for the editor: title, organization and the version + * the editor was seeded with. Loaded once per note — later agent versions + * reach the editor through the review, not through a reload here. + */ + readonly content: NoteWithContent | null; + readonly loading: boolean; + readonly error: string | null; + readonly status: DocumentStatus; + /** + * The assistant has written at least one version: the loaded note had one, + * or the chat's activity reports a succeeded edit_note since. + */ + readonly hasWrittenVersion: boolean; + /** 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; + /** Deep link to the note in the notebook, once its organization is known. */ + readonly notebookHref: string | null; + readonly reload: () => void; +} + +interface UseAIModeDocumentOptions { + readonly note: ChatNoteRef | null; + readonly chat: AgentChat | null; + readonly latestExecution: ChatExecution | null; +} + +/** Any succeeded `edit_note` in the chat carries the version it produced. */ +function chatHasEditedNote(chat: AgentChat | null): boolean { + return (chat?.executions ?? []).some((execution) => + (execution.activity ?? []).some( + (item) => + item.type === 'tool_call' && item.status === 'succeeded' && item.note_version_id != null + ) + ); +} + +/** 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; +} + +/** + * The document behind a conversation: loads it for the editor and derives + * the pane's state from the active turn. Keeping the document current as the + * assistant writes is the review hook's job (see useNoteAgentReview), which + * splices each new version into the live editor instead of reloading it. + */ +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); + 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; + 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; + setContent(null); + setError(null); + setLoading(noteId != null); + if (noteId != null) fetchNote(); + }, [noteId, fetchNote]); + + const draftText = currentEditDraft(latestExecution); + const turnActive = latestExecution != null && isActiveExecutionStatus(latestExecution.status); + const phaseLabel = turnActive ? (latestExecution?.phase?.label ?? null) : null; + + const hasWrittenVersion = (content != null && content.versionId > 0) || chatHasEditedNote(chat); + + const status: DocumentStatus = useMemo(() => { + if (noteId == null) return 'absent'; + if (draftText != null) return 'drafting'; + if (turnActive) return 'working'; + if (content != null && !hasWrittenVersion) return 'empty'; + return 'settled'; + }, [noteId, draftText, turnActive, content, hasWrittenVersion]); + + 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, + hasWrittenVersion, + draftText, + phaseLabel, + notebookHref, + reload: fetchNote, + }; +} diff --git a/components/Activity/ActivityCacheBypassControl.tsx b/components/Activity/ActivityCacheBypassControl.tsx index a8d107550..3fc4ef314 100644 --- a/components/Activity/ActivityCacheBypassControl.tsx +++ b/components/Activity/ActivityCacheBypassControl.tsx @@ -2,7 +2,7 @@ import { FC, useCallback, useState } from 'react'; import { useRouter, useSearchParams } from 'next/navigation'; -import * as Popover from '@radix-ui/react-popover'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/Popover'; import { Shield } from 'lucide-react'; import { Switch } from '@/components/ui/Switch'; import { cn } from '@/utils/styles'; @@ -43,8 +43,8 @@ export const ActivityCacheBypassControl: FC = ( ); return ( - - + + - + - - -
-
-

Bypass cache

-
- + +
+
+

Bypass cache

- - - + +
+
+ ); }; diff --git a/components/Notebook/AgentChat/ActivityFeed.tsx b/components/AgentChat/ActivityFeed.tsx similarity index 92% rename from components/Notebook/AgentChat/ActivityFeed.tsx rename to components/AgentChat/ActivityFeed.tsx index 3de4140ae..8dbd695ea 100644 --- a/components/Notebook/AgentChat/ActivityFeed.tsx +++ b/components/AgentChat/ActivityFeed.tsx @@ -22,12 +22,13 @@ import { import { Loader } from '@/components/ui/Loader'; import { cn } from '@/utils/styles'; import { MarkdownMessage } from './MarkdownMessage'; +import { answerRevealKey, isRevealable, narrationRevealKey } from '@/hooks/useTextReveal'; import type { ActivityCallStatus, ChatFeedItem, ChatActivitySource, ChatToolCallActivity, -} from '@/types/notebookChat'; +} from '@/types/agentChat'; /** * Icons for the tools we know about today. The backend adds tools without @@ -349,16 +350,29 @@ export function carriesSweep(item: ChatFeedItem): boolean { function ActivityItemBody({ item, streaming, + executionId, }: { readonly item: ChatFeedItem; readonly streaming: boolean; + readonly executionId?: number; }) { if (item.type === 'narration') { // Same renderer as the answer bubble, so the live narration preview and - // the settled message it becomes read as one continuous surface. + // the settled message it becomes read as one continuous surface. Streamed + // narration types out, and its progress carries over to the answer so the + // settled bubble finishes the text rather than starting it again. + const itemId = 'id' in item && typeof item.id === 'string' ? item.id : null; + const key = + executionId != null && itemId != null ? narrationRevealKey(executionId, itemId) : null; + const reveals = key != null && (streaming || isRevealable(key)); return (
- +
); } @@ -374,7 +388,7 @@ function ActivityItemBody({ text={item.text} streaming={streaming} className="text-gray-500 hover:text-gray-700 [--shine:theme(colors.gray.500)]" - bodyClassName="italic text-gray-500" + bodyClassName="text-sm italic text-gray-500" /> ); } @@ -388,7 +402,7 @@ function ActivityItemBody({ streaming={streaming} icon={TOOL_ICONS[item.tool] ?? Wrench} className="text-gray-800 hover:text-gray-600 [--shine:theme(colors.gray.800)]" - bodyClassName="text-gray-500" + bodyClassName="text-sm text-gray-500" /> ); } @@ -402,6 +416,8 @@ interface ActivityFeedProps { readonly items: ChatFeedItem[]; /** Stream id of the item currently receiving deltas, while the turn is live. */ readonly streamingItemId?: string; + /** The turn these items belong to; keys the typed-out reveal of its narration. */ + readonly executionId?: number; readonly className?: string; } @@ -409,7 +425,12 @@ interface ActivityFeedProps { * The ordered account of what the agent did during a turn: narration prose * between tool calls, and one row per tool call with status + citations. */ -export function ActivityFeed({ items, streamingItemId, className }: ActivityFeedProps) { +export function ActivityFeed({ + items, + streamingItemId, + executionId, + className, +}: ActivityFeedProps) { const rows = items.filter(drawsAsRow); if (rows.length === 0) return null; @@ -422,7 +443,11 @@ export function ActivityFeed({ items, streamingItemId, className }: ActivityFeed // making their fallback index stable within the settled activity list. // eslint-disable-next-line react/no-array-index-key
  • - +
  • ); })} diff --git a/components/Notebook/AgentChat/ChatComposer.tsx b/components/AgentChat/ChatComposer.tsx similarity index 93% rename from components/Notebook/AgentChat/ChatComposer.tsx rename to components/AgentChat/ChatComposer.tsx index 8d3f73a74..cb00ed57e 100644 --- a/components/Notebook/AgentChat/ChatComposer.tsx +++ b/components/AgentChat/ChatComposer.tsx @@ -3,7 +3,7 @@ import { useEffect, type KeyboardEvent, type ReactNode, type RefObject } from 'react'; import { ArrowUp, Square } from 'lucide-react'; import { cn } from '@/utils/styles'; -import { MAX_CHAT_MESSAGE_LENGTH } from '@/types/notebookChat'; +import { MAX_CHAT_MESSAGE_LENGTH } from '@/types/agentChat'; export interface ComposerNotice { tone: 'warning' | 'error'; @@ -40,6 +40,8 @@ interface ChatComposerProps { * what is being configured and owns only where it sits. */ readonly toolbar?: ReactNode; + /** Extra classes for the outer wrapper — a host can drop the top border it already draws. */ + readonly className?: string; } const COUNTER_THRESHOLD = MAX_CHAT_MESSAGE_LENGTH - 1000; @@ -62,6 +64,7 @@ export function ChatComposer({ placeholder = 'Ask the assistant…', textareaRef, toolbar, + className, }: ChatComposerProps) { // Grow with content up to ~6 lines, then scroll. useEffect(() => { @@ -81,7 +84,7 @@ export function ChatComposer({ }; return ( -
    +
    {notice && ( // carries an implicit status role (polite live region). @@ -114,7 +117,7 @@ export function ChatComposer({ disabled={disabled} placeholder={placeholder} aria-label="Message the assistant" - className="block max-h-40 min-h-[24px] w-full resize-none bg-transparent text-sm text-gray-800 placeholder:text-gray-500 focus:outline-none disabled:cursor-not-allowed" + className="block max-h-40 min-h-[24px] w-full resize-none bg-transparent text-md text-gray-800 placeholder:text-gray-500 focus:outline-none disabled:cursor-not-allowed" />
    {toolbar}
    diff --git a/components/AgentChat/ChatPicker.tsx b/components/AgentChat/ChatPicker.tsx new file mode 100644 index 000000000..ecfb439d8 --- /dev/null +++ b/components/AgentChat/ChatPicker.tsx @@ -0,0 +1,101 @@ +'use client'; + +import type { ReactNode } from 'react'; +import { Loader } from '@/components/ui/Loader'; +import { BaseMenu, BaseMenuItem } from '@/components/ui/form/BaseMenu'; +import { MenuTrigger } from '@/components/ui/MenuTrigger'; +import { cn } from '@/utils/styles'; +import { formatTimeAgo } from '@/utils/date'; +import type { AgentChatListItem } from '@/types/agentChat'; + +interface ChatPickerProps { + readonly chats: AgentChatListItem[]; + readonly activeChatId: number | null; + /** Live title of the open chat — fresher than the listing after renames/derives. */ + readonly activeTitle: string | null; + readonly onSelect: (chatId: number) => void; + /** Fired when the dropdown opens — refresh the listing projection. */ + readonly onOpen: () => void; + /** + * Control for the open chat's title, seated right after the picker. A slot + * rather than a prop pair so the picker stays ignorant of what the action + * is — it only owns where it sits. + */ + readonly titleAction?: ReactNode; +} + +/** + * Header dropdown for switching between the note's chats. Built on the cheap + * listing projection: title, preview, activity spinner — never full chats. + * + * Switching is all it does. Starting a chat lives on the header button beside + * it, where it is one tap rather than two. + */ +export function ChatPicker({ + chats, + activeChatId, + activeTitle, + onSelect, + onOpen, + titleAction, +}: ChatPickerProps) { + const currentLabel = activeChatId == null ? 'New chat' : activeTitle?.trim() || 'Untitled chat'; + + return ( + // Claims the row so the header's panel actions stay pinned right, but + // nothing inside grows: the picker and the title action sit together at + // the left and the slack collects after them. Only a title long enough to + // need the space takes it, truncating rather than shoving. +
    + { + if (open) onOpen(); + }} + trigger={ + + {currentLabel} + + } + > + {/* The listing is empty until the first chat is saved, and the menu + would otherwise open as a bare box. */} + {chats.length === 0 && ( +

    No chats on this note yet.

    + )} + + {chats.map((chat) => ( + onSelect(chat.id)} + aria-current={chat.id === activeChatId} + className={cn( + 'cursor-pointer items-start rounded-md px-3 py-2', + 'focus:bg-gray-50 data-[highlighted]:bg-gray-50', + chat.id === activeChatId && + 'bg-primary-50/60 focus:bg-primary-50/60 data-[highlighted]:bg-primary-50/60' + )} + > +
    +
    + + {chat.title?.trim() || 'Untitled chat'} + + {chat.has_active_turn && ( + + )} +
    + {chat.last_message_preview && ( +

    {chat.last_message_preview}

    + )} +

    {formatTimeAgo(chat.updated_date)}

    +
    +
    + ))} +
    + + {titleAction} +
    + ); +} 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 93% rename from components/Notebook/AgentChat/ChatSources.tsx rename to components/AgentChat/ChatSources.tsx index c49494b1c..b9ede9e1d 100644 --- a/components/Notebook/AgentChat/ChatSources.tsx +++ b/components/AgentChat/ChatSources.tsx @@ -1,7 +1,7 @@ 'use client'; import { ExternalLink, Globe } from 'lucide-react'; -import type { ChatActivitySource, NotebookChat } from '@/types/notebookChat'; +import type { ChatActivitySource, AgentChat } from '@/types/agentChat'; import { collectSources, hostnameOf } from './ActivityFeed'; /** @@ -10,7 +10,7 @@ import { collectSources, hostnameOf } from './ActivityFeed'; * — the transcript already carries the sources, they're just buried per tool * call, which makes them hard to use once a chat runs long. */ -export function collectChatSources(chat: NotebookChat | null): ChatActivitySource[] { +export function collectChatSources(chat: AgentChat | null): ChatActivitySource[] { const byUrl = new Map(); for (const execution of chat?.executions ?? []) { for (const source of collectSources(execution.activity ?? [])) { diff --git a/components/Notebook/AgentChat/ChatTranscript.tsx b/components/AgentChat/ChatTranscript.tsx similarity index 81% rename from components/Notebook/AgentChat/ChatTranscript.tsx rename to components/AgentChat/ChatTranscript.tsx index 8c02df0e7..0b72e1bde 100644 --- a/components/Notebook/AgentChat/ChatTranscript.tsx +++ b/components/AgentChat/ChatTranscript.tsx @@ -1,9 +1,10 @@ 'use client'; -import { Fragment, useMemo } from 'react'; -import type { ChatExecution, ChatMessage, NotebookChat } from '@/types/notebookChat'; -import type { PendingSend } from '@/hooks/useNotebookChat'; +import { Fragment, useMemo, type ReactNode } from 'react'; +import type { ChatExecution, ChatMessage, AgentChat } from '@/types/agentChat'; +import type { PendingSend } from '@/hooks/useAgentChat'; import { MarkdownMessage } from './MarkdownMessage'; +import { answerRevealKey, isRevealable } from '@/hooks/useTextReveal'; import { ExecutionProgress } from './ExecutionProgress'; import { PendingThinkingRow } from './ActivityFeed'; @@ -89,7 +90,7 @@ function pushAssistantEntry(build: TranscriptBuild, message: ChatMessage): void * missing, and user messages whose turn failed. Everything present renders; * nothing double-renders. */ -function buildTranscript(chat: NotebookChat, pendingSend: PendingSend | null): TranscriptEntry[] { +function buildTranscript(chat: AgentChat, pendingSend: PendingSend | null): TranscriptEntry[] { const messages = [...chat.messages].sort((a, b) => a.sequence - b.sequence); const build: TranscriptBuild = { entries: [], @@ -133,7 +134,7 @@ function buildTranscript(chat: NotebookChat, pendingSend: PendingSend | null): T function UserBubble({ text }: { readonly text: string }) { return (
    -
    +
    {text}
    @@ -146,20 +147,33 @@ function UserBubble({ text }: { readonly text: string }) { * boxed column. Only the user's turns are chrome-wrapped, so the transcript * still parses at a glance. */ -function AssistantBubble({ content }: { readonly content: string }) { +function AssistantBubble({ + content, + revealKey = null, +}: { + readonly content: string; + /** Types the answer out when the turn was watched live; see useTextReveal. */ + readonly revealKey?: string | null; +}) { return (
    - +
    ); } interface ChatTranscriptProps { - readonly chat: NotebookChat; + readonly chat: AgentChat; readonly pendingSend: PendingSend | null; + /** + * Extra content for a turn, rendered after its answer — a host-specific + * card for something the turn produced (AI Mode's document). A slot so the + * transcript stays ignorant of what a turn can make. + */ + readonly renderExecutionExtra?: (execution: ChatExecution) => ReactNode; } -export function ChatTranscript({ chat, pendingSend }: ChatTranscriptProps) { +export function ChatTranscript({ chat, pendingSend, renderExecutionExtra }: ChatTranscriptProps) { const entries = useMemo(() => buildTranscript(chat, pendingSend), [chat, pendingSend]); return ( @@ -187,7 +201,17 @@ export function ChatTranscript({ chat, pendingSend }: ChatTranscriptProps) { return (
    - {entry.answer && } + {entry.answer && ( + + )} + {renderExecutionExtra?.(entry.execution)}
    ); case 'assistant': diff --git a/components/Notebook/AgentChat/CreditMeter.tsx b/components/AgentChat/CreditMeter.tsx similarity index 100% rename from components/Notebook/AgentChat/CreditMeter.tsx rename to components/AgentChat/CreditMeter.tsx diff --git a/components/Notebook/AgentChat/ExecutionProgress.tsx b/components/AgentChat/ExecutionProgress.tsx similarity index 95% rename from components/Notebook/AgentChat/ExecutionProgress.tsx rename to components/AgentChat/ExecutionProgress.tsx index 7bb09fc48..6e223fb60 100644 --- a/components/Notebook/AgentChat/ExecutionProgress.tsx +++ b/components/AgentChat/ExecutionProgress.tsx @@ -1,15 +1,16 @@ 'use client'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { AlertCircle, Ban, ChevronDown, ChevronRight } from 'lucide-react'; import { cn } from '@/utils/styles'; +import { answerRevealKey, markRevealable } from '@/hooks/useTextReveal'; import { isActiveExecutionStatus, type ChatFeedItem, type ChatExecution, type ChatStreamItem, type ChatToolCallActivity, -} from '@/types/notebookChat'; +} from '@/types/agentChat'; import { ActivityFeed, carriesSweep, @@ -96,6 +97,11 @@ export function ExecutionProgress({ execution }: ExecutionProgressProps) { // until publication so we never render "done" with no answer bubble. const finishing = execution.status === 'SUCCEEDED' && execution.assistant_message_pending; const live = active || finishing; + // A turn watched live gets its answer typed out when it lands, even one + // that streamed no narration first; turns loaded from history do not. + useEffect(() => { + if (live) markRevealable(answerRevealKey(execution.id)); + }, [live, execution.id]); // Settling a turn used to collapse it, which swapped the feed for a flat list // of aggregated links — the same sources in a different shape. Stay expanded // so the turn reads the same before and after it finishes. @@ -158,6 +164,7 @@ export function ExecutionProgress({ execution }: ExecutionProgressProps) { )} diff --git a/components/AgentChat/JumpToLatestButton.tsx b/components/AgentChat/JumpToLatestButton.tsx new file mode 100644 index 000000000..43c68b8b4 --- /dev/null +++ b/components/AgentChat/JumpToLatestButton.tsx @@ -0,0 +1,36 @@ +'use client'; + +import { ArrowDown } from 'lucide-react'; +import { cn } from '@/utils/styles'; + +interface JumpToLatestButtonProps { + readonly visible: boolean; + readonly onClick: () => void; + readonly className?: string; +} + +/** + * The floating "jump to latest" control for a transcript. Position it over + * the scrolling area's bottom edge; it fades out while the reader is already + * at the end. + */ +export function JumpToLatestButton({ visible, onClick, className }: JumpToLatestButtonProps) { + return ( + + ); +} diff --git a/components/Notebook/AgentChat/MarkdownMessage.tsx b/components/AgentChat/MarkdownMessage.tsx similarity index 82% rename from components/Notebook/AgentChat/MarkdownMessage.tsx rename to components/AgentChat/MarkdownMessage.tsx index fa9695741..9c041dec5 100644 --- a/components/Notebook/AgentChat/MarkdownMessage.tsx +++ b/components/AgentChat/MarkdownMessage.tsx @@ -4,6 +4,7 @@ import { useMemo } from 'react'; import MarkdownIt from 'markdown-it'; import sanitizeHtml from 'sanitize-html'; import { cn } from '@/utils/styles'; +import { useTextReveal } from '@/hooks/useTextReveal'; // html:false makes markdown-it escape raw HTML in the source; the sanitize // pass below is defense in depth over the generated markup. @@ -66,7 +67,7 @@ const SANITIZE_OPTIONS: sanitizeHtml.IOptions = { * descendant arbitrary variants instead of `prose`. */ const MARKDOWN_STYLES = cn( - 'text-sm leading-relaxed text-gray-800 break-words', + 'text-md leading-relaxed text-gray-800 break-words', '[&_p]:my-2 [&_p:first-child]:mt-0 [&_p:last-child]:mb-0', '[&_ul]:my-2 [&_ul]:list-disc [&_ul]:pl-5', '[&_ol]:my-2 [&_ol]:list-decimal [&_ol]:pl-5', @@ -93,11 +94,24 @@ const MARKDOWN_STYLES = cn( interface MarkdownMessageProps { readonly content: string; readonly className?: string; + /** + * Type the text out a few characters per frame under this key (see + * useTextReveal); null or absent renders it whole. `revealCarryTo` mirrors + * the progress to another key, for the component that continues this text. + */ + readonly revealKey?: string | null; + readonly revealCarryTo?: string; } /** Renders assistant Markdown (sanitized) for chat bubbles. */ -export function MarkdownMessage({ content, className }: MarkdownMessageProps) { - const html = useMemo(() => sanitizeHtml(md.render(content), SANITIZE_OPTIONS), [content]); +export function MarkdownMessage({ + content, + className, + revealKey = null, + revealCarryTo, +}: MarkdownMessageProps) { + const shown = useTextReveal(content, revealKey, revealCarryTo); + const html = useMemo(() => sanitizeHtml(md.render(shown), SANITIZE_OPTIONS), [shown]); return (
    void; + readonly onChangeOptions: (options: GenerationOptions) => void; + readonly disabled: boolean; + readonly multiplierExplanation: string; +} + +/** + * Widest the panels go. Set by the widest row the catalog produces — seven + * effort pills — plus a little slack, since the row scrolls rather than + * wraps and a few pixels short would clip the last pill rather than move it. + * Never wider than the viewport allows. + */ +const PANEL_WIDTH = 'w-[360px] max-w-[calc(100vw-1rem)]'; + +/** + * The composer's two controls: which model answers, and how hard it works. + * + * Model and effort lock after the first turn. Once effort is locked the + * panel only says so: thinking and temperature depend on the effort the chat + * runs at, and offering them under a lock reads as a control that half works. + * + * The model picker is a menu (`BaseMenu`): one choice, closes on pick. The + * effort panel is a popover: it holds all three controls, temperature + * included — they are one decision about how much work a turn does — and the + * user adjusts them in place, so it must not close on each click. Only the + * controls the model can actually honor are drawn, in combinations it will + * accept. The backend refuses a temperature sent to a reasoning model, so + * that slider is simply absent until thinking is off. + */ +export function ModelControls({ + models, + model, + pinned, + effortPinned, + options, + onSelectModel, + onChangeOptions, + disabled, + multiplierExplanation, +}: ModelControlsProps) { + if (!model) return null; + + const effortLevels = availableEffortLevels(model, options.thinking); + // A single mode is not a choice: models that always reason take no toggle, + // they just reason. + const thinkingModes = + model.capabilities.thinking.length > 1 + ? availableThinkingModes(model, effortPinned, options.effort ?? null) + : []; + const showTemperature = temperatureAvailable(model, options.thinking); + // Claude refuses sampling params to a model that is still reasoning, which + // would otherwise read as a control that went missing on its own. + const temperatureNeedsThinkingOff = + !showTemperature && model.capabilities.temperature && thinkingModes.includes('disabled'); + const hasEffort = model.capabilities.effort.length > 0 || options.effort != null; + const hasEffortMenu = hasEffort || thinkingModes.length > 0 || showTemperature; + const effortLocked = effortPinned && hasEffort; + const lockedEffortLabel = options.effort ? EFFORT_LABELS[options.effort] : 'Locked effort'; + const lockedEffortDescription = options.effort + ? `${EFFORT_LABELS[options.effort]} effort is locked for this chat. Start a new chat to change it.` + : 'Effort is locked for this chat. Start a new chat to change it.'; + const allowedModels = models.filter((option) => option.allowed); + + return ( +
    + + + {hasEffortMenu && model.allowed && ( + + + + + + {effortLocked && ( +

    {lockedEffortDescription}

    + )} + + {!effortLocked && effortLevels.length > 0 && ( + ({ + value: level, + label: EFFORT_LABELS[level], + }))} + onChange={(effort) => onChangeOptions({ effort })} + /> + )} + + {!effortLocked && thinkingModes.length > 0 && ( + ({ + value: mode, + label: THINKING_LABELS[mode], + }))} + hint={ + temperatureNeedsThinkingOff + ? 'Temperature is only available with thinking off.' + : null + } + onChange={(thinking) => onChangeOptions({ thinking })} + /> + )} + + {!effortLocked && showTemperature && ( + onChangeOptions({ temperature })} + /> + )} +
    +
    + )} +
    + ); +} + +/** + * What the effort button says at a glance. Effort is the control people reach + * for, so it wins the label; the others only surface when nothing outranks + * them, and the full picture is in the button's title. + */ +function effortButtonLabel(options: GenerationOptions): string { + if (options.effort) return EFFORT_LABELS[options.effort]; + if (options.thinking) return `Thinking ${THINKING_LABELS[options.thinking].toLowerCase()}`; + if (options.temperature != null) return `Temp ${formatTemperature(options.temperature)}`; + return 'Auto'; +} + +function ModelRow({ + model, + selected, + onSelect, + multiplierExplanation, +}: { + readonly multiplierExplanation: string; + readonly model: AgentModel; + readonly selected: boolean; + readonly onSelect: () => void; +}) { + return ( + + + ); +} + +/** + * Temperature parks mid-range while unset, reading "Auto": the server's own + * default isn't published, so the slider shows a neutral position rather than + * claiming a number nobody chose. The first drag commits one. + */ +function TemperatureControl({ + value, + onChange, +}: { + readonly value: number | undefined; + readonly onChange: (value: number | undefined) => void; +}) { + return ( +
    +
    + Temperature + {value == null ? ( + Auto + ) : ( + + + {formatTemperature(value)} + + + + )} +
    + onChange(clampTemperature(next))} + className={cn(value == null && 'opacity-60')} + /> +
    + ); +} diff --git a/components/Editor/components/BlockEditor/BlockEditor.tsx b/components/Editor/components/BlockEditor/BlockEditor.tsx index 40d71b876..5d27e335a 100644 --- a/components/Editor/components/BlockEditor/BlockEditor.tsx +++ b/components/Editor/components/BlockEditor/BlockEditor.tsx @@ -19,6 +19,12 @@ export interface BlockEditorProps { onUpdate?: (editor: Editor) => void; editable?: boolean; setEditor?: (editor: Editor | null) => void; + /** Focus the editor on mount. Defaults to `editable`. */ + autofocus?: boolean; + /** Live read-only toggle that keeps the editor instance (see useBlockEditor). */ + locked?: boolean; + /** Require a leading heading when editable (default true; see useBlockEditor). */ + requireTitle?: boolean; } export const BlockEditor: React.FC = ({ @@ -28,6 +34,9 @@ export const BlockEditor: React.FC = ({ setEditor, isLoading = false, editable = true, + autofocus, + locked, + requireTitle, }) => { const menuContainerRef = useRef(null); @@ -36,6 +45,9 @@ export const BlockEditor: React.FC = ({ contentJson, onUpdate, editable, + autofocus, + locked, + requireTitle, }); useEffect(() => { diff --git a/components/Editor/components/menus/TextMenu/hooks/useTextmenuStates.ts b/components/Editor/components/menus/TextMenu/hooks/useTextmenuStates.ts index 9a0a2b873..9754d8274 100644 --- a/components/Editor/components/menus/TextMenu/hooks/useTextmenuStates.ts +++ b/components/Editor/components/menus/TextMenu/hooks/useTextmenuStates.ts @@ -30,7 +30,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..359a40eb8 100644 --- a/components/Editor/hooks/useBlockEditor.ts +++ b/components/Editor/hooks/useBlockEditor.ts @@ -29,6 +29,9 @@ export const useBlockEditor = ({ onUpdate, customClass, includeTitle = false, + autofocus = editable, + locked = false, + requireTitle = true, }: { aiToken?: string; userId?: string; @@ -39,16 +42,32 @@ 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; + /** + * Temporarily read-only without recreating the editor: `editable` picks + * the extension set and is a creation-time choice, while this toggles + * live (e.g. while an assistant is mid-edit). Goes into the options so + * tiptap's own option re-application can't flip it back. + */ + locked?: boolean; + /** + * Editable documents must start with a heading (the note's title). Off for + * documents another writer composes — an assistant's note may open with a + * paragraph, and a schema that forbids it throws on load. + */ + requireTitle?: boolean; }) => { + const isEditable = editable && !locked; const editor = useEditor( { - editable, + editable: isEditable, immediatelyRender: false, shouldRerenderOnTransaction: false, - autofocus: editable, + autofocus, extensions: [ ...ExtensionKit({ - customDocument: editable ? CustomDocument : undefined, + customDocument: editable && requireTitle ? CustomDocument : undefined, placeholderConfig: { includeChildren: true, showOnlyCurrent: false, @@ -110,10 +129,11 @@ export const useBlockEditor = ({ ); useEffect(() => { - if (typeof window !== 'undefined' && editor) { - window.editor = editor; + if (editor && !editor.isDestroyed && editor.isEditable !== isEditable) { + // Not a content change: emitting `update` here would trigger a save. + editor.setEditable(isEditable, false); } - }, [editor]); + }, [editor, isEditable]); return { editor }; }; diff --git a/components/Editor/styles/index.css b/components/Editor/styles/index.css index d2df59433..402843b89 100644 --- a/components/Editor/styles/index.css +++ b/components/Editor/styles/index.css @@ -42,6 +42,13 @@ @apply px-4 py-3; } +/* The AI Mode document pane supplies its own padding; the editor keeps the + 64px left gutter the block "+" and drag handle render into, so they stay + inside the pane instead of floating over the chat. */ +.ai-mode-document .ProseMirror { + @apply max-w-none py-2 pl-16 pr-0; +} + [data-theme='slash-command'] { width: 1000vw; } diff --git a/components/Feed/BaseFeedItem.tsx b/components/Feed/BaseFeedItem.tsx index dc4facf63..67cfd3817 100644 --- a/components/Feed/BaseFeedItem.tsx +++ b/components/Feed/BaseFeedItem.tsx @@ -20,7 +20,6 @@ import { BountyInfoSummary } from '@/components/Bounty/BountyInfoSummary'; import { useRouter } from 'next/navigation'; import Link from 'next/link'; import { BountyInfo } from '../Bounty/BountyInfo'; -import { sanitizeHighlightHtml } from '@/components/Search/lib/htmlSanitizer'; // Base interfaces for the modular components export interface BaseFeedItemProps { @@ -65,7 +64,6 @@ export interface BadgeSectionProps { // Title component interface export interface TitleSectionProps { title: string; - highlightedTitle?: string; className?: string; href?: string; onClick?: () => void; @@ -74,7 +72,6 @@ export interface TitleSectionProps { // Content component interface export interface ContentSectionProps { content: string; - highlightedContent?: string; maxLength?: number; className?: string; } @@ -136,28 +133,13 @@ export const BadgeSection: FC = ({ ); }; -export const TitleSection: FC = ({ - title, - highlightedTitle, - className, - href, - onClick, -}) => { +export const TitleSection: FC = ({ title, className, href, onClick }) => { const titleStyles = cn( 'text-md md:!text-lg font-semibold text-gray-900 mb-1 hover:underline', className ); - const content = highlightedTitle ? ( -

    - ) : ( -

    {title}

    - ); + const content =

    {title}

    ; if (href) { return ( @@ -172,7 +154,6 @@ export const TitleSection: FC = ({ export const ContentSection: FC = ({ content, - highlightedContent, className, maxLength = 200, }) => { @@ -184,19 +165,6 @@ export const ContentSection: FC = ({ setIsExpanded(!isExpanded); }; - // If we have highlighted HTML, render it (already truncated by backend) - if (highlightedContent) { - return ( -
    -

    -

    - ); - } - // Default: render truncated plain text return (