diff --git a/app/endowment/components/EndowmentTopBar.tsx b/app/endowment/components/EndowmentTopBar.tsx
index 339e7193b..b74cd23d4 100644
--- a/app/endowment/components/EndowmentTopBar.tsx
+++ b/app/endowment/components/EndowmentTopBar.tsx
@@ -111,14 +111,9 @@ export function EndowmentTopBar() {
opacity 0.25s ease,
transform 0.25s ease;
}
- /* Clear the app nav rail, which is 70px from the tablet breakpoint and
- 240px once it expands at sidebar-compact. Below tablet the rail is
- off-canvas, so the bar spans the full width. */
- @media (min-width: 768px) {
- .endowment-topbar {
- left: 70px;
- }
- }
+ /* Clear the app's left column, which is 240px from sidebar-compact
+ up. Narrower, the sidebar is off-canvas, so the bar spans the full
+ width. */
@media (min-width: 1240px) {
.endowment-topbar {
left: 240px;
diff --git a/app/endowment/layout.tsx b/app/endowment/layout.tsx
index 6216c5233..11efc302f 100644
--- a/app/endowment/layout.tsx
+++ b/app/endowment/layout.tsx
@@ -19,10 +19,10 @@ export default function EndowmentLayout({ children }: Readonly<{ children: React
return (
{/* The landing page keeps its own top bar and footer, so it stays outside
- PageLayout; the nav rail is mounted directly to preserve continuity
- with the rest of the app. Passing `isOpen={false}` leaves it
- off-canvas below the tablet breakpoint, where there is no menu button
- to reopen it. */}
+ PageLayout; the app's left column is mounted directly to preserve
+ continuity with the rest of the app. Passing `isOpen={false}` leaves
+ it off-canvas below 1240px, where this page has no menu button to
+ open it. */}
{children}
diff --git a/app/globals.css b/app/globals.css
index 31dc8791b..ba79ec5ef 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -88,6 +88,28 @@ body {
}
}
+ /* The workspace page's lists and panes arrive by opacity alone, while the
+ logo, the Publish button and the top bar around them hold still. Not
+ `.animate-in`, which also slides. */
+ .workspace-fade-in {
+ animation: workspaceFadeIn 0.2s ease-out;
+ }
+
+ @keyframes workspaceFadeIn {
+ from {
+ opacity: 0;
+ }
+ to {
+ opacity: 1;
+ }
+ }
+
+ @media (prefers-reduced-motion: reduce) {
+ .workspace-fade-in {
+ animation: none;
+ }
+ }
+
/* Hide scrollbar for Chrome, Safari and Opera */
.scrollbar-hide::-webkit-scrollbar {
display: none;
diff --git a/app/journal/page.tsx b/app/journal/page.tsx
index 61ff3e5fb..86850b5d0 100644
--- a/app/journal/page.tsx
+++ b/app/journal/page.tsx
@@ -37,7 +37,7 @@ const PIPELINE_STEPS = [
] as const;
const JournalIssueCards = () => (
-
+ {/* Wide screens only, and no taller than the text beside it, as on My Funding. */}
+
diff --git a/app/layouts/LeftSidebar.tsx b/app/layouts/LeftSidebar.tsx
index 647a5d1ff..cf4433875 100644
--- a/app/layouts/LeftSidebar.tsx
+++ b/app/layouts/LeftSidebar.tsx
@@ -2,7 +2,6 @@
import { AlertCircle } from 'lucide-react';
import { usePathname } from 'next/navigation';
-import { FooterLinks } from '../../components/FooterLinks';
import { Navigation } from './Navigation';
import toast from 'react-hot-toast';
import { PublishMenu } from './PublishMenu';
@@ -10,12 +9,19 @@ import { Logo } from '@/components/ui/Logo';
import Link from 'next/link';
import { Icon } from '@/components/ui/icons';
import { FundingPowerRailButton } from '@/components/Funding/FundingPowerRailButton';
+import { SidebarDocuments } from './components/SidebarDocuments';
+import { SidebarHelpMenu } from './components/SidebarHelpMenu';
interface LeftSidebarProps {
forceMinimize?: boolean;
+ /**
+ * Runs once something in it is picked: below 1240px the sidebar is a menu,
+ * and a pick that changes the page in place must still close it.
+ */
+ onNavigate?: () => void;
}
-export const LeftSidebar: React.FC = ({ forceMinimize = false }) => {
+export const LeftSidebar: React.FC = ({ forceMinimize = false, onNavigate }) => {
const pathname = usePathname();
const handleUnimplementedFeature = (featureName: string) => {
@@ -43,32 +49,35 @@ export const LeftSidebar: React.FC = ({ forceMinimize = false
return (
-
-
-
+
+
+
-
+
-
-
+
+
-
+ {/* The nav and the user's documents scroll together, so a long list
+ never squeezes the nav. A forced icon rail has no room for them. */}
+
+
+
+
- {/* Navigation above is flex-1, so this sits at the bottom of the column.
+ {/* The scroll area above is flex-1, so this sits at the bottom of the column.
Only covers 768px to the right sidebar's breakpoint: below that the
bar is docked over the mobile bottom nav, and above it the funding
power card is in the right sidebar. */}
@@ -76,8 +85,8 @@ export const LeftSidebar: React.FC = ({ forceMinimize = false
);
};
@@ -269,16 +216,15 @@ export const Navigation: React.FC = ({
);
diff --git a/app/layouts/PageLayout.tsx b/app/layouts/PageLayout.tsx
index f3cc9f54f..d0d106506 100644
--- a/app/layouts/PageLayout.tsx
+++ b/app/layouts/PageLayout.tsx
@@ -27,10 +27,17 @@ const MobileBottomNav = dynamic(
interface PageLayoutProps {
children: ReactNode;
rightSidebar?: boolean | ReactNode;
+ /** Align a custom sidebar with the main column's padded content. */
+ rightSidebarTopOffset?: 'default' | 'aligned';
className?: string;
sidebarContentClassName?: string;
topBanner?: ReactNode;
rightSidebarAbove?: ReactNode;
+ /**
+ * With a card above it the gray rail normally sizes to its content; this
+ * keeps it stretched to the bottom of the column regardless.
+ */
+ rightSidebarFill?: boolean;
fundraiseGrantId?: number;
/**
* `default` centers the page in a 1180px container and lets the main column
@@ -45,17 +52,26 @@ interface PageLayoutProps {
* of empty space beside the content.
*/
wideRow?: boolean;
+ /**
+ * The page is an app surface, not a document: it fills the area beside the
+ * left column and under the top bar edge to edge and manages its own
+ * scrolling. No page container, padding, right sidebar or mobile bottom nav.
+ */
+ fullBleed?: boolean;
}
function PageLayoutInner({
children,
rightSidebar = true,
+ rightSidebarTopOffset = 'default',
className,
sidebarContentClassName,
topBanner,
rightSidebarAbove,
+ rightSidebarFill = false,
contentWidth = 'default',
wideRow = false,
+ fullBleed = false,
}: PageLayoutProps) {
const isNarrow = contentWidth === 'narrow';
const pathname = usePathname() || '';
@@ -93,63 +109,84 @@ function PageLayoutInner({
-
-
- {/* Scrollable content area.
- Mobile: top padding clears the fixed top bar (needed so content
- can scroll under the hide-on-scroll bar).
- Tablet+: top margin instead, so the scrollport starts below the bar.
- When the EndowmentPromoBanner is visible above the TopBar on mobile
- we add extra top padding to clear the banner + topbar stack.
- The banner itself is hidden at >= 768px (tablet:!hidden) so the
- offset is reset by the inner media query below.
- Bottom padding on mobile clears the fixed MobileBottomNav. */}
-
- {topBanner &&
{topBanner}
}
+
+ {fullBleed ? (
+ // Offset from the fixed top bar exactly as the scrollport below is,
+ // but nothing here scrolls: the page lays out its own panes in the
+ // height that is left, so the top bar never hides on a phone. The bottom
+ // nav is the same bar as everywhere else, and its room is kept below.
-
+
+ ) : (
+ /* The scrollport. Padding clears the fixed top bar on phones, where it
+ hides on scroll; a margin does from the tablet breakpoint up. The
+ `page-layout-*` classes reserve room for the promo banner and the
+ bottom nav where those show. */
+
+ {topBanner &&
{topBanner}
}
+
+
-
- {children}
-
-
+
+ {children}
+
+
- {rightSidebar && (
-
- )}
-
+ {rightSidebar && (
+
+ )}
+
-
-
+
+
+ )}
);
diff --git a/app/layouts/PublishMenu.tsx b/app/layouts/PublishMenu.tsx
index dceae1b10..c259c8e32 100644
--- a/app/layouts/PublishMenu.tsx
+++ b/app/layouts/PublishMenu.tsx
@@ -1,122 +1,53 @@
'use client';
-import { ChevronRight, Plus } from 'lucide-react';
-import { useRouter } from 'next/navigation';
-import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
-import { faBullhorn, faFileSignature } from '@fortawesome/pro-light-svg-icons';
-import { BaseMenu, BaseMenuItem } from '@/components/ui/form/BaseMenu';
+import { Plus } from 'lucide-react';
+import { BaseMenu } from '@/components/ui/form/BaseMenu';
import { useAuthenticatedAction } from '@/contexts/AuthModalContext';
import { SwipeableDrawer } from '@/components/ui/SwipeableDrawer';
import {
- OpenFundingOpportunityModal,
- type FundingOpportunityCreationMethod,
-} from '@/components/Funding/OpenFundingOpportunityModal';
-import {
- OpenProposalModal,
- type ProposalCreationMethod,
-} from '@/components/Funding/OpenProposalModal';
+ FUNDING_DRAFT_OPTIONS,
+ FundingDraftMenuItems,
+ FundingDraftOptionContent,
+ type FundingDraftOption,
+} from '@/components/Funding/fundingDraftOptions';
+import { useFundingDrafting } from '@/components/Funding/useFundingDrafting';
import { useScreenSize } from '@/hooks/useScreenSize';
import { useState } from 'react';
interface PublishMenuProps {
forceMinimize?: boolean;
+ /** Runs once an item is picked, for a host that should then get out of the way. */
+ onItemSelected?: () => void;
}
-const PUBLISH_MENU_SECTIONS = [
- {
- title: 'Publish on ResearchHub',
- items: [
- {
- id: 'give-funding',
- title: 'Request for Proposal',
- description: 'Fund specific research you care about',
- icon: ,
- handler: 'handleOpenGrant',
- },
- {
- id: 'request-funding',
- title: 'Proposal',
- description: 'Raise money for your research',
- icon: (
-
- ),
- handler: 'handleFundResearch',
- },
- ],
- },
-] as const;
-
-interface MenuItemContentProps {
- icon: React.ReactNode;
- title: string;
- description: string;
-}
-
-const MenuItemContent: React.FC = ({ icon, title, description }) => {
- return (
-
-
-
- {icon}
-
-
-
-
{title}
-
{description}
-
-
-
- );
-};
-
-export const PublishMenu: React.FC = ({ forceMinimize = false }) => {
- const router = useRouter();
+/**
+ * The sidebar's Publish button: a Request for Proposal or a Proposal, either
+ * of which leads straight to a new draft in the workspace for that side of
+ * the money.
+ */
+export const PublishMenu: React.FC = ({
+ forceMinimize = false,
+ onItemSelected,
+}) => {
const { executeAuthenticatedAction } = useAuthenticatedAction();
+ const { startNew } = useFundingDrafting();
const { smAndDown } = useScreenSize();
const [isMobileDrawerOpen, setIsMobileDrawerOpen] = useState(false);
- const [isFundingOpportunityModalOpen, setIsFundingOpportunityModalOpen] = useState(false);
- const [isProposalModalOpen, setIsProposalModalOpen] = useState(false);
-
- const handleFundResearch = () => {
- setIsProposalModalOpen(true);
- };
-
- const handleConfirmCreateProposal = (method: ProposalCreationMethod) => {
- setIsProposalModalOpen(false);
- router.push(`/notebook?newFunding=true&proposalSource=${method}`);
- };
-
- const handleOpenGrant = () => {
- setIsFundingOpportunityModalOpen(true);
- };
- const handleConfirmOpenGrant = (method: FundingOpportunityCreationMethod) => {
- setIsFundingOpportunityModalOpen(false);
- router.push(`/notebook?newGrant=true&grantSource=${method}`);
- };
-
- const handleMenuItemClick = (item: (typeof PUBLISH_MENU_SECTIONS)[number]['items'][number]) => {
- executeAuthenticatedAction(() => {
- switch (item.handler) {
- case 'handleFundResearch':
- handleFundResearch();
- break;
- case 'handleOpenGrant':
- handleOpenGrant();
- break;
- }
- });
+ const handleMenuItemClick = (item: FundingDraftOption) => {
+ executeAuthenticatedAction(() => startNew(item.intent));
// Close mobile drawer after action
if (smAndDown) {
setIsMobileDrawerOpen(false);
}
+ onItemSelected?.();
};
// Regular trigger for standard mode
const standardTrigger = (
{
e.stopPropagation();
e.preventDefault();
@@ -131,10 +62,10 @@ export const PublishMenu: React.FC = ({ forceMinimize = false
);
- // Compact trigger for minimized sidebar
+ // Compact trigger for a sidebar forced into the icon rail
const compactTrigger = (
{
e.stopPropagation();
e.preventDefault();
@@ -148,59 +79,27 @@ export const PublishMenu: React.FC = ({ forceMinimize = false
);
- const menuContent = (
-
);
};
diff --git a/app/layouts/TopBar.tsx b/app/layouts/TopBar.tsx
index 3780fd1e6..d1c3783cb 100644
--- a/app/layouts/TopBar.tsx
+++ b/app/layouts/TopBar.tsx
@@ -7,13 +7,14 @@ import { useRouter, usePathname } from 'next/navigation';
import { useAuthModalContext } from '@/contexts/AuthModalContext';
import { useNotifications } from '@/contexts/NotificationContext';
import Link from 'next/link';
+import { Menu } from 'lucide-react';
import { calculateProfileCompletion } from '@/utils/profileCompletion';
-import { Logo } from '@/components/ui/Logo';
import { Tabs } from '@/components/ui/Tabs';
import { useFundTabs } from '@/hooks/useFundTabs';
import { useFeedTabsVisibility } from '@/contexts/FeedTabsVisibilityContext';
import { useTopBarSlot } from '@/contexts/TopBarSlotContext';
import { useSmartBack } from '@/hooks/useSmartBack';
+import { Icon } from '@/components/ui/icons';
import { usePendingCounts } from '@/components/Moderators/PendingCountsContext';
import { getPageInfo, isRootNavigationPage } from './topbar/pageRoutes';
@@ -22,10 +23,12 @@ import { TopBarBreadcrumb } from './topbar/TopBarBreadcrumb';
import { TopBarUserControls } from './topbar/TopBarUserControls';
interface TopBarProps {
+ /** Opens or closes the app's menu: the left sidebar, below the width at which it is a column. */
onMenuClick: () => void;
+ readonly isMenuOpen?: boolean;
}
-export function TopBar({ onMenuClick }: TopBarProps) {
+export function TopBar({ onMenuClick, isMenuOpen = false }: TopBarProps) {
const { user, isLoading } = useUser();
const router = useRouter();
const pathname = usePathname();
@@ -48,9 +51,17 @@ export function TopBar({ onMenuClick }: TopBarProps) {
// default breadcrumb.
const topBarSlot = useTopBarSlot();
const leftSlot = topBarSlot?.leftSlot;
-
- const pageInfo = getPageInfo(pathname);
- const showBackButton = pageInfo && !isRootNavigationPage(pathname);
+ // Or keep the back arrow and name what the page has open instead of the page.
+ const openTitle = topBarSlot?.title ?? null;
+
+ const routePageInfo = getPageInfo(pathname);
+ // '' from the page: it has not loaded what it names yet (see TopBarSlotContext).
+ const pageInfo =
+ routePageInfo && openTitle != null
+ ? { ...routePageInfo, title: openTitle, loading: openTitle === '' }
+ : routePageInfo;
+ // Nothing of the title's row until it is known: the arrow would stand alone.
+ const showBackButton = pageInfo && !pageInfo.loading && !isRootNavigationPage(pathname);
const profilePercent = useCallback(() => {
if (!user) return 100;
@@ -84,10 +95,24 @@ export function TopBar({ onMenuClick }: TopBarProps) {
{/* Left side */}
-
-
-
-
+ {/* The left sidebar is a column from 1240px up; narrower, this opens it. */}
+
+
+
+
+ {/* The sidebar holds the logo from 1240px up; narrower, it sits here. */}
+
+
{leftSlot ? (
@@ -108,7 +133,10 @@ export function TopBar({ onMenuClick }: TopBarProps) {
{pageInfo && (
)}
>
diff --git a/app/layouts/components/EndowmentPromoBanner.tsx b/app/layouts/components/EndowmentPromoBanner.tsx
index f3e31993f..373a00f80 100644
--- a/app/layouts/components/EndowmentPromoBanner.tsx
+++ b/app/layouts/components/EndowmentPromoBanner.tsx
@@ -30,7 +30,7 @@ const EndowmentPromoBannerComponent: React.FC = () => {
return (
void;
}
-export function LeftSidebarContainer({ isOpen }: LeftSidebarContainerProps) {
+/**
+ * The app's left column from 1240px up. Narrower than that there is no
+ * column: the top bar's menu button slides the whole sidebar in, over the
+ * top bar and the page.
+ */
+export function LeftSidebarContainer({ isOpen, onClose }: LeftSidebarContainerProps) {
return (
-
+
);
}
diff --git a/app/layouts/components/MobileOverlay.tsx b/app/layouts/components/MobileOverlay.tsx
index e198ae091..514dd8368 100644
--- a/app/layouts/components/MobileOverlay.tsx
+++ b/app/layouts/components/MobileOverlay.tsx
@@ -13,7 +13,7 @@ export function MobileOverlay({ show, visible, onClose }: MobileOverlayProps) {
);
diff --git a/app/layouts/components/RightSidebarContainer.tsx b/app/layouts/components/RightSidebarContainer.tsx
index 6e114b052..31f1a82d6 100644
--- a/app/layouts/components/RightSidebarContainer.tsx
+++ b/app/layouts/components/RightSidebarContainer.tsx
@@ -4,12 +4,12 @@ import { ReactNode, Suspense } from 'react';
import { cn } from '@/lib/utils';
import { useWorkTab } from '@/components/work/WorkHeader/WorkTabContext';
import { SwipeableDrawer } from '@/components/ui/SwipeableDrawer';
-import { FundSidebar } from '@/components/Funding/FundSidebar';
+import { RecentlyVisited } from '@/components/RecentlyVisited/RecentlyVisited';
import { FundingPowerCard } from '@/components/Funding/FundingPowerCard';
function RightSidebarContent({ rightSidebar }: { rightSidebar: boolean | ReactNode }) {
if (typeof rightSidebar === 'boolean') {
- return ;
+ return ;
}
return <>{rightSidebar}>;
@@ -18,13 +18,19 @@ function RightSidebarContent({ rightSidebar }: { rightSidebar: boolean | ReactNo
interface RightSidebarContainerProps {
rightSidebar: boolean | ReactNode;
contentClassName?: string;
+ /** Align a custom sidebar's panel with the main column's padded content. */
+ topOffset?: 'default' | 'aligned';
aboveSidebar?: ReactNode;
+ /** Stretch the gray rail to the bottom of the column even with a card above it. */
+ fill?: boolean;
}
export function RightSidebarContainer({
rightSidebar,
contentClassName,
+ topOffset = 'default',
aboveSidebar,
+ fill = false,
}: RightSidebarContainerProps) {
const { mobileSidebarOpen, setMobileSidebarOpen } = useWorkTab();
const isDefaultSidebar = typeof rightSidebar === 'boolean';
@@ -40,7 +46,8 @@ export function RightSidebarContainer({
<>
diff --git a/app/layouts/components/SidebarDocuments.tsx b/app/layouts/components/SidebarDocuments.tsx
new file mode 100644
index 000000000..58af6a850
--- /dev/null
+++ b/app/layouts/components/SidebarDocuments.tsx
@@ -0,0 +1,170 @@
+'use client';
+
+import type { ReactNode } from 'react';
+import Link from 'next/link';
+import { FileText, MoveRight, Plus } from 'lucide-react';
+import { useOptionalAIMode } from '@/components/AIMode/AIModeContext';
+import { AssistantActivityDot } from '@/components/AgentChat/AssistantActivityDot';
+import { FundingDraftMenuItems } from '@/components/Funding/fundingDraftOptions';
+import { useFundingDrafting } from '@/components/Funding/useFundingDrafting';
+import { BaseMenu } from '@/components/ui/form/BaseMenu';
+import { useFundingDocuments } from '@/contexts/FundingDocumentsContext';
+import type { Note } from '@/types/note';
+import { cn } from '@/utils/styles';
+import { SidebarRow } from './SidebarRow';
+
+/** Rows shown per section; everything else is a click away in My Funding. */
+const DRAFTS_SHOWN = 8;
+const PUBLISHED_SHOWN = 5;
+
+interface SidebarDocumentsProps {
+ /** Runs after any choice that should put a drawer or an overlay away. */
+ readonly onNavigate?: () => void;
+ readonly className?: string;
+}
+
+/**
+ * The user's RFPs and proposals under the app's nav, on every page: what is
+ * still a draft, with a "+" to start another, and what has been published.
+ * Each opens in the workspace; the one open there is highlighted.
+ */
+export function SidebarDocuments({ onNavigate, className }: SidebarDocumentsProps) {
+ const { drafts, published, status, refresh } = useFundingDocuments();
+ const { startNew, openDraft } = useFundingDrafting();
+ const aiMode = useOptionalAIMode();
+ const target = aiMode?.target;
+ const workingNoteId = aiMode?.workingNoteId ?? null;
+ const activeNoteId = target?.kind === 'document' ? target.noteId : null;
+
+ if (status === 'off') return null;
+
+ const open = (note: Note) => {
+ // The open document stays on the chat it is on.
+ if (note.id !== activeNoteId) openDraft(note);
+ onNavigate?.();
+ };
+ const rows = (notes: readonly Note[], shown: number) =>
+ notes
+ // The open document keeps its row, even when it is older than the rest.
+ .filter((note, index) => index < shown || note.id === activeNoteId)
+ .map((note) => (
+ }
+ trailing={
+ note.id === workingNoteId ? (
+
+ ) : undefined
+ }
+ isActive={note.id === activeNoteId}
+ onSelect={() => open(note)}
+ />
+ ));
+
+ const ready = status === 'ready';
+ const hasPublished = ready && published.length > 0;
+
+ return (
+
- {pageInfo.title ? (
+ {pageInfo.loading ? (
+
+ ) : pageInfo.title ? (
- {pageInfo.title}
+ {shortTitle && !isMobile ? (
+ <>
+ {/* The bar is as wide as the screen under 1240px, and 240px narrower past it. */}
+
+ {pageInfo.title}
+
+
+ {shortTitle}
+
+ >
+ ) : (
+ pageInfo.title
+ )}
) : (
pageInfo.icon && (
diff --git a/app/layouts/topbar/pageRoutes.tsx b/app/layouts/topbar/pageRoutes.tsx
index dc3f5306e..443d70f79 100644
--- a/app/layouts/topbar/pageRoutes.tsx
+++ b/app/layouts/topbar/pageRoutes.tsx
@@ -16,6 +16,8 @@ import { HOME_TAB_PATHS, isHomeTabPath } from '@/hooks/useFundTabs';
export interface PageInfo {
title: string;
icon?: React.ReactNode;
+ /** The page names what it has open and has not yet: a placeholder stands in. */
+ loading?: boolean;
}
export const ROOT_NAVIGATION_PATHS = new Set([
@@ -61,6 +63,14 @@ const ROUTE_RULES: RouteRule[] = [
icon: ,
}),
},
+ {
+ // Not a root navigation page, so the top bar gives it the back arrow:
+ // that arrow is the way out of the workspace. It always names what it has
+ // open (a document, or the draft it will start) rather than itself, so
+ // until it does, the title is a placeholder rather than "Workspace".
+ match: (p) => p === '/workspace',
+ getInfo: () => ({ title: '', loading: true }),
+ },
{
match: (p) => p === '/notifications',
getInfo: () => ({
diff --git a/app/my-funding/components/FundsReceived.tsx b/app/my-funding/components/FundsReceived.tsx
deleted file mode 100644
index ce77ae11f..000000000
--- a/app/my-funding/components/FundsReceived.tsx
+++ /dev/null
@@ -1,151 +0,0 @@
-'use client';
-
-import type { ReactNode } from 'react';
-import Link from 'next/link';
-import { EarnEarningsSummary } from '@/components/Earn/EarnEarningsSummary';
-import { FeedContent } from '@/components/Feed/FeedContent';
-import { buttonVariants } from '@/components/ui/Button';
-import { useActivityFeed } from '@/hooks/useActivityFeed';
-import { useFeed } from '@/hooks/useFeed';
-import type { ActivityCommentType } from '@/services/activity.service';
-import { cn } from '@/utils/styles';
-
-interface FundsReceivedProps {
- userId: number;
- authorId?: number;
-}
-
-/** Stable reference: a new array on every render would restart the activity feed. */
-const PEER_REVIEW_COMMENT_TYPES: readonly ActivityCommentType[] = ['REVIEW', 'PEER_REVIEW'];
-
-function EmptyState({ children }: Readonly<{ children: ReactNode }>) {
- return (
-
- );
-}
diff --git a/app/my-funding/components/MyFundingActivityContent.tsx b/app/my-funding/components/MyFundingActivityContent.tsx
new file mode 100644
index 000000000..131dfca54
--- /dev/null
+++ b/app/my-funding/components/MyFundingActivityContent.tsx
@@ -0,0 +1,40 @@
+'use client';
+
+import { MyFundingActivityFeed } from '@/components/Funding/dashboard/MyFundingActivityFeed';
+import type { MyFundingActivity } from '@/components/Funding/dashboard/hooks/useMyFundingActivity';
+import { MyFundingActivityEmptyState } from './MyFundingActivityEmptyState';
+import { MyFundingDataError } from './MyFundingDataError';
+
+interface MyFundingActivityContentProps {
+ readonly activity: MyFundingActivity;
+ /** When the user last looked; later news is marked new. */
+ readonly seenAt: number | null;
+ /** A scientist the user backs, named in the empty state. */
+ readonly firstScientist?: string;
+ readonly hasRfps: boolean;
+}
+
+/** The Activity tab. */
+export function MyFundingActivityContent({
+ activity,
+ seenAt,
+ firstScientist,
+ hasRfps,
+}: MyFundingActivityContentProps) {
+ return (
+
+ {activity.error && (
+
+
+
+ )}
+
+ }
+ />
+
+ );
+}
diff --git a/app/my-funding/components/MyFundingActivityEmptyState.tsx b/app/my-funding/components/MyFundingActivityEmptyState.tsx
new file mode 100644
index 000000000..fc6428fb6
--- /dev/null
+++ b/app/my-funding/components/MyFundingActivityEmptyState.tsx
@@ -0,0 +1,95 @@
+import Link from 'next/link';
+import { Activity, Bell, Megaphone, Star, type LucideIcon } from 'lucide-react';
+import { DISPLAY_FONT } from '@/components/Funding/dashboard/MyFundingHero';
+import { activityPromise } from '@/components/Funding/dashboard/RecentActivityPreview';
+import { cn } from '@/utils/styles';
+
+interface MyFundingActivityEmptyStateProps {
+ /** A scientist the user backs, so the promise names someone real. */
+ readonly firstScientist?: string;
+ readonly hasRfps: boolean;
+}
+
+interface ComingKind {
+ readonly label: string;
+ readonly Icon: LucideIcon;
+ readonly tint: string;
+ /** Shown only once the user does what makes it possible. */
+ readonly note?: string;
+}
+
+/**
+ * The Activity tab before anything has happened: what will show up here,
+ * drawn as the rows it will become, and the ways to make it happen sooner.
+ */
+export function MyFundingActivityEmptyState({
+ firstScientist,
+ hasRfps,
+}: MyFundingActivityEmptyStateProps) {
+ const kinds: ComingKind[] = [
+ {
+ label: 'Updates from the scientists you back',
+ Icon: Bell,
+ tint: 'bg-primary-50 text-primary-600',
+ },
+ { label: 'Peer reviews of the work you fund', Icon: Star, tint: 'bg-amber-50 text-amber-700' },
+ {
+ label: 'Updates and reviews on proposals sent to your RFPs',
+ Icon: Megaphone,
+ tint: 'bg-primary-50 text-primary-600',
+ note: hasRfps ? undefined : 'Once you open an RFP',
+ },
+ ];
+
+ return (
+
+
+
+
+
+ )}
+
+ {activeTab === 'activity' && !isEmpty ? (
+
+ ) : (
+ [document.postId, document.entry]))}
+ proposalEntries={
+ new Map(
+ documents.published
+ .filter((document) => document.kind === 'proposal')
+ .map((document) => [document.postId, document.entry])
+ )
+ }
+ reviews={{
+ entries: readsReviews ? reviews.entries : [],
+ total: reviewTotal,
+ hasMore: reviews.hasMore,
+ isLoadingMore: reviews.isLoadingMore,
+ loadMore: reviews.loadMore,
+ }}
+ activity={activity}
+ now={now}
+ seen={seen}
+ onTabChange={handleTabChange}
+ />
+ )}
+
+ setPeopleOpen(null)}
+ title="Scientists you support"
+ people={people.map((person) => ({
+ profile: person.profile,
+ detail: person.proposalTitle,
+ amount: format(person.youGave, { shorten: true }),
+ }))}
+ />
+ setPeopleOpen(null)}
+ title="Your funders"
+ people={ownFunders.map((profile) => ({ profile }))}
/>
+
+ );
+}
+
+/**
+ * The hero's deck: the work the page is about (funded proposals, the user's
+ * own, their RFPs), one of each kind first. A deck with fewer than two cards
+ * is filled out with generic ones, a green proposal and a blue RFP.
+ */
+function heroCovers(
+ fundedRows: readonly FundedRow[],
+ ownProposals: readonly OwnProposalModel[],
+ rfps: readonly RfpCardModel[],
+ format: (amount: Money, options?: { shorten?: boolean }) => string
+): HeroCover[] {
+ // Ordered by what was given and described by it, so the deck is the same
+ // before and after the activity loads.
+ // The overview has no covers, so a funded proposal is drawn with the
+ // proposal icon and named by what was given to it.
+ const funded = [...fundedRows]
+ .sort((a, b) => b.youGave.usd - a.youGave.usd || b.youGave.rsc - a.youGave.rsc)
+ .map(
+ (row): HeroCover => ({
+ key: row.key,
+ title: row.title,
+ image: null,
+ status: `You gave ${format(row.youGave, { shorten: true })}`,
+ detail: row.scientist.fullName,
+ })
+ );
+ const own = ownProposals.map(
+ (proposal): HeroCover => ({
+ key: proposal.key,
+ title: proposal.title,
+ image: proposal.image,
+ ...(proposal.status === 'raising'
+ ? {
+ percent: proposal.percent ?? 0,
+ status: `${format(proposal.raised, { shorten: true })} of ${format(proposal.goal, { shorten: true })}`,
+ }
+ : {
+ status: proposal.status === 'closed' ? 'Closed' : 'Funded',
+ detail: `${proposal.funderCount} ${proposal.funderCount === 1 ? 'funder' : 'funders'}`,
+ }),
+ })
+ );
+ const rfpCovers = rfps.map(
+ (rfp): HeroCover => ({
+ key: rfp.key,
+ title: rfp.title,
+ image: rfp.image,
+ tone: 'blue',
+ status: rfp.isOpen ? 'RFP · open' : 'RFP · closed',
+ detail: `${rfp.proposalCount} ${rfp.proposalCount === 1 ? 'proposal' : 'proposals'}`,
+ })
+ );
+
+ // One of each kind before seconds, so a busy page still shows its range.
+ const groups = [funded, own, rfpCovers];
+ const real: HeroCover[] = [];
+ for (let round = 0; real.length < 3 && groups.some((group) => group.length > round); round++) {
+ for (const group of groups) if (group[round] && real.length < 3) real.push(group[round]);
+ }
+ if (real.length >= 2) return real;
+ if (real.length === 0) return GENERIC_COVERS;
+ // One real card: pair it with the generic card of the other kind.
+ const realIsRfp = rfpCovers.includes(real[0]);
+ return [real[0], GENERIC_COVERS[realIsRfp ? 0 : 1]];
+}
-
- {activeTab === 'given' ? (
-
- ) : (
-
- )}
+/**
+ * The header's shape while its contents are being worked out: the headline,
+ * the line under it, the deck and the tab bar, at the sizes they will have.
+ */
+function HeroSkeleton() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+/** The whole page while the signed-in account is still loading. */
+function MyFundingLoading() {
+ return (
+ }
+ rightSidebarAbove={}
+ rightSidebarTopOffset="aligned"
+ topBanner={}
+ >
+
+ {[0, 1, 2].map((index) => (
+
+ ))}
+
);
}
diff --git a/app/my-funding/components/MyFundingSignedOut.tsx b/app/my-funding/components/MyFundingSignedOut.tsx
new file mode 100644
index 000000000..8cf543e22
--- /dev/null
+++ b/app/my-funding/components/MyFundingSignedOut.tsx
@@ -0,0 +1,52 @@
+'use client';
+
+import Link from 'next/link';
+import { PageLayout } from '@/app/layouts/PageLayout';
+import { FundingPowerCard } from '@/components/Funding/FundingPowerCard';
+import { MyFundingEmptyState } from '@/components/Funding/dashboard/MyFundingEmptyState';
+import { MyFundingWelcomeHero } from '@/components/Funding/dashboard/MyFundingHero';
+import { MyFundingRail } from '@/components/Funding/dashboard/MyFundingRail';
+import { useAuthModalContext } from '@/contexts/AuthModalContext';
+
+/**
+ * My Funding for someone who has not signed in: what the page will hold once
+ * they take part, a way to sign in, drafting an RFP or a proposal, and the
+ * proposals and RFPs open right now so the preview is real rather than a
+ * promise. Its width and rail are the signed-in page's.
+ */
+export function MyFundingSignedOut() {
+ const { showAuthModal } = useAuthModalContext();
+
+ return (
+ }
+ rightSidebarAbove={}
+ rightSidebarTopOffset="aligned"
+ topBanner={
+
+ showAuthModal()}
+ className="inline-flex h-11 items-center rounded-lg bg-primary-500 px-5 text-[15px] font-semibold text-white hover:bg-primary-600"
+ >
+ Sign in
+
+
+ Browse proposals
+
+ >
+ }
+ />
+ }
+ >
+
+
+ );
+}
diff --git a/app/my-funding/components/myFundingCopy.tsx b/app/my-funding/components/myFundingCopy.tsx
new file mode 100644
index 000000000..976c9134c
--- /dev/null
+++ b/app/my-funding/components/myFundingCopy.tsx
@@ -0,0 +1,110 @@
+import type { ReactNode } from 'react';
+import { HeadlineAmount } from '@/components/Funding/dashboard/MyFundingHero';
+import type { Money } from '@/components/Funding/dashboard/lib/myFundingModel';
+
+/** What the page knows about the user's funding, enough to write its hero. */
+export interface HeroFacts {
+ readonly isOwnPage: boolean;
+ readonly given: Money;
+ readonly raised: Money;
+ readonly asked: Money;
+ readonly reviewEarned: Money;
+ readonly fundedCount: number;
+ readonly rfpCount: number;
+ readonly rfpBudget: Money;
+ readonly ownCount: number;
+}
+
+export interface HeroCopy {
+ readonly headline: ReactNode;
+ /** The same for everyone: the figures are in the headline, the tabs and the rail. */
+ readonly line: string;
+ /** Which side leads the page when the user is on both. */
+ readonly lead: 'giving' | 'raising';
+}
+
+const LINE = 'Track your contributions and the science they support.';
+
+const has = (amount: Money) => amount.usd > 0 || amount.rsc > 0;
+
+/**
+ * The hero's headline, built from what the user has done: what they gave,
+ * what they raised, the RFPs they run, and what they earned reviewing.
+ * Nobody tells the page which they are.
+ */
+export function buildHeroCopy(
+ facts: HeroFacts,
+ format: (amount: Money, options?: { shorten?: boolean }) => string
+): HeroCopy {
+ const gave = has(facts.given) || facts.fundedCount > 0;
+ const raised = facts.isOwnPage && has(facts.raised);
+ const lead = gave && raised && facts.raised.usd > facts.given.usd ? 'raising' : 'giving';
+
+ if (gave && raised) {
+ return {
+ lead,
+ line: LINE,
+ headline: (
+ <>
+ You’ve given {format(facts.given)} and raised{' '}
+ {format(facts.raised)}.
+ >
+ ),
+ };
+ }
+
+ if (gave) {
+ const who = facts.isOwnPage ? 'You’ve' : 'This funder has';
+ return {
+ lead,
+ line: LINE,
+ headline: (
+ <>
+ {who} put {format(facts.given)} into science.
+ >
+ ),
+ };
+ }
+
+ if (facts.isOwnPage && facts.ownCount > 0) {
+ return {
+ lead: 'raising',
+ line: LINE,
+ headline: raised ? (
+ <>
+ You’ve raised {format(facts.raised)} for
+ your research.
+ >
+ ) : (
+ <>
+ You’re raising{' '}
+ {format(facts.asked, { shorten: true })}{' '}
+ for your research.
+ >
+ ),
+ };
+ }
+
+ if (facts.rfpCount > 0) {
+ return {
+ lead: 'giving',
+ line: LINE,
+ headline: (
+ <>
+ Your RFPs offer {format(facts.rfpBudget)} for research.
+ >
+ ),
+ };
+ }
+
+ return {
+ lead: 'raising',
+ line: LINE,
+ headline: (
+ <>
+ You’ve earned {format(facts.reviewEarned)}{' '}
+ reviewing research.
+ >
+ ),
+ };
+}
diff --git a/app/my-funding/page.tsx b/app/my-funding/page.tsx
index b0a8a30a2..96422288c 100644
--- a/app/my-funding/page.tsx
+++ b/app/my-funding/page.tsx
@@ -1,10 +1,11 @@
+import 'cal-sans/index.css';
import { Metadata } from 'next';
import { buildOpenGraphMetadata } from '@/lib/metadata';
import { MyFundingPage } from './components/MyFundingPage';
export const metadata: Metadata = buildOpenGraphMetadata({
title: 'My Funding',
- description: 'Track the funding you give and the earnings you receive.',
+ description: 'Manage your RFPs and proposals, track funding activity, and view your earnings.',
url: '/my-funding',
});
diff --git a/app/notebook/[orgSlug]/page.tsx b/app/notebook/[orgSlug]/page.tsx
index 046727ae6..cdae9f313 100644
--- a/app/notebook/[orgSlug]/page.tsx
+++ b/app/notebook/[orgSlug]/page.tsx
@@ -2,17 +2,13 @@
import { useOrganizationContext } from '@/contexts/OrganizationContext';
import { useNotebookContext } from '@/contexts/NotebookContext';
-import { useEffect } from 'react';
+import { useEffect, useState } from 'react';
import { useSearchParams, useRouter } from 'next/navigation';
import proposalTemplate from '@/components/Editor/lib/data/proposalTemplate';
import { initialContent } from '@/components/Editor/lib/data/initialContent';
import grantTemplate from '@/components/Editor/lib/data/grantTemplate';
-import {
- getDocumentTitle,
- getTemplatePlainText,
-} from '@/components/Editor/lib/utils/documentTitle';
-import { useCreateNote, useNoteContent } from '@/hooks/useNote';
-import { NoteService } from '@/services/note.service';
+import { getDocumentTitle } from '@/components/Editor/lib/utils/documentTitle';
+import { createFundingDraft } from '@/components/Funding/createFundingDraft';
import { NoteCreationPopover } from '@/components/Notebook/NoteCreationPopover';
import { useUser } from '@/contexts/UserContext';
import type { ID } from '@/types/root';
@@ -34,8 +30,7 @@ export default function OrganizationPage() {
const { user, isLoading: isLoadingUser } = useUser();
const isModerator = !!user?.isModerator;
- const [{ isLoading: isCreatingNote }, createNote] = useCreateNote();
- const [{ isLoading: isUpdatingContent }, updateNoteContent] = useNoteContent();
+ const [isCreatingNote, setIsCreatingNote] = useState(false);
const isNewFunding = searchParams.get('newFunding') === 'true';
const isNewChangelog = searchParams.get('newChangelog') === 'true';
@@ -56,36 +51,28 @@ export default function OrganizationPage() {
template: typeof proposalTemplate | typeof grantTemplate | typeof initialContent;
queryParam?: string;
queryValue?: string;
- documentType?: string;
+ documentType: string;
selectedGrantId?: Exclude;
}
) => {
+ setIsCreatingNote(true);
try {
- const title = getDocumentTitle(template) || 'Untitled';
- const newNote = await createNote({
- organizationSlug: orgSlug,
- title,
- grouping: 'WORKSPACE',
+ const newNote = await createFundingDraft({
+ orgSlug,
+ title: getDocumentTitle(template) || 'Untitled',
documentType,
+ grouping: 'WORKSPACE',
+ selectedGrantId,
+ template,
});
- if (newNote) {
- if (selectedGrantId) {
- await NoteService.updateNote({ noteId: newNote.id, selectedGrantId });
- }
-
- await updateNoteContent({
- note: newNote.id,
- fullJson: JSON.stringify(template),
- plainText: getTemplatePlainText(template),
- });
-
- const queryString = queryParam && queryValue ? `?${queryParam}=${queryValue}` : '';
- refreshNotes();
- router.replace(`/notebook/${orgSlug}/${newNote.id}${queryString}`);
- }
+ const queryString = queryParam && queryValue ? `?${queryParam}=${queryValue}` : '';
+ refreshNotes();
+ router.replace(`/notebook/${orgSlug}/${newNote.id}${queryString}`);
} catch (err) {
console.error('Failed to create note:', err);
+ } finally {
+ setIsCreatingNote(false);
}
};
@@ -156,5 +143,5 @@ export default function OrganizationPage() {
return null;
}
- return ;
+ return ;
}
diff --git a/app/peer-review/PeerReviewHero.tsx b/app/peer-review/PeerReviewHero.tsx
new file mode 100644
index 000000000..5ad6b7278
--- /dev/null
+++ b/app/peer-review/PeerReviewHero.tsx
@@ -0,0 +1,247 @@
+import { Fragment } from 'react';
+import Link from 'next/link';
+import { ArrowRight, Check, Coins, PenLine, ShieldCheck, Star, Target } from 'lucide-react';
+import { cn } from '@/utils/styles';
+
+const PIPELINE_STEPS = [
+ {
+ label: 'Submitted proposal',
+ description: 'Researchers ask for funding',
+ href: '/fund/proposals',
+ current: false,
+ },
+ {
+ label: 'Peer Review',
+ description: 'Paid, public expert reviews',
+ href: null,
+ current: true,
+ },
+ {
+ label: 'Funded proposal',
+ description: 'Reviews give funders context',
+ href: null,
+ current: false,
+ },
+];
+
+const REVIEW_SCORES = [
+ { label: 'Overall impact', stars: 5 },
+ { label: 'Importance', stars: 4 },
+ { label: 'Rigor & feasibility', stars: 4 },
+];
+
+const HOW_IT_WORKS = [
+ { icon: Target, title: 'Pick a proposal', description: 'No invite or permission' },
+ { icon: PenLine, title: 'Review', description: 'Rate each section yourself' },
+ { icon: ShieldCheck, title: 'Editors decide', description: 'Top 2 per proposal accepted' },
+ { icon: Coins, title: 'Get paid', description: '$150, about 10 days later' },
+];
+
+/** The journal's issue-card stack, dressed as an accepted proposal review. */
+const PeerReviewCards = () => (
+
+ Expert, public reviews of submitted proposals. $150 for every accepted review.
+
+
+
+
+
+ {/* Wide screens only, and no taller than the text beside it, as on My Funding. */}
+
+
+
+
+
+
+);
+
+/**
+ * Sits under the hero, across the feed and the sidebar: the container matches
+ * the layout's row, and from `lg` (where the sidebar shows) it drops the right
+ * padding so it ends flush with the sidebar's edge.
+ */
+export const PeerReviewHowItWorks = () => (
+
+
+ );
+}
+
+const MOBILE_SECTION_CLASS = 'rounded-xl border-t-0 bg-gray-50 px-4 py-3';
+
+/**
+ * The right sidebar is hidden below `lg` and this page has no trigger for the
+ * layout's mobile drawer, so phones get the same details here, collapsed so the
+ * bounties stay near the top. Mirrors the journal page.
+ */
+export function MobilePeerReviewDetails() {
+ return (
+
ResearchHub compensates researchers for peer-reviewing scientific proposals. Unlike
@@ -45,11 +57,11 @@ export default async function PeerReviewPage() {
ways it can improve.
+
Open peer-review bounties
-
Get paid to peer-review research proposals.
);
diff --git a/app/workspace/WorkspaceClient.tsx b/app/workspace/WorkspaceClient.tsx
new file mode 100644
index 000000000..a560ccbf8
--- /dev/null
+++ b/app/workspace/WorkspaceClient.tsx
@@ -0,0 +1,30 @@
+'use client';
+
+import { Suspense } from 'react';
+import dynamic from 'next/dynamic';
+import { PageLayout } from '@/app/layouts/PageLayout';
+
+/** The app's shell with the workspace's areas empty, until the workspace itself has loaded. */
+function WorkspaceShell() {
+ return (
+
+
+
+ );
+}
+
+// Client-only: the workspace sizes its panes from the window and from widths
+// remembered in the browser, none of which a server render can know.
+const WorkspacePage = dynamic(
+ () => import('@/components/AIMode/WorkspacePage').then((module) => module.WorkspacePage),
+ { ssr: false, loading: () => }
+);
+
+/** Behind Suspense because the workspace reads what it is open on from the URL's search params. */
+export function WorkspaceClient() {
+ return (
+ }>
+
+
+ );
+}
diff --git a/app/workspace/page.tsx b/app/workspace/page.tsx
new file mode 100644
index 000000000..1498ec77e
--- /dev/null
+++ b/app/workspace/page.tsx
@@ -0,0 +1,11 @@
+import { Metadata } from 'next';
+import { WorkspaceClient } from './WorkspaceClient';
+
+export const metadata: Metadata = {
+ title: 'Workspace',
+ robots: { index: false, follow: false },
+};
+
+export default function WorkspaceRoute() {
+ return ;
+}
diff --git a/components/AIMode/AIModeContext.tsx b/components/AIMode/AIModeContext.tsx
index f5c7188e4..d6f9a8cdc 100644
--- a/components/AIMode/AIModeContext.tsx
+++ b/components/AIMode/AIModeContext.tsx
@@ -2,145 +2,78 @@
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;
+import { useSearchParams } from 'next/navigation';
+import {
+ documentTarget,
+ readWorkspaceTarget,
+ sameTarget,
+ workspaceHref,
+ type DocumentChat,
+ type WorkspaceTarget,
+} from './workspaceUrl';
+
+export interface AIModeContextValue {
+ /** What the workspace is open on, as the URL says. */
+ readonly target: WorkspaceTarget;
+ selectTarget: (target: WorkspaceTarget) => void;
+ /** Open a document, on its most recent chat unless another is named. */
+ selectDocument: (noteId: number, chat?: DocumentChat) => void;
+ /**
+ * The open document while the assistant works on it, for the sidebar's
+ * row. Only the open one is known without asking the server, so only it
+ * is marked.
+ */
+ readonly workingNoteId: number | null;
+ setWorkingNoteId: (noteId: 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.
+ * Owns what the workspace page is open on. It lives in the URL, so a reload
+ * or a shared link lands on the same document and chat.
*
- * Mounted once, globally. The overlay body is lazy-loaded so a session that
- * never opens it pays nothing.
+ * Mounted by the `/workspace` page, not globally: the doors elsewhere in the
+ * app are plain links built with `workspaceHref`. Reads the URL with
+ * `useSearchParams`, so it needs a Suspense boundary above it.
*/
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 searchParams = useSearchParams();
+ // The same object until the target itself changes, so effects keyed on it
+ // do not re-run with every render or unrelated param.
+ const parsed = readWorkspaceTarget(searchParams);
+ const targetRef = useRef(parsed);
+ if (!sameTarget(targetRef.current, parsed)) targetRef.current = parsed;
+ const target = targetRef.current;
+
+ const selectTarget = useCallback((next: WorkspaceTarget) => {
+ if (sameTarget(next, targetRef.current)) return;
+ // Replaced, not pushed: the workspace is one history entry however many
+ // documents and chats are opened in it, so Back leaves in a single step. Native
+ // history with no state of our own, which the app router picks up and
+ // feeds back through useSearchParams without fetching anything.
+ window.history.replaceState(null, '', `${workspaceHref(next)}${window.location.hash}`);
}, []);
-
- 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 selectDocument = useCallback(
+ (noteId: number, chat?: DocumentChat) => selectTarget(documentTarget(noteId, chat)),
+ [selectTarget]
);
- const stateRef = useRef(state);
- stateRef.current = state;
- const toggle = useCallback(() => {
- if (stateRef.current.isOpen) close();
- else open();
- }, [open, close]);
+ const [workingNoteId, setWorkingNoteId] = useState(null);
const value = useMemo(
- () => ({ ...state, open, close, toggle, selectChat }),
- [state, open, close, toggle, selectChat]
+ () => ({ target, selectTarget, selectDocument, workingNoteId, setWorkingNoteId }),
+ [target, selectTarget, selectDocument, workingNoteId]
);
- return (
-
- {children}
-
-
-
- {state.isOpen && }
-
- );
+ return {children};
}
export function useAIMode(): AIModeContextValue {
@@ -151,7 +84,7 @@ export function useAIMode(): AIModeContextValue {
return context;
}
-/** Same as {@link useAIMode} but tolerates rendering outside the provider. */
+/** Same as {@link useAIMode} but null anywhere other than the workspace page. */
export function useOptionalAIMode(): AIModeContextValue | null {
return useContext(AIModeContext);
}
diff --git a/components/AIMode/AIModeOverlay.tsx b/components/AIMode/AIModeOverlay.tsx
deleted file mode 100644
index c72baac15..000000000
--- a/components/AIMode/AIModeOverlay.tsx
+++ /dev/null
@@ -1,361 +0,0 @@
-'use client';
-
-import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
-import { createPortal } from 'react-dom';
-import { PanelRight, Sparkles, X } from 'lucide-react';
-import { cn } from '@/utils/styles';
-import { ResizeHandle } from '@/components/ui/ResizeHandle';
-import { SwipeableDrawer } from '@/components/ui/SwipeableDrawer';
-import { useMediaQuery } from '@/hooks/useMediaQuery';
-import { useResizableWidth } from '@/hooks/useResizableWidth';
-import { useAIMode } from './AIModeContext';
-import { ChatPane } from './ChatPane';
-import { ConversationList } from './ConversationList';
-import { DocumentCard } from './DocumentCard';
-import { DocumentPane } from './DocumentPane';
-import { useAIModeChat } from './useAIModeChat';
-import type { NotebookTab } from '@/components/Notebook/NotebookTabs';
-import type { PublishingDefaultArticleType } from '@/contexts/PublishingHostContext';
-import { useAIModeDocument } from './useAIModeDocument';
-import { AI_MODE_NAME } from './copy';
-
-/** Above the overlay (9500), below BaseModal (9999). */
-const AI_MODE_DRAWER_Z_INDEX = 9600;
-
-const LIST_MIN_WIDTH = 200;
-const LIST_MAX_WIDTH = 440;
-const LIST_DEFAULT_WIDTH = 264;
-const CHAT_MIN_WIDTH = 360;
-const DOCUMENT_MIN_WIDTH = 420;
-/** The details form needs more room than the document: authors, image, funding fields. */
-const DETAILS_MIN_WIDTH = 560;
-/** Share of the viewport the document opens at before the user drags it. */
-const DOCUMENT_DEFAULT_SHARE = 0.55;
-
-/**
- * A modal that portals outside the overlay (BaseModal, a drawer) is showing.
- * Closed drawers stay mounted off-screen with `role="dialog"`, so presence in
- * the DOM is not enough — the box has to intersect the viewport.
- */
-function isForeignDialogOpen(): boolean {
- const overlay = document.getElementById('ai-mode-overlay');
- return Array.from(document.querySelectorAll('[role="dialog"]')).some((el) => {
- if (overlay?.contains(el)) return false;
- const rect = el.getBoundingClientRect();
- return (
- rect.width > 0 &&
- rect.height > 0 &&
- rect.bottom > 0 &&
- rect.right > 0 &&
- rect.top < window.innerHeight &&
- rect.left < window.innerWidth
- );
- });
-}
-
-/**
- * The full-viewport shell: header, conversation list, chat, document. Sits
- * below BaseModal (9999) and Tooltip (10000) so real modals and tooltips
- * opened from inside it still render on top.
- */
-export function AIModeOverlay() {
- const { close } = useAIMode();
- const state = useAIModeChat();
- // Below the tablet breakpoint the list lives in a bottom drawer.
- const [listDrawerOpen, setListDrawerOpen] = useState(false);
- const closeListDrawer = useCallback(() => setListDrawerOpen(false), []);
-
- const doc = useAIModeDocument({
- note: state.note,
- chat: state.chat.chat,
- latestExecution: state.chat.latestExecution,
- });
-
- // Tailwind's `tablet` breakpoint; the drawer only exists below it.
- const isBelowTablet = useMediaQuery('(max-width: 767px)') === true;
-
- // ---- pane widths, claude.ai style: both side panes drag, the chat takes the rest ----
- const [viewportWidth, setViewportWidth] = useState(() =>
- typeof window === 'undefined' ? 1440 : window.innerWidth
- );
- useEffect(() => {
- const update = () => setViewportWidth(window.innerWidth);
- update();
- window.addEventListener('resize', update);
- return () => window.removeEventListener('resize', update);
- }, []);
- const listWidth = useResizableWidth({
- storageKey: 'ai-mode:list-width',
- min: LIST_MIN_WIDTH,
- max: LIST_MAX_WIDTH,
- defaultWidth: LIST_DEFAULT_WIDTH,
- anchor: 'left',
- });
- // Document or details in the right pane; details wants a wider floor.
- const [documentTab, setDocumentTab] = useState('document');
- const documentMinWidth = documentTab === 'details' ? DETAILS_MIN_WIDTH : DOCUMENT_MIN_WIDTH;
- // The document may grow until the chat is down to its minimum column.
- const documentMaxWidth = Math.max(
- documentMinWidth,
- viewportWidth - listWidth.width - CHAT_MIN_WIDTH
- );
- const documentWidth = useResizableWidth({
- storageKey: 'ai-mode:document-width',
- min: documentMinWidth,
- max: documentMaxWidth,
- defaultWidth: (width) => width * DOCUMENT_DEFAULT_SHARE,
- anchor: 'right',
- });
- const isBelowTabletRef = useRef(isBelowTablet);
- isBelowTabletRef.current = isBelowTablet;
-
- // On desktop the document pane opens by itself the moment a conversation
- // gains a note. On mobile it never opens by itself — the card in the
- // transcript is the way in, and it opens a drawer. Either way the user can
- // close it and reopen it from the card or the chat header.
- const noteId = state.note?.id ?? null;
- const [documentOpen, setDocumentOpen] = useState(false);
- useEffect(() => {
- setDocumentOpen(noteId != null && !isBelowTabletRef.current);
- setDocumentTab('document');
- }, [noteId]);
-
- // What the conversation set out to write, from its opening message, so the
- // details form preselects the matching work type for a note that has none.
- const defaultArticleType = useMemo(() => {
- const opening = state.chat.chat?.messages.find((message) => message.role === 'user')?.content;
- if (!opening) return null;
- if (/request for proposals|\bRFP\b/i.test(opening)) return 'grant';
- if (/proposal/i.test(opening)) return 'preregistration';
- return null;
- }, [state.chat.chat?.messages]);
- const openDocument = useCallback(() => setDocumentOpen(true), []);
- const closeDocument = useCallback(() => setDocumentOpen(false), []);
- const showDocument = noteId != null && documentOpen;
-
- // The turn that created the document, for seating its card in the transcript.
- const documentCardExecutionId = useMemo(() => {
- if (noteId == null) return null;
- for (const execution of state.chat.chat?.executions ?? []) {
- const created = (execution.activity ?? []).some(
- (item) =>
- item.type === 'tool_call' &&
- item.tool === 'create_note' &&
- item.status === 'succeeded' &&
- item.note_id === noteId
- );
- if (created) return execution.id;
- }
- return null;
- }, [noteId, state.chat.chat]);
- const documentCard =
- noteId != null ? (
-
- ) : null;
-
- // Esc closes, unless something inside already claimed it (a menu, a modal
- // that portals outside the overlay).
- useEffect(() => {
- const handleKeyDown = (event: KeyboardEvent) => {
- if (event.key !== 'Escape' || event.defaultPrevented) return;
- if (isForeignDialogOpen()) return;
- close();
- };
- document.addEventListener('keydown', handleKeyDown);
- return () => document.removeEventListener('keydown', handleKeyDown);
- }, [close]);
-
- // A real modal: the overlay portals to the body and everything else at
- // the top level goes inert while it is open, so nothing behind it — a
- // notebook editor that autofocuses late, say — can take focus or keys.
- // Layers that mount later (menus, tooltips, modals) append after and stay
- // live; the overlay's own drawers render inside it.
- const [rootEl, setRootEl] = useState(null);
- useEffect(() => {
- if (!rootEl) return;
- rootEl.focus();
- const inerted: Element[] = [];
- for (const child of Array.from(document.body.children)) {
- if (child === rootEl || child.tagName === 'SCRIPT' || child.tagName === 'NEXTJS-PORTAL') {
- continue;
- }
- if (child.hasAttribute('inert')) continue;
- child.setAttribute('inert', '');
- inerted.push(child);
- }
- return () => {
- for (const child of inerted) child.removeAttribute('inert');
- };
- }, [rootEl]);
-
- // Lock the page behind the overlay.
- useEffect(() => {
- const previous = document.body.style.overflow;
- document.body.style.overflow = 'hidden';
- return () => {
- document.body.style.overflow = previous;
- };
- }, []);
-
- const conversationList = (
- {
- state.selectChat(chatId);
- closeListDrawer();
- }}
- onNew={() => {
- state.startNewChat();
- closeListDrawer();
- }}
- onRename={state.rename}
- onDelete={state.deleteChat}
- loadNotes={state.notesForChat}
- onRetry={state.list.refresh}
- />
- );
-
- return createPortal(
-
-
-
-
- {AI_MODE_NAME}
-
-
- Esc to close
-
-
-
-
-
-
-
-
-
- setListDrawerOpen(true)}
- documentCard={documentCard}
- documentCardExecutionId={documentCardExecutionId}
- headerActions={
- noteId != null && (
- setDocumentOpen((open) => !open)}
- aria-pressed={showDocument}
- aria-label={showDocument ? 'Hide document' : 'Show document'}
- title={showDocument ? 'Hide document' : 'Show document'}
- className={cn(
- 'inline-flex shrink-0 items-center justify-center rounded-lg p-1.5 transition-colors',
- showDocument
- ? 'bg-primary-50 text-primary-700 hover:bg-primary-100'
- : 'text-gray-500 hover:bg-gray-100 hover:text-gray-900'
- )}
- >
-
-
- )
- }
- />
-
- {/* One editor per note at a time: the pane mounts in the column above
- the tablet breakpoint and in the drawer below it, never both. */}
- {showDocument && !isBelowTablet && (
-
- )}
-
-
- {/* Drawers portal to the body, so they need to stack above this overlay
- (z-9500) while staying under BaseModal (9999). */}
-
- {conversationList}
-
-
- {showDocument && isBelowTablet && (
-
- )}
-
-
,
- document.body
- );
-}
diff --git a/components/AIMode/ChatPane.tsx b/components/AIMode/ChatPane.tsx
deleted file mode 100644
index 6a0db2a66..000000000
--- a/components/AIMode/ChatPane.tsx
+++ /dev/null
@@ -1,307 +0,0 @@
-'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);
- const paneRef = 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 (
-
-
- {/* A conversation keeps the composer docked at the bottom; the
- new-conversation screen seats it in the middle with the starters. */}
- {chatId != null && (
-
- ) : null}
-
- >
- );
-}
diff --git a/components/AIMode/DocumentCard.tsx b/components/AIMode/DocumentCard.tsx
deleted file mode 100644
index 1542867c6..000000000
--- a/components/AIMode/DocumentCard.tsx
+++ /dev/null
@@ -1,72 +0,0 @@
-'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 (
-
-
- {writing ? (
-
- ) : (
-
- )}
-
-
- {title}
-
- Document
- ·
-
- Only you
- ·
- {subtitle}
-
-
-
- {open ? 'Showing' : 'Open'}
-
-
- );
-}
diff --git a/components/AIMode/DocumentPane.tsx b/components/AIMode/DocumentPane.tsx
deleted file mode 100644
index f99dd8a7b..000000000
--- a/components/AIMode/DocumentPane.tsx
+++ /dev/null
@@ -1,321 +0,0 @@
-'use client';
-
-import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
-import type { Editor } from '@tiptap/react';
-import { ExternalLink } from 'lucide-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 { DraftBlockPreview } from './DraftBlockPreview';
-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, draftBlocks, 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;
-
- // The assistant is writing the first version: nothing to show in the
- // editor yet, so the whole pane becomes the progress screen.
- const startingDocument =
- status === 'working' && !document.hasWrittenVersion && review.review == null;
-
- 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. */}
-
- This document couldn’t be displayed here. Open it in the notebook to view it;
- nothing has been changed.
-
- )}
- {status === 'empty' && review.review == null && }
-
- {/* Mounted once per note: the editor's content prop is only read on
- creation, and later versions arrive through the review. */}
-
- );
-}
-
-/**
- * The assistant is writing the first version. Fills the pane: there is no
- * document to sit beside yet, so the progress state is the page.
- */
-function StartingDocument({ label }: { readonly label: string | null }) {
- return (
-
-
-
Starting the document…
- {label &&
{label}
}
-
- );
-}
-
-/** The note exists, the turn has settled, and nothing was ever written. */
-function EmptyDocument() {
- return (
-
-
- Nothing has been written here yet. You can start typing, or ask the assistant.
-
-
-
- );
-}
-
-/** 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/WorkspacePage.tsx b/components/AIMode/WorkspacePage.tsx
new file mode 100644
index 000000000..1d56bd515
--- /dev/null
+++ b/components/AIMode/WorkspacePage.tsx
@@ -0,0 +1,221 @@
+'use client';
+
+import { useCallback, useEffect, useState } from 'react';
+import { PanelRightClose, PanelRightOpen } from 'lucide-react';
+import { PageLayout } from '@/app/layouts/PageLayout';
+import { useFundingDocuments } from '@/contexts/FundingDocumentsContext';
+import { useMediaQuery } from '@/hooks/useMediaQuery';
+import { useResizableWidth } from '@/hooks/useResizableWidth';
+import { isActiveExecutionStatus } from '@/types/agentChat';
+import type { FundingIntent } from '@/components/Funding/fundingDirection';
+import { isPublishedNote } from '@/types/note';
+import { cn } from '@/utils/styles';
+import { AIModeProvider, useAIMode } from './AIModeContext';
+import { ChatPane } from './chat/ChatPane';
+import { newDraftTitle } from './copy';
+import { DocumentPane } from './document/DocumentPane';
+import { useAIModeDocument, type AIModeDocument } from './document/useAIModeDocument';
+import { WorkspacePanes } from './shell/WorkspacePanes';
+import { WorkspaceTopBar } from './shell/WorkspaceTopBar';
+import { useAIModeChat } from './useAIModeChat';
+
+const CHAT_MIN_WIDTH = 360;
+const DOCUMENT_MIN_WIDTH = 420;
+/** Share of the viewport the document opens at before the user drags it. */
+const DOCUMENT_DEFAULT_SHARE = 0.55;
+
+/** An element's width as it resizes; the viewport's until it has mounted. */
+function useElementWidth(element: HTMLElement | null): number {
+ const [width, setWidth] = useState(() =>
+ typeof window === 'undefined' ? 1440 : window.innerWidth
+ );
+ useEffect(() => {
+ if (!element) return;
+ const update = () => setWidth(Math.round(element.getBoundingClientRect().width));
+ update();
+ if (typeof ResizeObserver === 'undefined') return;
+ const observer = new ResizeObserver(update);
+ observer.observe(element);
+ return () => observer.disconnect();
+ }, [element]);
+ return width;
+}
+
+/**
+ * What the top bar names: on the start screen what it will produce (an RFP
+ * or a proposal), and on a document its title as the document heads it, or,
+ * below the width at which a title stays readable, just whether it is out
+ * yet. '' is the bar's loading state: shown until the note has loaded, so
+ * nothing stands in for the real title first.
+ */
+function topBarTitle({
+ startingIntent,
+ doc,
+ isWide,
+}: {
+ readonly startingIntent: FundingIntent | null;
+ readonly doc: AIModeDocument;
+ readonly isWide: boolean;
+}): string {
+ if (startingIntent) return newDraftTitle(startingIntent);
+ const loadedNote = doc.details ?? doc.content;
+ if (doc.missing || (doc.error != null && loadedNote == null)) return 'Untitled';
+ if (loadedNote == null) return '';
+ if (!isWide) return isPublishedNote(loadedNote) ? 'Published' : 'Draft';
+ return doc.displayTitle ?? '';
+}
+
+/**
+ * The `/workspace` page: the app's shell, with the chat and, beside it on
+ * the right, the document it is about. The user's documents are in the
+ * app's left sidebar, as on every page. What it is open on lives in the URL.
+ */
+export function WorkspacePage() {
+ return (
+
+
+
+ );
+}
+
+function Workspace() {
+ const state = useAIModeChat();
+ const { target, note } = state;
+
+ // From Tailwind's `sidebar-compact` up the app's left column is there and
+ // the top bar has room for a document's title.
+ const isWide = useMediaQuery('(min-width: 1240px)') === true;
+ // Tailwind's `tablet` breakpoint; the side-by-side panes only exist from it up.
+ const phoneQuery = useMediaQuery('(max-width: 767px)');
+ const isBelowTablet = phoneQuery === true;
+
+ const latestExecution = state.chat.latestExecution;
+ const turnActive = latestExecution != null && isActiveExecutionStatus(latestExecution.status);
+ const doc = useAIModeDocument({
+ note,
+ chat: state.chat.chat,
+ latestExecution,
+ assistantWorking: turnActive || state.anyTurnActive,
+ messagePending: state.messagePending,
+ });
+
+ // The sidebar marks the open document's row while the assistant works on
+ // it, as the document's own dot does.
+ const { setWorkingNoteId } = useAIMode();
+ const workingNoteId =
+ note != null && (doc.status === 'drafting' || doc.status === 'working' || state.anyTurnActive)
+ ? note.id
+ : null;
+ useEffect(() => {
+ setWorkingNoteId(workingNoteId);
+ }, [workingNoteId, setWorkingNoteId]);
+ useEffect(() => () => setWorkingNoteId(null), [setWorkingNoteId]);
+
+ // ---- the document's width: it drags, the chat takes the rest ----
+ const [panesEl, setPanesEl] = useState(null);
+ const panesWidth = useElementWidth(panesEl);
+ const documentMaxWidth = Math.max(DOCUMENT_MIN_WIDTH, panesWidth - CHAT_MIN_WIDTH);
+ const documentWidth = useResizableWidth({
+ storageKey: 'ai-mode:document-width',
+ min: DOCUMENT_MIN_WIDTH,
+ max: documentMaxWidth,
+ defaultWidth: (width) => width * DOCUMENT_DEFAULT_SHARE,
+ anchor: 'right',
+ });
+
+ // On desktop the document shows beside its chat as soon as it is open; on
+ // a phone the chat header's toggle opens it in a drawer. Either way the
+ // user can hide it and bring it back from there; that choice holds for
+ // the document it was made on. Until the width is known, nothing opens.
+ const noteId = note?.id ?? null;
+ const [documentChoice, setDocumentChoice] = useState<{
+ readonly noteId: number;
+ readonly open: boolean;
+ } | null>(null);
+ const documentOpen =
+ documentChoice != null && documentChoice.noteId === noteId
+ ? documentChoice.open
+ : phoneQuery === false;
+ const setDocumentOpen = useCallback(
+ (open: boolean) => {
+ if (noteId != null) setDocumentChoice({ noteId, open });
+ },
+ [noteId]
+ );
+ const closeDocument = useCallback(() => setDocumentOpen(false), [setDocumentOpen]);
+ const showDocument = noteId != null && documentOpen && !doc.missing;
+
+ // The sidebar's row follows the open document's saved title (its heading
+ // once that has saved, or a name the assistant gave it) and latest edit.
+ const { patch } = useFundingDocuments();
+ const { title: docTitle, updatedDate: docUpdatedDate } = doc;
+ useEffect(() => {
+ if (noteId == null || !docTitle) return;
+ patch(
+ noteId,
+ docUpdatedDate ? { title: docTitle, updatedDate: docUpdatedDate } : { title: docTitle }
+ );
+ }, [noteId, docTitle, docUpdatedDate, patch]);
+
+ const headerTitle = topBarTitle({
+ startingIntent: target.kind === 'new' ? state.intent : null,
+ doc,
+ isWide,
+ });
+
+ const documentPane = (inDrawer: boolean) => (
+
+ );
+
+ return (
+
+
+
+
+ setDocumentOpen(!showDocument)}
+ aria-pressed={showDocument}
+ aria-label={showDocument ? 'Hide document' : 'Show document'}
+ title={showDocument ? 'Hide document' : 'Show document'}
+ className={cn(
+ 'flex h-[34px] w-[34px] shrink-0 items-center justify-center rounded-lg transition-colors',
+ showDocument
+ ? 'bg-primary-50 text-primary-700 hover:bg-primary-100'
+ : 'text-gray-500 hover:bg-gray-100 hover:text-gray-900'
+ )}
+ >
+ {/* The chevron points where the document goes: away to the
+ right while it shows, in from the right while hidden. */}
+ {showDocument ? (
+
+ ) : (
+
+ )}
+
+ )
+ }
+ />
+ }
+ document={showDocument ? documentPane(false) : null}
+ documentDrawer={showDocument ? documentPane(true) : null}
+ />
+
+
+ );
+}
diff --git a/components/AIMode/chat/ChatMenu.tsx b/components/AIMode/chat/ChatMenu.tsx
new file mode 100644
index 000000000..3f464cfd7
--- /dev/null
+++ b/components/AIMode/chat/ChatMenu.tsx
@@ -0,0 +1,85 @@
+'use client';
+
+import { useState } from 'react';
+import { MoreHorizontal, Pencil, Trash2 } from 'lucide-react';
+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 ChatMenuProps {
+ readonly title: string;
+ readonly onRename: () => void;
+ /** Called only after the user confirms. */
+ readonly onDelete: () => void;
+ readonly className?: string;
+}
+
+/**
+ * The open chat's ellipsis menu: rename inline, or delete behind a
+ * confirmation. Deleting a chat never touches its document.
+ */
+export function ChatMenu({ title, onRename, onDelete, className }: ChatMenuProps) {
+ const [confirming, setConfirming] = useState(false);
+
+ return (
+ <>
+
+
+
+ }
+ >
+
+
+ Rename
+
+ setConfirming(true)}
+ className="gap-2 text-red-600 focus:bg-red-50 focus:text-red-700"
+ >
+
+ Delete
+
+
+
+ setConfirming(false)}
+ title="Delete chat?"
+ size="sm"
+ footer={
+
+ );
+ }
+ if (listBlocked) return ;
+ if (resolving) {
+ // Never the new-chat state while the most recent chat is being found:
+ // it would read as the chat having gone.
+ return listFailed ? (
+
+ ) : (
+
+ );
+ }
+ // A new chat says what the assistant can do in a line and leaves the
+ // rest to the composer: no heading, no suggestions to pick from.
+ const emptyState = (
+
+
+
+ The assistant can help draft this document, find research to support it, and answer your
+ questions along the way.
+
+
+ );
+ if (chatId == null) return emptyState;
+ switch (chat.access) {
+ case 'unauthorized':
+ return ;
+ case 'error':
+ if (chat.chat == null) {
+ return ;
+ }
+ break;
+ case 'loading':
+ case 'not_found':
+ // A chat that is gone hands over to the document's most recent one.
+ if (chat.chat == null) return ;
+ break;
+ }
+ if (!chat.chat) return null;
+ const isEmpty =
+ chat.chat.messages.length === 0 && chat.chat.executions.length === 0 && !chat.pendingSend;
+ if (isEmpty) return emptyState;
+ return (
+
+
+
+ );
+ };
+
+ return (
+
+ {/* No border or fill: the controls float over the pane, at its right.
+ No title: the top bar already names the document, and History
+ names the chats. The start screen has no chat yet, so no header. */}
+ {!onStart && !documentMissing && (
+
+ {renaming && chatId != null ? (
+ setRenaming(false)}
+ onCommit={(value) => {
+ setRenaming(false);
+ const next = value.trim();
+ if (next && next !== (state.chatTitle ?? '')) void state.rename(next);
+ }}
+ />
+ ) : (
+
+ )}
+
+ {/* This chat's menu, then History of the document's chats. */}
+ {chatId != null && !renaming && (
+ setRenaming(true)}
+ onDelete={() => void state.deleteChat(chatId)}
+ />
+ )}
+ {!listBlocked && (
+ state.openChat(id)}
+ onOpen={() => void list.refresh()}
+ onNewChat={() => state.openChat('new')}
+ failed={listFailed}
+ className="flex-none"
+ trigger={
+
+
+
+ }
+ />
+ )}
+ {headerActions}
+
+ )}
+
+
+
+
+ {body()}
+
+
+
+
+
+
+
+ {/* A document's chat keeps the composer docked at the bottom; the start
+ screen seats it in the middle. */}
+ {!onStart && !documentMissing && (
+
+ {detail ?? 'Your account doesn’t have access to this feature.'}
+
+
+ );
+}
diff --git a/components/AIMode/ConversationTitleField.tsx b/components/AIMode/chat/ChatTitleField.tsx
similarity index 81%
rename from components/AIMode/ConversationTitleField.tsx
rename to components/AIMode/chat/ChatTitleField.tsx
index fc72c4f23..511cd79dd 100644
--- a/components/AIMode/ConversationTitleField.tsx
+++ b/components/AIMode/chat/ChatTitleField.tsx
@@ -4,7 +4,7 @@ import { useEffect, useRef, useState, type KeyboardEvent } from 'react';
import { MAX_CHAT_TITLE_LENGTH } from '@/types/agentChat';
import { cn } from '@/utils/styles';
-interface ConversationTitleFieldProps {
+interface ChatTitleFieldProps {
readonly initialValue: string;
readonly onCommit: (value: string) => void;
readonly onCancel: () => void;
@@ -12,15 +12,16 @@ interface ConversationTitleFieldProps {
}
/**
- * Inline title editor: Enter or blur commits, Escape cancels. Escape is
- * claimed here so the overlay's own Esc handler doesn't close it.
+ * Inline title editor for a chat: Enter or blur commits, Escape cancels.
+ * Escape is claimed here so nothing around the field — an overlay, a menu —
+ * also acts on it.
*/
-export function ConversationTitleField({
+export function ChatTitleField({
initialValue,
onCommit,
onCancel,
className,
-}: ConversationTitleFieldProps) {
+}: ChatTitleFieldProps) {
const [value, setValue] = useState(initialValue);
const inputRef = useRef(null);
useEffect(() => {
@@ -47,7 +48,7 @@ export function ConversationTitleField({
onKeyDown={handleKeyDown}
onBlur={() => onCommit(value)}
maxLength={MAX_CHAT_TITLE_LENGTH}
- aria-label="Conversation title"
+ aria-label="Chat 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/copy.ts b/components/AIMode/copy.ts
index 93caea86f..c16e45448 100644
--- a/components/AIMode/copy.ts
+++ b/components/AIMode/copy.ts
@@ -1,60 +1,66 @@
-import type { IconDefinition } from '@fortawesome/fontawesome-svg-core';
-import {
- faBullhorn,
- faFileSignature,
- faMagnifyingGlassDollar,
-} from '@fortawesome/pro-light-svg-icons';
+import type { FundingIntent } from '@/components/Funding/fundingDirection';
/**
- * User-facing copy for AI Mode, in one place so the product name and the
- * empty-state wording can change without touching components.
+ * User-facing copy for the workspace, in one place so the new-draft 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';
+/**
+ * What the new-draft screen is called, in the app's top bar: named for what
+ * it will produce, not for the chat.
+ */
+export const newDraftTitle = (intent: FundingIntent): string =>
+ intent === 'fund' ? 'New RFP' : 'New proposal';
+
+/** A new draft's title until the assistant (or the user) names it. */
+export const untitledDraftTitle = (intent: FundingIntent): string =>
+ intent === 'fund' ? 'Untitled RFP' : 'Untitled proposal';
-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;
-}
+/** The heading of the new-draft screen. */
+export const AI_MODE_GREETING = 'Let’s get started';
+
+/** The line under the greeting and what the composer asks for, per side of the money. */
+export const INTENT_COPY: Record = {
+ fund: {
+ tagline: 'The fastest way to fund science.',
+ placeholder: 'Describe the research you want to fund…',
+ },
+ need_funding: {
+ tagline: 'Turn your proposal into funded science.',
+ placeholder: 'Describe the research you need funding for…',
+ },
+};
/**
- * 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.
+ * Examples under a new RFP's composer, to show a funder what the assistant
+ * can do. The label is what the pill reads; clicking it sends the message as
+ * the user's first, so the chat shows exactly what was asked. No amounts: the
+ * assistant asks about the budget itself.
*/
-export const AI_MODE_STARTER_PROMPTS: readonly StarterPrompt[] = [
+export const RFP_START_PRESETS: readonly { readonly label: string; readonly message: string }[] = [
+ {
+ label: 'Fund a cure for cancer',
+ message:
+ 'I want to fund research toward a cure for cancer. Help me write an RFP that will reach the labs doing the most promising work.',
+ },
+ {
+ label: 'Fund novel longevity research',
+ message:
+ 'I want to fund novel research on the biology of aging that could lead to longer, healthier lives. Help me write an RFP for it.',
+ },
{
- id: 'draft-rfp',
- title: 'Draft a Request for Proposal',
- description: 'Fund specific research you care about',
- icon: faBullhorn,
+ label: 'Back new Alzheimer’s treatments',
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.',
+ 'I want to back research into new ways to prevent or treat Alzheimer’s disease. Help me write an RFP for it.',
},
{
- id: 'draft-proposal',
- title: 'Draft a Research Proposal',
- description: 'Raise money for your research',
- icon: faFileSignature,
+ label: 'Support rare disease research',
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.',
+ 'I want to support research on a rare disease that few others fund. Help me write an RFP that will reach the right labs.',
},
{
- id: 'funding',
- title: 'Find me funding',
- description: 'Open RFPs that fit your work',
- icon: faMagnifyingGlassDollar,
+ label: 'Advance mental health care',
message:
- 'Find open RFPs I could apply to based on my expertise, and tell me why each one is a match.',
+ 'I want to fund research that could lead to better treatments for depression and anxiety. Help me write an RFP for it.',
},
];
diff --git a/components/AIMode/document/DocumentPane.tsx b/components/AIMode/document/DocumentPane.tsx
new file mode 100644
index 000000000..256c4121a
--- /dev/null
+++ b/components/AIMode/document/DocumentPane.tsx
@@ -0,0 +1,354 @@
+'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 { AssistantActivityDot } from '@/components/AgentChat/AssistantActivityDot';
+import { NoteReviewBanner } from '@/components/Notebook/NoteReview/NoteReviewBanner';
+import { PublishingFormProvider } from '@/components/Notebook/PublishingForm';
+import { PublishingHostProvider, 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 { DraftBlockPreview } from './DraftBlockPreview';
+import { DocumentPaneSkeleton } from '@/components/skeletons/AIModeSkeleton';
+import { useUpdateNote } from '@/hooks/useNote';
+import type { AgentChat } from '@/types/agentChat';
+import { cn } from '@/utils/styles';
+import { Masthead } from './masthead/Masthead';
+import { useMastheadSlots } from './masthead/useMastheadSlots';
+import { PublishDialog } from './PublishDialog';
+import { PublishPill } from './PublishPill';
+import type { AIModeDocument } from './useAIModeDocument';
+import { useDocumentTitle } from './useDocumentTitle';
+
+/** 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;
+ /** The assistant has a turn running on this document in one of its other chats. */
+ readonly assistantWorking?: boolean;
+ readonly className?: string;
+}
+
+/**
+ * The document pane: the note the assistant is composing, in the real editor,
+ * under a masthead that holds what it needs before it can be published, with
+ * a Publish button floating over it. The same in the column beside the chat
+ * and in the phone's drawer.
+ * 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,
+ assistantWorking = false,
+ className,
+}: DocumentPaneProps) {
+ const { note, content, loading, error, status, draftText, draftBlocks, phaseLabel } = document;
+ const noteId = note?.id ?? null;
+ const writing = status === 'drafting' || status === 'working';
+
+ // ---- the editor, its autosave, and the assistant-version review ----
+ // The editor is held with the note it was made for. Switching documents
+ // leaves the previous one in state until the next editor is up; read as
+ // this note's, it would report the old document's heading as the new
+ // one's title and save into the wrong note. Until then there is none.
+ const [heldEditor, setHeldEditor] = useState<{
+ readonly editor: Editor | null;
+ readonly noteId: number | null;
+ }>({ editor: null, noteId: null });
+ const setEditor = useCallback(
+ (instance: Editor | null) => setHeldEditor({ editor: instance, noteId }),
+ [noteId]
+ );
+ const editor = heldEditor.noteId === noteId ? heldEditor.editor : null;
+ // 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);
+ // A save whose heading changed brings the note's saved title along. The
+ // title hook that does it needs this save in turn, so it is reached
+ // through a ref, set where the hook is called below.
+ const syncAfterSaveRef = useRef(() => {});
+ 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,
+ saveTitle: () => syncAfterSaveRef.current(),
+ });
+ // 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]
+ );
+
+ const publishingHost = useMemo(
+ () => ({
+ note: content,
+ editor,
+ isLoading: loading,
+ saveDetailsSoon,
+ saveDetailsNow,
+ }),
+ [content, editor, loading, saveDetailsSoon, saveDetailsNow]
+ );
+
+ 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;
+
+ // The assistant is about to write the first version: nothing to show in
+ // the editor yet, so the whole pane is the progress screen from the moment
+ // the message is sent, even before the note has loaded.
+ const startingDocument = document.starting && review.review == null;
+
+ const reviewing = review.review != null;
+ const mastheadSlots = useMastheadSlots(editor);
+
+ // The document's one title: its first heading (see useDocumentTitle).
+ const { title, syncSavedTitle, syncAfterSave, writeTitle } = useDocumentTitle({
+ editor,
+ savedTitle: document.title,
+ showSavedTitle: document.rename,
+ reportHeading: document.setHeadingTitle,
+ saveDetailsSoon,
+ saveNoteNow,
+ locked,
+ reviewing,
+ });
+ syncAfterSaveRef.current = syncAfterSave;
+ const acceptReview = review.accept;
+ const accept = useCallback(() => {
+ acceptReview();
+ syncSavedTitle();
+ }, [acceptReview, syncSavedTitle]);
+
+ // The bottom-centre spot is the assistant's first: while it writes, and
+ // while its changes wait to be accepted or rejected, there is no Publish.
+ const showPublish =
+ content != null && !loading && !writing && review.review == null && !editorLostContent;
+
+ return (
+
+ {/* The form lives as long as the note does, whichever view is showing:
+ its values are the source of the masthead and the publish controls,
+ and remounting it would rehydrate from the note as it was loaded,
+ dropping edits saved since. */}
+ }
+ refreshedNote={document.details}
+ >
+
+ {/* Above the content, so it does not scroll: the one sign of the
+ assistant's work on the document, flashing and named while it
+ drafts, still while its changes wait to be accepted or rejected. */}
+ {(writing || assistantWorking) && (
+
+
+ Drafting
+
+ )}
+ {!writing && !assistantWorking && reviewing && (
+
+ )}
+ {/* The document is the pane: no gutter, no card, just the page. */}
+
+ This document couldn’t be displayed here. Open it in the notebook to view it;
+ nothing has been changed.
+
+ )}
+ {status === 'empty' && review.review == null && }
+
+ {/* Mounted once per note: the editor's content prop is only read on
+ creation, and later versions arrive through the review. */}
+
+
+
+ );
+}
+
+/**
+ * The assistant is writing the first version. Fills the pane: there is no
+ * document to sit beside yet, so the progress state is the page.
+ */
+function StartingDocument({ label }: { readonly label: string | null }) {
+ return (
+
+
+
Starting the document…
+ {label &&
{label}
}
+
+ );
+}
+
+/** The note exists, the turn has settled, and nothing was ever written. */
+function EmptyDocument() {
+ return (
+
+
+ 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,
+ blocks,
+ editor,
+ hasSavedContent,
+}: {
+ readonly text: string;
+ readonly blocks: AIModeDocument['draftBlocks'];
+ readonly editor: Editor | null;
+ readonly hasSavedContent: boolean;
+}) {
+ return (
+
+
+
+ );
+}
+
+/** 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/DraftBlockPreview.tsx b/components/AIMode/document/DraftBlockPreview.tsx
similarity index 100%
rename from components/AIMode/DraftBlockPreview.tsx
rename to components/AIMode/document/DraftBlockPreview.tsx
diff --git a/components/AIMode/document/MissingDetails.tsx b/components/AIMode/document/MissingDetails.tsx
new file mode 100644
index 000000000..c2331d01b
--- /dev/null
+++ b/components/AIMode/document/MissingDetails.tsx
@@ -0,0 +1,371 @@
+'use client';
+
+import { useRef, useState, type ReactNode } from 'react';
+import { useWatch } from 'react-hook-form';
+import { MIN_PUBLISH_TITLE_LENGTH } from '@/components/modals/ConfirmPublishModal';
+import { usePublishingController } from '@/components/Notebook/PublishingForm';
+import type { PublishingFormData } from '@/components/Notebook/PublishingForm/schema';
+import { cn } from '@/utils/styles';
+import { AmountEditor } from './masthead/AmountWidget';
+import { useCoverImagePicker } from './masthead/CoverImage';
+import { EDITING_FIELD_CLASS, EDITING_INPUT_CLASS } from './masthead/MastheadLine';
+import { MAX_TITLE_LENGTH, normalizeTitle } from './masthead/title';
+import {
+ detailValue,
+ mastheadWidgetsFor,
+ peopleOf,
+ type MastheadWidgetConfig,
+} from './masthead/mastheadWidgets';
+import { PeopleEditor } from './masthead/PeopleWidget';
+import { TextEditor } from './masthead/TextWidget';
+import { useLeaveToCommit } from './masthead/useLeaveToCommit';
+import type { PublishReadiness } from './masthead/usePublishReadiness';
+
+const TITLE_ROW = 'title';
+
+interface MissingDetailsProps {
+ readonly readiness: PublishReadiness;
+ /** The note's title, and how the dialog changes it when it is too short to publish under. */
+ readonly title: string;
+ readonly onRename: (title: string) => void;
+}
+
+/**
+ * What the document still needs before it can be published, listed in the
+ * publish dialog with a way to give each one right there. The document
+ * itself never marks anything as missing; this is where the user finds out.
+ * The rows alone say it: a row stays once it is filled, showing what was
+ * entered, so the list does not shift under the pointer.
+ */
+export function MissingDetails({ readiness, title, onRename }: MissingDetailsProps) {
+ const { articleType } = usePublishingController();
+ const values = useWatch() as PublishingFormData;
+ const widgets = mastheadWidgetsFor(articleType);
+ const { missing, titleMissing, titleTooShort } = readiness;
+
+ // What was missing when the dialog opened: the rows it keeps.
+ const [rowIds] = useState(() => [
+ ...(titleTooShort ? [TITLE_ROW] : []),
+ ...missing.flatMap((field) => widgets.find((widget) => widget.field === field)?.id ?? []),
+ ]);
+ // One editor at a time.
+ const [editingId, setEditingId] = useState(null);
+
+ if (rowIds.length === 0) return null;
+
+ const configs = rowIds.flatMap((id) => widgets.find((widget) => widget.id === id) ?? []);
+
+ const rowState = (id: string) => ({
+ editing: editingId === id,
+ onEditingChange: (editing: boolean) =>
+ setEditingId((current) => {
+ if (editing) return id;
+ return current === id ? null : current;
+ }),
+ });
+
+ return (
+
+ );
+}
+
+/** The small outlined button at a row's right end: "Add", "Edit", "Change". */
+function RowButton({
+ onClick,
+ children,
+}: {
+ readonly onClick: () => void;
+ readonly children: ReactNode;
+}) {
+ return (
+
+ {children}
+
+ );
+}
+
+/** A row's value once it has one: click it to change it. */
+const ROW_VALUE_CLASS =
+ 'min-w-0 max-w-[230px] truncate rounded-md px-1 text-right text-sm font-medium text-gray-900 transition-colors hover:bg-gray-50 focus-visible:bg-gray-50 focus-visible:outline-none';
+
+interface RowProps {
+ readonly label: string;
+ /** A second, quieter line under the label. */
+ readonly hint?: ReactNode;
+ /** What sits at the row's right end: a button, a small field, or the value. */
+ readonly control?: ReactNode;
+ /** An editor too wide for the right end, on a line of its own under the label. */
+ readonly below?: ReactNode;
+}
+
+function Row({ label, hint, control, below }: RowProps) {
+ return (
+
+
+
+ {label}
+ {hint && {hint}}
+
+ {control}
+
+ {below}
+
+ );
+}
+
+interface RowEditing {
+ readonly editing: boolean;
+ readonly onEditingChange: (editing: boolean) => void;
+}
+
+function DetailRow({
+ config,
+ values,
+ editing,
+ onEditingChange,
+}: RowEditing & {
+ readonly config: MastheadWidgetConfig;
+ readonly values: PublishingFormData;
+}) {
+ const open = () => onEditingChange(true);
+ const close = () => onEditingChange(false);
+ const value = detailValue(config, values);
+ // What sits at the row's right end when no editor is open: the value, to change it, or Add.
+ const control = value ? (
+
+ {value}
+
+ ) : (
+ Add
+ );
+
+ switch (config.kind) {
+ case 'cover':
+ return ;
+ case 'amount':
+ return (
+
+ ) : (
+ control
+ )
+ }
+ />
+ );
+ case 'people':
+ return (
+
+ ) : undefined
+ }
+ />
+ );
+ case 'text':
+ case 'paragraph':
+ return (
+
+
+
+ ) : undefined
+ }
+ />
+ );
+ // A proposal publishes without an RFP, so it is never listed here.
+ case 'grant':
+ return null;
+ }
+}
+
+/** The cover image has no editor: Add opens the file picker, and the pick shows as a thumbnail. */
+function CoverRow({
+ config,
+ value,
+}: {
+ readonly config: MastheadWidgetConfig;
+ readonly value: PublishingFormData['coverImage'];
+}) {
+ const cover = useCoverImagePicker(value);
+ return (
+
+ {cover.imageUrl && (
+ // eslint-disable-next-line @next/next/no-img-element
+
+ )}
+ {cover.imageUrl ? 'Change' : 'Add'}
+
+
+ }
+ />
+ );
+}
+
+/**
+ * The document's title, when it has none or one too short to publish under:
+ * given or edited here in full, and written into the document's heading.
+ */
+function TitleRow({
+ title,
+ missing,
+ tooShort,
+ onRename,
+ editing,
+ onEditingChange,
+}: RowEditing & {
+ readonly title: string;
+ readonly missing: boolean;
+ readonly tooShort: boolean;
+ readonly onRename: (title: string) => void;
+}) {
+ return (
+ }
+ control={
+ editing ? undefined : (
+ onEditingChange(true)}>{missing ? 'Add' : 'Edit'}
+ )
+ }
+ below={
+ editing ? (
+ onEditingChange(false)} />
+ ) : undefined
+ }
+ />
+ );
+}
+
+/** The title as it stands, and what it lacks: a short one is not missing, so it says how short. */
+function TitleHint({
+ title,
+ missing,
+ tooShort,
+}: {
+ readonly title: string;
+ readonly missing: boolean;
+ readonly tooShort: boolean;
+}) {
+ if (missing) return <>Your document needs a title>;
+ return (
+ <>
+ {title}
+ {tooShort && (
+ Too short: {titleLength(title.length)}
+ )}
+ >
+ );
+}
+
+/** How a title's length stands against the minimum to publish under. */
+function titleLength(length: number): string {
+ return length < MIN_PUBLISH_TITLE_LENGTH
+ ? `${length} of at least ${MIN_PUBLISH_TITLE_LENGTH} characters`
+ : `${length} characters`;
+}
+
+function TitleEditor({
+ title,
+ onRename,
+ onClose,
+}: {
+ readonly title: string;
+ readonly onRename: (title: string) => void;
+ readonly onClose: () => void;
+}) {
+ const [draft, setDraft] = useState(title);
+ const ref = useRef(null);
+ const next = normalizeTitle(draft);
+ const short = next.length < MIN_PUBLISH_TITLE_LENGTH;
+ const commit = () => {
+ // A note is never left without a title.
+ if (next && next !== title) onRename(next);
+ onClose();
+ };
+ useLeaveToCommit(ref, commit);
+
+ return (
+
+
+
+ setDraft(event.target.value)}
+ onKeyDown={(event) => {
+ if (event.key === 'Enter') {
+ event.preventDefault();
+ commit();
+ } else if (event.key === 'Escape') {
+ // Handled here, so the dialog does not take it as its own and close.
+ event.preventDefault();
+ onClose();
+ }
+ }}
+ className={EDITING_INPUT_CLASS}
+ />
+
+ {/* Enter saves too, but a phone's keyboard may not offer it. */}
+
+ Save
+
+
+ {/* Counts as the user types, so they know when it is long enough
+ without having to save to find out. */}
+
+ {next ? titleLength(next.length) : `At least ${MIN_PUBLISH_TITLE_LENGTH} characters`}
+
+
+ );
+}
diff --git a/components/AIMode/document/PublishDialog.tsx b/components/AIMode/document/PublishDialog.tsx
new file mode 100644
index 000000000..ce6bd1fe0
--- /dev/null
+++ b/components/AIMode/document/PublishDialog.tsx
@@ -0,0 +1,406 @@
+'use client';
+
+import { Fragment, useEffect, useState, type ReactNode } from 'react';
+import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild } from '@headlessui/react';
+import { Globe, Lock, User, type LucideIcon } from 'lucide-react';
+import { useFormContext } from 'react-hook-form';
+import { PublishGuidelines } from '@/components/modals/ConfirmPublishModal';
+import { NonprofitConfirmModal, NonprofitSearchSection } from '@/components/Nonprofit';
+import { usePublishingController } from '@/components/Notebook/PublishingForm';
+import { FUNDRAISE_DURATION_HELP } from '@/components/Notebook/PublishingForm/components/EndDateSection';
+import {
+ GRANT_APPLICATION_VISIBILITY_HELP,
+ GRANT_APPLICATION_VISIBILITY_OPTIONS,
+} from '@/components/Notebook/PublishingForm/components/GrantApplicationVisibilitySection';
+import {
+ PROPOSAL_VISIBILITY_OPTIONS,
+ useIsLockedPrivate,
+ useShowPrivateWarning,
+} from '@/components/Notebook/PublishingForm/components/PreregistrationPrivacySection';
+import { DEFAULT_FUNDRAISE_END_DAYS } from '@/components/Notebook/PublishingForm/formMapping';
+import type { PublishingFormData } from '@/components/Notebook/PublishingForm/schema';
+import { Button } from '@/components/ui/Button';
+import { Checkbox } from '@/components/ui/form/Checkbox';
+import { cn } from '@/utils/styles';
+import { usePublishReadiness } from './masthead/usePublishReadiness';
+import { MissingDetails } from './MissingDetails';
+
+/** What each visibility looks like at a glance, for proposals and for an RFP's applications. */
+const VISIBILITY_ICONS: Record = {
+ public: Globe,
+ private: Lock,
+ OPTIONAL: User,
+ PUBLIC: Globe,
+ PRIVATE: Lock,
+};
+
+const FUNDRAISE_DAYS = ['30', '60', '90'] as const;
+
+interface PublishDialogProps {
+ /** The document's first heading: what the work is published under. Empty when it has none. */
+ readonly title: string;
+ /** Writes a title into the heading, for when there is none or it is too short to publish under. */
+ readonly onRename: (title: string) => void;
+}
+
+/**
+ * The workspace's one step between Publish and a published work, and the only
+ * place that says what is missing. It opens on every Publish click: whatever
+ * the document still needs is listed first, each with a way to add it here;
+ * then what the masthead leaves out because it has a sensible default (who
+ * can see the work, how long a fundraise runs, a nonprofit); then the posting
+ * guidelines to agree to. Its Publish waits until nothing is missing. An
+ * update has only the list and the guidelines.
+ */
+export function PublishDialog({ title, onRename }: PublishDialogProps) {
+ const { articleType, workId, isPublishing, dismissConfirmation, confirmPublish } =
+ usePublishingController();
+ const { watch } = useFormContext();
+ const selectedNonprofit = watch('selectedNonprofit');
+ const readiness = usePublishReadiness(title);
+ const [hasAgreed, setHasAgreed] = useState(false);
+ const [showGuidelines, setShowGuidelines] = useState(false);
+ // The nonprofit is chosen in here, so its confirmation comes after, in the dialog's place.
+ const [confirmingNonprofit, setConfirmingNonprofit] = useState(false);
+
+ const isRfp = articleType === 'grant';
+ const isUpdate = Boolean(workId);
+ const isNewProposal = articleType === 'preregistration' && !isUpdate;
+ const privateNeedsRfp = useShowPrivateWarning() && isNewProposal;
+
+ const action = isUpdate ? 'Update' : 'Publish';
+ const canPublish = readiness.ready && hasAgreed && !isPublishing && !privateNeedsRfp;
+
+ const publish = () => {
+ if (!canPublish) return;
+ if (isNewProposal && selectedNonprofit) setConfirmingNonprofit(true);
+ else void confirmPublish(title);
+ };
+
+ return (
+ <>
+
+ {/* Matches BaseModal. */}
+
+
+
+ {confirmingNonprofit && selectedNonprofit && (
+ setConfirmingNonprofit(false)}
+ onConfirm={() => {
+ setConfirmingNonprofit(false);
+ void confirmPublish(title);
+ }}
+ nonprofitName={selectedNonprofit.name}
+ ein={selectedNonprofit.ein}
+ />
+ )}
+ >
+ );
+}
+
+/**
+ * A setting with a default: its name, a line on what it decides, and its
+ * choices, each saying what it means, as the notebook's Details tab has them.
+ */
+function SettingSection({
+ label,
+ help,
+ action,
+ children,
+}: {
+ readonly label: ReactNode;
+ readonly help?: ReactNode;
+ /** A control at the right of the name, for a setting that is only offered. */
+ readonly action?: ReactNode;
+ readonly children?: ReactNode;
+}) {
+ return (
+
+
+
+
{label}
+ {help &&
{help}
}
+
+ {action}
+
+ {children}
+
+ );
+}
+
+/** A rule that comes with a choice, stated in gray under it. */
+function SettingNote({ children }: { readonly children: ReactNode }) {
+ return
{children}
;
+}
+
+interface Choice {
+ readonly value: string;
+ readonly label: string;
+ /** Leads the label: what the choice is at a glance (a globe for public, a lock for private). */
+ readonly icon?: LucideIcon;
+ /**
+ * What choosing it means, shown under the choices while it is the chosen
+ * one. Choices that speak for themselves ("30 days") have none.
+ */
+ readonly description?: ReactNode;
+}
+
+/**
+ * A setting's few choices side by side in one box, and under them, in the
+ * same box, what the chosen one means: the explanation reads as the
+ * switch's own, and the setting takes two lines rather than a card per choice.
+ * Without descriptions it is just the switch.
+ */
+function ChoiceBox({
+ label,
+ value,
+ choices,
+ onChange,
+}: {
+ /** Names the group for assistive technology; the section shows it visibly. */
+ readonly label: string;
+ readonly value: string;
+ readonly choices: readonly Choice[];
+ readonly onChange: (value: string) => void;
+}) {
+ const selected = choices.find((choice) => choice.value === value);
+ return (
+
+ );
+}
+
+/**
+ * A new proposal's settings: who can see it, how long it stays open, and a
+ * nonprofit to receive the funds. Two rules come with the first, and the
+ * first of them holds back the dialog's Publish: a private proposal needs an
+ * RFP, and an RFP that only takes private proposals makes the proposal private.
+ */
+function ProposalSettings() {
+ const { watch, setValue } = useFormContext();
+ const isPublic = watch('isPublic');
+ const days = watch('fundraiseEndDays') ?? DEFAULT_FUNDRAISE_END_DAYS;
+ const isLockedPrivate = useIsLockedPrivate();
+ const privateNeedsRfp = useShowPrivateWarning();
+ // The RFP may have been chosen since the form loaded, which is the only
+ // other moment the lock is applied.
+ useEffect(() => {
+ if (isLockedPrivate && isPublic !== false) setValue('isPublic', false);
+ }, [isLockedPrivate, isPublic, setValue]);
+
+ return (
+ <>
+ {isLockedPrivate ? (
+
+ ) : (
+
+ ({
+ ...option,
+ icon: VISIBILITY_ICONS[option.value],
+ }))}
+ value={isPublic === false ? 'private' : 'public'}
+ onChange={(next) => setValue('isPublic', next === 'public', { shouldValidate: true })}
+ />
+
+ )}
+ {privateNeedsRfp && (
+ In order to submit a private proposal, you must select an RFP.
+ )}
+
+
+
+ setValue('fundraiseEndDays', next as (typeof FUNDRAISE_DAYS)[number], {
+ shouldDirty: true,
+ })
+ }
+ choices={FUNDRAISE_DAYS.map((value) => ({ value, label: `${value} days` }))}
+ />
+
+
+ {/* The search itself, not an offer to open it: finding the
+ foundation is the whole of the setting. */}
+
+ Nonprofit optional
+ >
+ }
+ >
+
+
+ >
+ );
+}
+
+/** A new RFP's one setting: how its applicants may submit. */
+function RfpSettings() {
+ const { watch, setValue } = useFormContext();
+ const value = watch('applicationVisibility') ?? 'OPTIONAL';
+
+ return (
+
+
+ setValue('applicationVisibility', next as PublishingFormData['applicationVisibility'], {
+ shouldValidate: true,
+ })
+ }
+ choices={GRANT_APPLICATION_VISIBILITY_OPTIONS.map((option) => ({
+ value: option.value,
+ label: option.label,
+ icon: VISIBILITY_ICONS[option.value],
+ description:
+ option.value === 'PUBLIC' ? (
+ <>
+ {option.description}
+
+ Community match eligible: community members can co-fund public proposals, adding
+ to your funding.
+
+ >
+ ) : (
+ option.description
+ ),
+ }))}
+ />
+
+ );
+}
diff --git a/components/AIMode/document/PublishPill.tsx b/components/AIMode/document/PublishPill.tsx
new file mode 100644
index 000000000..a3cf44b16
--- /dev/null
+++ b/components/AIMode/document/PublishPill.tsx
@@ -0,0 +1,156 @@
+'use client';
+
+import { ExternalLink, Send } from 'lucide-react';
+import { usePublishingController } from '@/components/Notebook/PublishingForm';
+import { getWorkPath } from '@/components/Notebook/PublishingForm/formMapping';
+import { Loader } from '@/components/ui/Loader';
+import { cn } from '@/utils/styles';
+
+/**
+ * The document's Publish button, floating at the bottom centre of the pane.
+ * It never waits on missing details and never comments on them: every click
+ * opens the publish dialog, which lists what is still empty and lets it be
+ * filled in there. Once published it says so, with a way to the page and an
+ * Update button.
+ */
+export function PublishPill() {
+ const controller = usePublishingController();
+ const { note, editor, articleType, workId, isPublished, isChangelog } = controller;
+
+ // A type this surface cannot publish has no button to press.
+ const isNewPreprint = articleType === 'discussion' && !workId && !isChangelog;
+ if (!articleType || isNewPreprint || controller.blockedMessage) return null;
+
+ const handleClick = () => void controller.requestPublish();
+
+ const busyLabel = controller.isUpserting
+ ? 'Publishing...'
+ : controller.isLinkingNonprofit
+ ? 'Linking nonprofit...'
+ : controller.isRedirecting
+ ? 'Redirecting...'
+ : null;
+ const disabled =
+ controller.readOnly || controller.isDeclined || controller.isPublishing || editor == null;
+
+ const livePath =
+ isPublished && note?.post && workId ? getWorkPath(articleType, workId, note.post.slug) : null;
+
+ return (
+
+ Activity data failed to load. Refresh to try again.
+
+ )}
+ {!hasEntries && !error ? (
@@ -52,7 +71,7 @@ export const ActivitySidebar: FC = ({
) : (
- {entries.map((entry) => (
+ {activityEntries.map((entry) => (
= {
+ working: 'The assistant is working on this document',
+ review: 'The assistant’s changes are ready to review',
+};
+
+/** The one sign that the assistant is at work: on a document, and on a chat's row in History. */
+export function AssistantActivityDot({ state, className }: AssistantActivityDotProps) {
+ return (
+
+
+
+ );
+}
diff --git a/components/AgentChat/ChatComposer.tsx b/components/AgentChat/ChatComposer.tsx
index a3402ce00..8958ef63a 100644
--- a/components/AgentChat/ChatComposer.tsx
+++ b/components/AgentChat/ChatComposer.tsx
@@ -69,6 +69,12 @@ interface ChatComposerProps {
>;
/** What takes file drops for this composer: the whole pane, say. Itself by default. */
readonly dropTargetRef?: RefObject;
+ /** Extra classes for the box itself: a host can round it more, pad it more, give it a shadow. */
+ readonly boxClassName?: string;
+ /** Lines the empty box shows; it still grows with the text from there. */
+ readonly minRows?: number;
+ /** Classes for the send button while a message can go, in place of the brand blue. */
+ readonly sendClassName?: string;
}
const COUNTER_THRESHOLD = MAX_CHAT_MESSAGE_LENGTH - 1000;
@@ -94,6 +100,9 @@ export function ChatComposer({
className,
attachments,
dropTargetRef,
+ boxClassName,
+ minRows = 1,
+ sendClassName,
}: ChatComposerProps) {
const wrapperRef = useRef(null);
const fileInputRef = useRef(null);
@@ -187,7 +196,8 @@ export function ChatComposer({
className={cn(
'relative rounded-lg border border-gray-200 bg-white px-3 py-2 transition-all',
'focus-within:border-gray-400',
- disabled && 'opacity-60'
+ disabled && 'opacity-60',
+ boxClassName
)}
>
{dragging && (
@@ -204,7 +214,7 @@ export function ChatComposer({
onChange={(event) => onChange(event.target.value)}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
- rows={1}
+ rows={minRows}
maxLength={MAX_CHAT_MESSAGE_LENGTH}
disabled={disabled}
placeholder={placeholder}
@@ -257,7 +267,7 @@ export function ChatComposer({
className={cn(
'flex h-8 w-8 shrink-0 items-center justify-center rounded-full transition-colors',
canSend
- ? 'bg-primary-500 text-white hover:bg-primary-600'
+ ? (sendClassName ?? 'bg-primary-500 text-white hover:bg-primary-600')
: 'cursor-not-allowed bg-gray-100 text-gray-400'
)}
>
diff --git a/components/AgentChat/ChatEmptyState.tsx b/components/AgentChat/ChatEmptyState.tsx
new file mode 100644
index 000000000..f1e3af237
--- /dev/null
+++ b/components/AgentChat/ChatEmptyState.tsx
@@ -0,0 +1,51 @@
+'use client';
+
+import { Logo } from '@/components/ui/Logo';
+import { ChatPresets } from './ChatPresets';
+
+interface ChatEmptyStateProps {
+ readonly noteIsEmpty: boolean;
+ readonly noteIsRfp: boolean;
+ readonly onSelectPreset: (message: string) => void;
+ readonly presetsDisabled: boolean;
+ /** What the help line calls the thing being written: the notebook's "note", the workspace's "document". */
+ readonly noun?: string;
+}
+
+/**
+ * A chat on a document before its first message: who the assistant is, what
+ * it can do, and a few ways to start, chosen by what the document is and
+ * whether anything is written in it yet.
+ */
+export function ChatEmptyState({
+ noteIsEmpty,
+ noteIsRfp,
+ onSelectPreset,
+ presetsDisabled,
+ noun = 'note',
+}: ChatEmptyStateProps) {
+ return (
+
+
+
+
+
+
+
+ Research assistant
+
+
+ Ask questions about this {noun}, search the web and scholarly literature, or have the
+ assistant edit the draft for you.
+
+
+
+
+
+ );
+}
diff --git a/components/AgentChat/ChatPicker.tsx b/components/AgentChat/ChatPicker.tsx
index ecfb439d8..e18b36814 100644
--- a/components/AgentChat/ChatPicker.tsx
+++ b/components/AgentChat/ChatPicker.tsx
@@ -1,12 +1,13 @@
'use client';
import type { ReactNode } from 'react';
-import { Loader } from '@/components/ui/Loader';
+import { SquarePen } from 'lucide-react';
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';
+import { AssistantActivityDot } from './AssistantActivityDot';
interface ChatPickerProps {
readonly chats: AgentChatListItem[];
@@ -22,14 +23,22 @@ interface ChatPickerProps {
* is — it only owns where it sits.
*/
readonly titleAction?: ReactNode;
+ /** What opens the menu, in place of the open chat's title. */
+ readonly trigger?: ReactNode;
+ /** Offers a new chat as the menu's first item. */
+ readonly onNewChat?: () => void;
+ /** The listing could not be loaded; the menu offers to try again. */
+ readonly failed?: boolean;
+ readonly className?: string;
}
/**
- * Header dropdown for switching between the note's chats. Built on the cheap
- * listing projection: title, preview, activity spinner — never full chats.
+ * A dropdown for switching between a document's chats. Built on the cheap
+ * listing projection: title, preview, activity dot — 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.
+ * In the notebook it is the header's title and switching is all it does;
+ * starting a chat lives on the header button beside it, where it is one tap
+ * rather than two. The workspace opens it from an icon, with "New chat" first.
*/
export function ChatPicker({
chats,
@@ -38,6 +47,10 @@ export function ChatPicker({
onSelect,
onOpen,
titleAction,
+ trigger,
+ onNewChat,
+ failed = false,
+ className,
}: ChatPickerProps) {
const currentLabel = activeChatId == null ? 'New chat' : activeTitle?.trim() || 'Untitled chat';
@@ -46,22 +59,53 @@ export function ChatPicker({
// 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}
-
+ trigger ?? (
+
+ {currentLabel}
+
+ )
}
>
+ {onNewChat && (
+ <>
+
+
+ New chat
+
+ {chats.length > 0 && (
+
+ )}
+ >
+ )}
+
+ {failed && chats.length === 0 && (
+ {
+ // Stays open to show what the retry brings back.
+ event.preventDefault();
+ onOpen();
+ }}
+ className="cursor-pointer flex-col items-start gap-0.5 rounded-md px-3 py-2 focus:bg-gray-50 data-[highlighted]:bg-gray-50"
+ >
+ Couldn’t load chats.
+ Try again
+
+ )}
+
{/* The listing is empty until the first chat is saved, and the menu
would otherwise open as a bare box. */}
- {chats.length === 0 && (
+ {chats.length === 0 && !onNewChat && !failed && (
{chat.last_message_preview && (
diff --git a/components/AgentChat/ChatTranscript.tsx b/components/AgentChat/ChatTranscript.tsx
index fabf150ae..205c3eca3 100644
--- a/components/AgentChat/ChatTranscript.tsx
+++ b/components/AgentChat/ChatTranscript.tsx
@@ -190,9 +190,16 @@ interface ChatTranscriptProps {
* transcript stays ignorant of what a turn can make.
*/
readonly renderExecutionExtra?: (execution: ChatExecution) => ReactNode;
+ /** What to offer under a failed turn's error, inside its box (see ExecutionProgress). */
+ readonly renderFailureActions?: (execution: ChatExecution) => ReactNode;
}
-export function ChatTranscript({ chat, pendingSend, renderExecutionExtra }: ChatTranscriptProps) {
+export function ChatTranscript({
+ chat,
+ pendingSend,
+ renderExecutionExtra,
+ renderFailureActions,
+}: ChatTranscriptProps) {
const entries = useMemo(() => buildTranscript(chat, pendingSend), [chat, pendingSend]);
return (
@@ -225,7 +232,10 @@ export function ChatTranscript({ chat, pendingSend, renderExecutionExtra }: Chat
case 'execution':
return (
-
+
{entry.answer && (
(null);
// Durable activity is followed by the current provider iteration's
@@ -202,6 +207,7 @@ export function ExecutionProgress({ execution }: ExecutionProgressProps) {
{execution.error?.message ?? 'Something went wrong.'}
)}
+ {failed && failureActions &&
{failureActions}
}
{cancelled && (
void;
readonly disabled: boolean;
readonly multiplierExplanation: string;
+ /** The sparkle and gauge before each label; off, the labels stand alone. The lock still shows. */
+ readonly showIcons?: boolean;
}
/**
@@ -73,6 +75,7 @@ export function ModelControls({
onChangeOptions,
disabled,
multiplierExplanation,
+ showIcons = true,
}: ModelControlsProps) {
if (!model) return null;
@@ -113,9 +116,9 @@ export function ModelControls({
icon={
pinned ? (
- ) : (
+ ) : showIcons ? (
- )
+ ) : undefined
}
srLabel={pinned ? 'Assistant model, locked for this chat:' : 'Assistant model:'}
className="max-w-[180px]"
@@ -153,9 +156,9 @@ export function ModelControls({
icon={
effortLocked ? (
- ) : (
+ ) : showIcons ? (
- )
+ ) : undefined
}
srLabel={effortLocked ? 'Effort, locked for this chat:' : 'Effort:'}
className="max-w-[140px]"
diff --git a/components/AgentChat/RefusalActions.tsx b/components/AgentChat/RefusalActions.tsx
new file mode 100644
index 000000000..f10a15960
--- /dev/null
+++ b/components/AgentChat/RefusalActions.tsx
@@ -0,0 +1,43 @@
+'use client';
+
+interface RefusalActionsProps {
+ /** Puts the refused message back in the composer to reword; absent when it is not known. */
+ readonly onEditMessage?: () => void;
+ /** Opens a new chat, without the refused exchange in its context. */
+ readonly onNewChat: () => void;
+}
+
+/**
+ * Under a refusal: the model declined because of what was asked, so asking
+ * the same thing again fails the same way. What helps is rewording it, or
+ * starting over in a chat that does not carry the refused exchange.
+ */
+export function RefusalActions({ onEditMessage, onNewChat }: RefusalActionsProps) {
+ return (
+
+
Try rewording your message, or start a new chat.
+
+ {onEditMessage && Edit message}
+ New chat
+
+
+ );
+}
+
+function ActionButton({
+ onClick,
+ children,
+}: {
+ readonly onClick: () => void;
+ readonly children: string;
+}) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/components/AgentChat/chatNotices.ts b/components/AgentChat/chatNotices.ts
new file mode 100644
index 000000000..e4cd01fa5
--- /dev/null
+++ b/components/AgentChat/chatNotices.ts
@@ -0,0 +1,85 @@
+import type { SendOutcome } from '@/services/notebookChat.service';
+import { formatBudgetReset } from '@/types/researchAI';
+import type { ComposerNotice } from './ChatComposer';
+
+export type FailedSend = Extract;
+
+/** How a surface words its notices and who shows a spent budget. */
+export interface ChatNoticePolicy {
+ /** What the surface calls a chat: "conversation" in the workspace, "chat" in the notebook. */
+ readonly noun: 'conversation' | 'chat';
+ /**
+ * A spent budget is announced by the composer notice, or left to a meter
+ * rendered beside the composer that already says so.
+ */
+ readonly usageLimit: 'inline' | 'meter';
+}
+
+/** The spent-budget notice, naming the reset time when it is known. */
+export function budgetSpentNotice(budgetResetsAt: string | null): ComposerNotice {
+ return {
+ tone: 'warning',
+ text: budgetResetsAt
+ ? `You’ve used today’s assistant budget. It resets at ${formatBudgetReset(budgetResetsAt)}.`
+ : 'You’ve used today’s assistant budget. Try again after it resets.',
+ };
+}
+
+/** A budget notice that went out before the reset time was known. */
+export function isBudgetNoticeWithoutReset(notice: ComposerNotice | null): boolean {
+ return (
+ notice?.tone === 'warning' &&
+ notice.text.includes('budget') &&
+ !notice.text.includes('resets at')
+ );
+}
+
+/**
+ * Composer copy for a failed send. Server `detail` is rendered verbatim
+ * wherever it exists; the fallbacks only cover bodies without one.
+ */
+export function noticeFromOutcome(
+ outcome: FailedSend,
+ policy: ChatNoticePolicy,
+ budgetResetsAt: string | null
+): ComposerNotice | null {
+ 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':
+ if (policy.usageLimit === 'meter') return null;
+ return budgetResetsAt
+ ? budgetSpentNotice(budgetResetsAt)
+ : { tone: 'warning', text: outcome.detail ?? budgetSpentNotice(null).text };
+ 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 ${policy.noun} 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.',
+ };
+ }
+}
diff --git a/components/Earn/EarnEarningsSummary.tsx b/components/Earn/EarnEarningsSummary.tsx
deleted file mode 100644
index b80a2becd..000000000
--- a/components/Earn/EarnEarningsSummary.tsx
+++ /dev/null
@@ -1,167 +0,0 @@
-'use client';
-
-import { useMemo } from 'react';
-import { Star } from 'lucide-react';
-import { FundingIcon } from '@/components/ui/icons/FundingIcon';
-import { cn } from '@/utils/styles';
-import { useUser } from '@/contexts/UserContext';
-import { useCurrencyPreference } from '@/contexts/CurrencyPreferenceContext';
-import { useEarningOverview } from '@/components/Earn/lib/hooks/useEarningOverview';
-import type { EarningAmount } from '@/types/user';
-import { formatRSC, formatLiteralUsd } from '@/utils/number';
-
-interface DisplayPair {
- primary: string;
- secondary: string | null;
-}
-
-function sumEarningAmounts(amounts: (EarningAmount | undefined)[]): EarningAmount {
- return amounts.reduce(
- (acc, amount) => ({
- rsc: acc.rsc + (amount?.rsc ?? 0),
- rscUsdSnapshot: acc.rscUsdSnapshot + (amount?.rscUsdSnapshot ?? 0),
- usd: acc.usd + (amount?.usd ?? 0),
- }),
- { rsc: 0, rscUsdSnapshot: 0, usd: 0 }
- );
-}
-
-function formatAmountPair(
- amountRsc: number,
- totalUsd: number,
- showUSD: boolean,
- withPlus = false
-): DisplayPair {
- const sign = withPlus && amountRsc > 0 ? '+' : '';
- const rsc = `${sign}${formatRSC({ amount: amountRsc, decimalPlaces: 2 })} RSC`;
- const usd = `${sign}${formatLiteralUsd(totalUsd)}`;
- return showUSD ? { primary: usd, secondary: rsc } : { primary: rsc, secondary: usd };
-}
-
-function formatEarningPair(amount: EarningAmount, showUSD: boolean, withPlus = false): DisplayPair {
- return formatAmountPair(amount.rsc, amount.rscUsdSnapshot + amount.usd, showUSD, withPlus);
-}
-
-export function EarnEarningsSummary() {
- const { user } = useUser();
- const { showUSD } = useCurrencyPreference();
- const { overview, isLoading: isLoadingOverview } = useEarningOverview(user?.id);
-
- const reviewAmount = useMemo(
- () => sumEarningAmounts([overview?.bySource.TIP_REVIEW, overview?.bySource.BOUNTY_PAYOUT]),
- [overview]
- );
- const fundraiseAmount = useMemo(
- () =>
- sumEarningAmounts([
- overview?.bySource.FUNDRAISE_PAYOUT,
- overview?.bySource.USD_FUNDRAISE_PAYOUT,
- ]),
- [overview]
- );
- const totalRsc = overview?.totalEarned.rsc ?? 0;
- const totalUsd = (overview?.totalEarned.rscUsdSnapshot ?? 0) + (overview?.totalEarned.usd ?? 0);
-
- const total = useMemo(
- () => formatAmountPair(totalRsc, totalUsd, showUSD, true),
- [totalRsc, totalUsd, showUSD]
- );
- const reviews = useMemo(() => formatEarningPair(reviewAmount, showUSD), [reviewAmount, showUSD]);
- const fundraises = useMemo(
- () => formatEarningPair(fundraiseAmount, showUSD),
- [fundraiseAmount, showUSD]
- );
-
- const isReady = !isLoadingOverview;
-
- return (
-
- {/* Header — Lifetime Earnings headline (the only green figure) */}
-
- );
-};
diff --git a/components/Funding/FundingDirectionIcon.tsx b/components/Funding/FundingDirectionIcon.tsx
new file mode 100644
index 000000000..2e477b78d
--- /dev/null
+++ b/components/Funding/FundingDirectionIcon.tsx
@@ -0,0 +1,35 @@
+import { cn } from '@/utils/styles';
+import { FUNDING_DIRECTION, type FundingDirection } from './fundingDirection';
+
+interface FundingDirectionIconProps {
+ readonly direction: FundingDirection;
+ /**
+ * Coloured by direction, or neutral: a grey circle whose arrow inherits
+ * the text colour around it, for a place that colours itself (a tab).
+ */
+ readonly colored?: boolean;
+ readonly className?: string;
+ readonly iconClassName?: string;
+}
+
+/** The direction's arrow in a circle. */
+export function FundingDirectionIcon({
+ direction,
+ colored = true,
+ className,
+ iconClassName,
+}: FundingDirectionIconProps) {
+ const { icon: Icon, iconClass, chipClass } = FUNDING_DIRECTION[direction];
+ return (
+
+
+
+ );
+}
diff --git a/components/Funding/OpenFundingOpportunityModal.tsx b/components/Funding/OpenFundingOpportunityModal.tsx
index 04ed020a6..f6bf9bf1e 100644
--- a/components/Funding/OpenFundingOpportunityModal.tsx
+++ b/components/Funding/OpenFundingOpportunityModal.tsx
@@ -102,7 +102,8 @@ const CREATION_OPTIONS: CreationOption[] = [
},
];
-const WHITE_GLOVE_BOOKING_URL = 'https://cal.com/tyler-diorio/15min';
+/** Where a funder books time with the ResearchHub team to shape an RFP together. */
+export const WHITE_GLOVE_BOOKING_URL = 'https://cal.com/tyler-diorio/15min';
type Step = 'benefits' | 'method' | 'upload';
diff --git a/components/Funding/createFundingDraft.ts b/components/Funding/createFundingDraft.ts
new file mode 100644
index 000000000..d97d7dd7f
--- /dev/null
+++ b/components/Funding/createFundingDraft.ts
@@ -0,0 +1,74 @@
+import type { JSONContent } from '@tiptap/core';
+import { getTemplatePlainText } from '@/components/Editor/lib/utils/documentTitle';
+import { NoteService } from '@/services/note.service';
+import type { Note, NoteAccess } from '@/types/note';
+import type { ID } from '@/types/root';
+import type { FundingIntent } from './fundingDirection';
+
+/** The note's document type for each side of the money. */
+export const DOCUMENT_TYPE_BY_INTENT: Record = {
+ fund: 'GRANT',
+ need_funding: 'PREREGISTRATION',
+};
+
+export interface CreateFundingDraftOptions {
+ readonly orgSlug: string;
+ readonly title: string;
+ /** `GRANT`, `PREREGISTRATION`, or the notebook's changelog `DISCUSSION`. */
+ readonly documentType: string;
+ /** `WORKSPACE` shares it with the organization; `PRIVATE` keeps it to its author. */
+ readonly grouping: NoteAccess;
+ /** The Request for Proposals a proposal answers. */
+ readonly selectedGrantId?: Exclude | null;
+ /** What the document starts with; without one it starts empty. */
+ readonly template?: JSONContent;
+ /**
+ * A note an earlier attempt created before a later step failed: the
+ * creation resumes on it instead of making a second one.
+ */
+ readonly existingNote?: Note | null;
+ /** The note exists; runs before the later steps, so a caller can keep it for a retry. */
+ readonly onNoteCreated?: (note: Note) => void;
+}
+
+/**
+ * A new draft: the note, then the RFP it answers, then its first content.
+ * Shared by the notebook, which starts drafts from templates, and the
+ * workspace, which starts them empty for the assistant to write. Throws if
+ * any step fails; the steps after creation can be run again.
+ */
+export async function createFundingDraft({
+ orgSlug,
+ title,
+ documentType,
+ grouping,
+ selectedGrantId,
+ template,
+ existingNote,
+ onNoteCreated,
+}: CreateFundingDraftOptions): Promise {
+ let note = existingNote ?? null;
+ if (note == null) {
+ note = await NoteService.createNote({
+ title,
+ grouping,
+ organization_slug: orgSlug,
+ document_type: documentType,
+ });
+ onNoteCreated?.(note);
+ }
+
+ if (selectedGrantId) {
+ await NoteService.updateNote({ noteId: note.id, selectedGrantId });
+ }
+
+ if (template) {
+ await NoteService.updateNoteContent({
+ note: note.id,
+ full_json: JSON.stringify(template),
+ plain_text: getTemplatePlainText(template),
+ });
+ }
+
+ return note;
+}
diff --git a/components/Funding/dashboard/DashboardSectionHeader.tsx b/components/Funding/dashboard/DashboardSectionHeader.tsx
new file mode 100644
index 000000000..6701f4f9d
--- /dev/null
+++ b/components/Funding/dashboard/DashboardSectionHeader.tsx
@@ -0,0 +1,22 @@
+import type { ReactNode } from 'react';
+
+interface DashboardSectionHeaderProps {
+ readonly title: string;
+ /** A small note after the title, e.g. how many items are active. */
+ readonly meta?: ReactNode;
+ /** The section's action, at the right: the button that starts a new one. */
+ readonly action?: ReactNode;
+}
+
+/** The heading row of a My Funding section: the title, a note beside it, and the way to add to it. */
+export function DashboardSectionHeader({ title, meta, action }: DashboardSectionHeaderProps) {
+ return (
+
+
+
{title}
+ {meta && {meta}}
+
+ {action}
+
+ );
+}
diff --git a/components/Funding/dashboard/DocumentThumbnail.tsx b/components/Funding/dashboard/DocumentThumbnail.tsx
new file mode 100644
index 000000000..5cb6b685d
--- /dev/null
+++ b/components/Funding/dashboard/DocumentThumbnail.tsx
@@ -0,0 +1,29 @@
+import Image from 'next/image';
+import { FileText } from 'lucide-react';
+import { cn } from '@/utils/styles';
+
+interface DocumentThumbnailProps {
+ readonly image: string | null;
+ readonly size?: 'md' | 'lg';
+}
+
+/** A funding document's image, or a file icon when it has none. */
+export function DocumentThumbnail({ image, size = 'md' }: DocumentThumbnailProps) {
+ const large = size === 'lg';
+ return (
+
+
+ {image ? (
+
+ ) : (
+
+ )}
+
+
+ );
+}
diff --git a/components/Funding/dashboard/FundedProposalsSection.tsx b/components/Funding/dashboard/FundedProposalsSection.tsx
deleted file mode 100644
index 7189b75c3..000000000
--- a/components/Funding/dashboard/FundedProposalsSection.tsx
+++ /dev/null
@@ -1,125 +0,0 @@
-'use client';
-
-import { FC, useCallback, useMemo } from 'react';
-import Link from 'next/link';
-import { SupportedProposal } from '@/types/funder';
-import { formatCurrency } from '@/utils/currency';
-import { useCurrencyPreference } from '@/contexts/CurrencyPreferenceContext';
-import { useExchangeRate } from '@/contexts/ExchangeRateContext';
-import { Avatar } from '@/components/ui/Avatar';
-import { buildWorkUrl } from '@/utils/url';
-
-interface FundedProposalsSectionProps {
- proposals: SupportedProposal[];
- className?: string;
-}
-
-export const FundedProposalsSection: FC = ({
- proposals,
- className,
-}) => {
- const { showUSD } = useCurrencyPreference();
- const { exchangeRate } = useExchangeRate();
-
- const fmt = useCallback(
- (rsc: number, usd: number) =>
- formatCurrency({
- amount: showUSD ? usd : rsc,
- showUSD,
- exchangeRate,
- shorten: true,
- skipConversion: true,
- }),
- [showUSD, exchangeRate]
- );
-
- // Ranked by the amount actually on screen, so the rows always descend even
- // when the RSC and USD orderings disagree.
- const displayedAmount = useCallback(
- (proposal: SupportedProposal) =>
- showUSD ? proposal.fundedAmount.usd : proposal.fundedAmount.rsc,
- [showUSD]
- );
-
- const ranked = useMemo(
- () => [...proposals].sort((a, b) => displayedAmount(b) - displayedAmount(a)),
- [proposals, displayedAmount]
- );
-
- const totalRsc = proposals.reduce((sum, p) => sum + p.fundedAmount.rsc, 0);
- const totalUsd = proposals.reduce((sum, p) => sum + p.fundedAmount.usd, 0);
-
- return (
-
- {/* Same eyebrow treatment as the hero KPIs, so the label reads as a
- field name rather than part of the proposal's own metadata. */}
-
- Amount funded
-
-
- {amount}
-
-
-
- );
-};
diff --git a/components/Funding/dashboard/FundedResearchList.tsx b/components/Funding/dashboard/FundedResearchList.tsx
new file mode 100644
index 000000000..7ca361a3d
--- /dev/null
+++ b/components/Funding/dashboard/FundedResearchList.tsx
@@ -0,0 +1,118 @@
+'use client';
+
+import Link from 'next/link';
+import { Bell, Star } from 'lucide-react';
+import { Avatar } from '@/components/ui/Avatar';
+import { DashboardSectionHeader } from '@/components/Funding/dashboard/DashboardSectionHeader';
+import type { FundedRow } from '@/components/Funding/dashboard/lib/myFundingModel';
+import { useMoneyFormat } from '@/components/Funding/dashboard/lib/useMoneyFormat';
+import { formatTimeAgo } from '@/utils/date';
+
+interface FundedResearchListProps {
+ readonly rows: readonly FundedRow[];
+ /** How many rows to show; the rest wait behind "See all". */
+ readonly limit?: number;
+ /** Opens the Funded tab, where every row is. */
+ readonly onSeeAll?: () => void;
+}
+
+/**
+ * Research you funded: each proposal with its scientist, the latest word from
+ * its author or reviewers, and what you gave.
+ */
+export function FundedResearchList({ rows, limit, onSeeAll }: FundedResearchListProps) {
+ if (rows.length === 0) return null;
+
+ const shown = limit ? rows.slice(0, limit) : rows;
+
+ return (
+
+ limit &&
+ onSeeAll && (
+
+ See all {rows.length}
+
+ )
+ }
+ />
+ {/* One card holding every row, like Your peer reviews. */}
+
- )}
-
- setIsScientistsOpen(false)}
- researchers={overview.supportedResearchers}
- />
-
- setIsInstitutionsOpen(false)}
- institutions={overview.supportedInstitutions}
- />
-
- );
-};
-
-const Divider: FC<{ className?: string }> = ({ className }) => (
-
-);
-
-interface KpiProps {
- label: string;
- /** Shown below tablet, where three full labels can't fit on one row. */
- shortLabel: string;
- value: string;
- hero?: boolean;
- tooltip?: ReactNode;
-}
-
-/**
- * From tablet up each cell takes an equal third and centers itself, so the
- * three sit evenly across the card rather than being pushed to the left, middle
- * and right edges. Below that the cells size to their content — equal thirds
- * are too narrow for the values on a phone and they collide.
- */
-const KPI_CELL = 'min-w-0 tablet:flex-1';
-
-/**
- * All three amounts share one size until the card reaches its full desktop
- * width, where the hero becomes the largest number on the row again. Mixing
- * sizes in a row this tight reads as a rendering bug, and `text-2xl` is as
- * large as three values can go before they collide on a phone.
- */
-const KPI_VALUE =
- 'whitespace-nowrap font-mono font-semibold leading-none tracking-tight text-2xl tablet:text-4xl';
-
-const Kpi: FC = ({ label, shortLabel, value, hero, tooltip }) => {
- const content = (
-
-);
-
-interface SupporterRowProps {
- label: ReactNode;
- onShowAll: () => void;
- className?: string;
- children: ReactNode;
-}
-
-/** Eyebrow heading over a row of avatars or chips, all opening a modal. */
-const SupporterRow: FC = ({ label, onShowAll, className, children }) => (
-
-
- {label}
-
- {/* min-height matches the avatar stack so chips sit on the same line as the faces. */}
-
- {children}
-
-
-);
-
-const MAX_VISIBLE_INSTITUTIONS = 2;
-
-const InstitutionChips: FC<{ institutions: SupportedInstitution[] }> = ({ institutions }) => {
- const remaining = Math.max(0, institutions.length - MAX_VISIBLE_INSTITUTIONS);
-
- return (
- // One row at every width: the chips shrink and truncate rather than wrap, so
- // a couple of long institution names can't turn the preview into a stack.
-
- router.push('/notebook?newGrant=true')}
- >
-
- New RFP
-
-
- }
- />
-
-
- {overview && (
-
- )}
- >
- );
-}
diff --git a/components/Funding/dashboard/ModeratorViewAsFunder.tsx b/components/Funding/dashboard/ModeratorViewAsFunder.tsx
index 891f55edf..998de42f9 100644
--- a/components/Funding/dashboard/ModeratorViewAsFunder.tsx
+++ b/components/Funding/dashboard/ModeratorViewAsFunder.tsx
@@ -3,7 +3,7 @@
import { FC, useCallback, useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/Popover';
-import { Shield, X } from 'lucide-react';
+import { ChevronDown, Shield, X } from 'lucide-react';
import { Avatar } from '@/components/ui/Avatar';
import {
SearchableUserSingleSelect,
@@ -13,15 +13,18 @@ import { cn } from '@/utils/styles';
interface ModeratorViewAsFunderProps {
className?: string;
+ variant?: 'default' | 'tab';
}
/**
- * Moderator-only override that points the funding dashboard at another funder,
- * kept to a shield icon until it is in use. The active state comes from the
- * `user_id` param rather than local state so a reload or a shared link still
- * shows that the dashboard belongs to someone else.
+ * Moderator-only override that points the funding dashboard at another funder.
+ * The active state comes from the `user_id` param rather than local state so a
+ * reload or a shared link still shows the dashboard override.
*/
-export const ModeratorViewAsFunder: FC = ({ className }) => {
+export const ModeratorViewAsFunder: FC = ({
+ className,
+ variant = 'default',
+}) => {
const router = useRouter();
const searchParams = useSearchParams();
const [isOpen, setIsOpen] = useState(false);
@@ -49,7 +52,29 @@ export const ModeratorViewAsFunder: FC = ({ classNam
return (
- {userIdParam ? (
+ {variant === 'tab' ? (
+ <>
+
+
+
+ Moderator
+ {userIdParam && (
+
+ · {label}
+
+ )}
+
+
+
+ >
+ ) : userIdParam ? (
// Two sibling buttons rather than a nested one: the chip as a whole
// reopens the picker, the X exits the override.