diff --git a/app/layouts/MobileBottomNav.tsx b/app/layouts/MobileBottomNav.tsx index 9e31604db..79ca3cbf1 100644 --- a/app/layouts/MobileBottomNav.tsx +++ b/app/layouts/MobileBottomNav.tsx @@ -13,7 +13,7 @@ import { faBars, } from '@fortawesome/pro-light-svg-icons'; import { faXTwitter, faDiscord, faGithub, faLinkedin } from '@fortawesome/free-brands-svg-icons'; -import { BriefcaseBusiness, Sprout, Star } from 'lucide-react'; +import { Sprout, Star } from 'lucide-react'; import { ChangelogLink } from '@/components/changelog/ChangelogLink'; import { FundingPowerBar } from '@/components/Funding/FundingPowerBar'; import { Icon } from '@/components/ui/icons'; @@ -24,10 +24,6 @@ import { useAuthenticatedAction } from '@/contexts/AuthModalContext'; import { useCurrencyPreference } from '@/contexts/CurrencyPreferenceContext'; import { useScrollContainer } from '@/contexts/ScrollContainerContext'; import { isHomeTabPath } from '@/hooks/useFundTabs'; -import { useUser } from '@/contexts/UserContext'; -import { useOptionalAIMode } from '@/components/AIMode/AIModeContext'; -import { AI_MODE_NAME } from '@/components/AIMode/copy'; -import { isHubEditorOrModerator } from '@/utils/permissions'; interface NavItem { label: string; @@ -36,8 +32,6 @@ interface NavItem { isMore?: boolean; requiresAuth?: boolean; isHome?: boolean; - /** Opens the workspace in place, on its new-conversation screen, instead of navigating. */ - isAIMode?: boolean; } // Additional navigation items not in the bottom bar @@ -76,17 +70,11 @@ export const MobileBottomNav: React.FC = () => { const { executeAuthenticatedAction } = useAuthenticatedAction(); const { showUSD, toggleCurrency } = useCurrencyPreference(); const scrollContainerRef = useScrollContainer(); - const { user } = useUser(); - const aiMode = useOptionalAIMode(); - // Moderators and hub editors, the only users the workspace admits, get it - // in the bar where Peer Review sits for everyone else. const mainNavItems: NavItem[] = [ { label: 'Home', href: '/', iconKey: 'home', isHome: true }, { label: 'My Funding', href: '/my-funding', iconKey: 'fund', requiresAuth: true }, - isHubEditorOrModerator(user) - ? { label: AI_MODE_NAME, iconKey: 'workspace', isAIMode: true } - : { label: 'Peer Review', href: '/peer-review', iconKey: 'peer-review' }, + { label: 'Peer Review', href: '/peer-review', iconKey: 'peer-review' }, { label: 'Wallet', href: '/researchcoin', iconKey: 'wallet' }, { label: 'More', isMore: true, iconKey: 'more' }, ]; @@ -117,10 +105,6 @@ export const MobileBottomNav: React.FC = () => { setIsMoreOpen(true); return; } - if (item.isAIMode) { - aiMode?.selectChat(null); - return; - } if (item.requiresAuth) { executeAuthenticatedAction(() => router.push(item.href!)); @@ -151,10 +135,6 @@ export const MobileBottomNav: React.FC = () => { color={iconColor} /> ); - case 'workspace': - return ( - - ); case 'peer-review': return ( { {mainNavItems.map((item) => { const isActive = item.isMore ? isMoreActive || isMoreOpen - : item.isAIMode - ? Boolean(aiMode?.isOpen) - : item.href - ? isPathActive(item.href, pathname, item.isHome) - : false; + : item.href + ? isPathActive(item.href, pathname, item.isHome) + : false; return ( - ); - } + const label = {item.label}; return ( = ({
)}
-
- {item.label} -
+
{label}
); }; @@ -255,11 +215,9 @@ export const Navigation: React.FC = ({ )} >
- {navigationItems - .filter((item) => !item.isAIMode || canUseAssistant) - .map((item) => ( - - ))} + {navigationItems.map((item) => ( + + ))}
); diff --git a/app/layouts/PageLayout.tsx b/app/layouts/PageLayout.tsx index f3cc9f54f..4751c70ff 100644 --- a/app/layouts/PageLayout.tsx +++ b/app/layouts/PageLayout.tsx @@ -31,6 +31,11 @@ interface PageLayoutProps { 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 @@ -54,6 +59,7 @@ function PageLayoutInner({ sidebarContentClassName, topBanner, rightSidebarAbove, + rightSidebarFill = false, contentWidth = 'default', wideRow = false, }: PageLayoutProps) { @@ -144,6 +150,7 @@ function PageLayoutInner({ rightSidebar={rightSidebar} contentClassName={sidebarContentClassName} aboveSidebar={rightSidebarAbove} + fill={rightSidebarFill} /> )} diff --git a/app/layouts/PublishMenu.tsx b/app/layouts/PublishMenu.tsx index dceae1b10..432c72331 100644 --- a/app/layouts/PublishMenu.tsx +++ b/app/layouts/PublishMenu.tsx @@ -15,6 +15,8 @@ import { OpenProposalModal, type ProposalCreationMethod, } from '@/components/Funding/OpenProposalModal'; +import type { FundingIntent } from '@/components/Funding/fundingDirection'; +import { useFundingDrafting } from '@/components/Funding/useFundingDrafting'; import { useScreenSize } from '@/hooks/useScreenSize'; import { useState } from 'react'; @@ -22,7 +24,19 @@ interface PublishMenuProps { forceMinimize?: boolean; } -const PUBLISH_MENU_SECTIONS = [ +interface PublishMenuItem { + readonly id: string; + readonly title: string; + readonly description: string; + readonly icon: React.ReactNode; + /** Which side of the money the item starts: an RFP funds, a proposal needs funding. */ + readonly intent: FundingIntent; +} + +const PUBLISH_MENU_SECTIONS: readonly { + readonly title: string; + readonly items: readonly PublishMenuItem[]; +}[] = [ { title: 'Publish on ResearchHub', items: [ @@ -31,7 +45,7 @@ const PUBLISH_MENU_SECTIONS = [ title: 'Request for Proposal', description: 'Fund specific research you care about', icon: , - handler: 'handleOpenGrant', + intent: 'fund', }, { id: 'request-funding', @@ -40,11 +54,11 @@ const PUBLISH_MENU_SECTIONS = [ icon: ( ), - handler: 'handleFundResearch', + intent: 'need_funding', }, ], }, -] as const; +]; interface MenuItemContentProps { icon: React.ReactNode; @@ -69,41 +83,39 @@ const MenuItemContent: React.FC = ({ icon, title, descript ); }; +/** + * The sidebar's Publish button: a Request for Proposal or a Proposal. For a + * user the workspace admits, either opens it straight onto a conversation + * for that side of the money; everyone else gets the notebook's opening + * modal and editor, as before. + */ export const PublishMenu: React.FC = ({ forceMinimize = false }) => { const router = useRouter(); const { executeAuthenticatedAction } = useAuthenticatedAction(); + const { inWorkspace, 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]) => { + const handleMenuItemClick = (item: PublishMenuItem) => { executeAuthenticatedAction(() => { - switch (item.handler) { - case 'handleFundResearch': - handleFundResearch(); - break; - case 'handleOpenGrant': - handleOpenGrant(); - break; + if (inWorkspace) { + startNew(item.intent); + } else if (item.intent === 'fund') { + setIsFundingOpportunityModalOpen(true); + } else { + setIsProposalModalOpen(true); } }); diff --git a/app/layouts/components/RightSidebarContainer.tsx b/app/layouts/components/RightSidebarContainer.tsx index 6e114b052..6019c674f 100644 --- a/app/layouts/components/RightSidebarContainer.tsx +++ b/app/layouts/components/RightSidebarContainer.tsx @@ -19,12 +19,15 @@ interface RightSidebarContainerProps { rightSidebar: boolean | ReactNode; contentClassName?: string; 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, aboveSidebar, + fill = false, }: RightSidebarContainerProps) { const { mobileSidebarOpen, setMobileSidebarOpen } = useWorkTab(); const isDefaultSidebar = typeof rightSidebar === 'boolean'; @@ -60,7 +63,8 @@ export function RightSidebarContainer({ // of filling the column — otherwise a short or empty sidebar leaves // a tall empty rail hanging beneath that card. It still shrinks and // scrolls when the content is taller than the space available. - cardAboveSidebar ? 'flex-initial' : 'flex-1 h-full' + // A page can ask for the full column anyway. + cardAboveSidebar && !fill ? 'flex-initial' : 'flex-1 h-full' )} >
diff --git a/app/layouts/topbar/TopBarUserControls.tsx b/app/layouts/topbar/TopBarUserControls.tsx index ff8461184..8150d9db8 100644 --- a/app/layouts/topbar/TopBarUserControls.tsx +++ b/app/layouts/topbar/TopBarUserControls.tsx @@ -6,8 +6,6 @@ import { Button } from '@/components/ui/Button'; import UserMenu from '@/components/menus/UserMenu'; import type { User } from '@/types/user'; import { TopBarSearchButton } from './TopBarSearchButton'; -import { TopBarWorkspaceButton } from './TopBarWorkspaceButton'; -import { isHubEditorOrModerator } from '@/utils/permissions'; import { formatBadgeCount, formatRSC } from '@/utils/number'; import { UserService } from '@/services/user.service'; import { cn } from '@/lib/utils'; @@ -103,9 +101,6 @@ export const TopBarUserControls = ({
- {/* The workspace's door here is gated like its nav item. */} - {user && isHubEditorOrModerator(user) && } - {isLoading ? ( <> diff --git a/app/layouts/topbar/TopBarWorkspaceButton.tsx b/app/layouts/topbar/TopBarWorkspaceButton.tsx deleted file mode 100644 index 0b77fa913..000000000 --- a/app/layouts/topbar/TopBarWorkspaceButton.tsx +++ /dev/null @@ -1,25 +0,0 @@ -'use client'; - -import { BriefcaseBusiness } from 'lucide-react'; -import { useOptionalAIMode } from '@/components/AIMode/AIModeContext'; -import { AI_MODE_NAME } from '@/components/AIMode/copy'; -import { useAuthenticatedAction } from '@/contexts/AuthModalContext'; - -/** The top bar's door into the workspace: always its new-conversation screen. */ -export function TopBarWorkspaceButton() { - const aiMode = useOptionalAIMode(); - const { executeAuthenticatedAction } = useAuthenticatedAction(); - if (!aiMode) return null; - - return ( - - ); -} diff --git a/app/my-funding/components/FundsReceived.tsx b/app/my-funding/components/FundsReceived.tsx index ce77ae11f..d4a98a965 100644 --- a/app/my-funding/components/FundsReceived.tsx +++ b/app/my-funding/components/FundsReceived.tsx @@ -1,14 +1,15 @@ 'use client'; -import type { ReactNode } from 'react'; -import Link from 'next/link'; -import { EarnEarningsSummary } from '@/components/Earn/EarnEarningsSummary'; +import { Plus } from 'lucide-react'; +import { Button } from '@/components/ui/Button'; import { FeedContent } from '@/components/Feed/FeedContent'; -import { buttonVariants } from '@/components/ui/Button'; +import { DashboardEmptyState } from '@/components/Funding/dashboard/DashboardEmptyState'; +import { DashboardSectionHeader } from '@/components/Funding/dashboard/DashboardSectionHeader'; +import { FundingRows } from '@/components/Funding/dashboard/FundingRows'; +import { useFundingDrafting } from '@/components/Funding/useFundingDrafting'; 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; @@ -18,24 +19,10 @@ interface FundsReceivedProps { /** 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 ( -
- {children} -
- ); -} - +/** The researcher's proposals, drafts first, then the published ones. */ function MyProposals({ userId }: Readonly<{ userId: number }>) { - const { - entries, - isLoading, - hasMore, - loadMore, - restoredScrollPosition, - page, - lastClickedEntryId, - } = useFeed('all', { + const { startNew } = useFundingDrafting(); + const { entries, isLoading, hasMore, loadMore } = useFeed('all', { endpoint: 'funding_feed', contentType: 'PREREGISTRATION', createdBy: userId, @@ -44,33 +31,29 @@ function MyProposals({ userId }: Readonly<{ userId: number }>) { return (
-

My proposals

- startNew('need_funding')} + > + + New proposal + + } + /> + + -

You have no published proposals.

- - Create a proposal - - - } + includeDrafts + emptyMessage="You have no proposals yet." />
); @@ -103,9 +86,7 @@ function PeerReviewFeed({ authorId }: Readonly<{ authorId: number }>) { hideActions isLoadingMore={isLoadingMore} noEntriesElement={ - -

You have no published peer reviews.

-
+ You have no published peer reviews. } maxLength={150} showReadMoreCTA @@ -127,11 +108,9 @@ function PeerReviews({ authorId }: Readonly<{ authorId?: number }>) {

Peer reviews

{!hasAuthorId ? (
- -

- Complete your researcher profile to publish peer reviews. -

-
+ + Complete your researcher profile to publish peer reviews. +
) : ( @@ -143,7 +122,6 @@ function PeerReviews({ authorId }: Readonly<{ authorId?: number }>) { export function FundsReceived({ userId, authorId }: Readonly) { return (
-
diff --git a/app/my-funding/components/MyFundingPage.tsx b/app/my-funding/components/MyFundingPage.tsx index 7b99baa3b..44d248a03 100644 --- a/app/my-funding/components/MyFundingPage.tsx +++ b/app/my-funding/components/MyFundingPage.tsx @@ -6,10 +6,17 @@ import type { FundingDirection } from '@/components/Funding/fundingDirection'; import { useRouter, useSearchParams } from 'next/navigation'; import { PageLayout } from '@/app/layouts/PageLayout'; import { FundsGiven } from '@/components/Funding/dashboard/FundsGiven'; +import { + parseViewedFunderId, + useFunderOverview, +} from '@/components/Funding/dashboard/hooks/useFunderOverview'; import { ModeratorViewAsFunder } from '@/components/Funding/dashboard/ModeratorViewAsFunder'; import { Tabs } from '@/components/ui/Tabs'; import { useUser } from '@/contexts/UserContext'; import { FundsReceived } from './FundsReceived'; +import { EarningsTotals } from '@/components/Funding/dashboard/EarningsTotals'; +import { FunderTotals } from '@/components/Funding/dashboard/FunderTotals'; +import { FundsGivenActivity, FundsReceivedActivity } from './MyFundingSidebar'; type MyFundingTab = 'given' | 'received'; @@ -56,6 +63,13 @@ export function MyFundingPage() { const hasModeratorOverrideOnReceivedTab = activeTab === 'received' && isModerator && searchParams.has('user_id'); + // A moderator may view another funder's page; everyone else sees their own. + const viewedUserId = + (isModerator ? parseViewedFunderId(searchParams.get('user_id')) : undefined) ?? user?.id; + const { overview, isLoading: isLoadingOverview } = useFunderOverview( + activeTab === 'given' ? viewedUserId : undefined + ); + useEffect(() => { if (isLoadingUser) return; @@ -71,10 +85,32 @@ export function MyFundingPage() { router.replace(`/my-funding?${params.toString()}`, { scroll: false }); }, [hasModeratorOverrideOnReceivedTab, isLoadingUser, router, searchParams, user]); - if (isLoadingUser || !user || hasModeratorOverrideOnReceivedTab) return null; + if (isLoadingUser || !user || hasModeratorOverrideOnReceivedTab || viewedUserId == null) { + return null; + } + + // The totals sit above the gray activity rail, where the funding power card + // sits on other pages; below `lg` the rail hides, so they lead the column. + const totals = + activeTab === 'given' ? ( + + ) : ( + + ); return ( - + + ) : ( + + ) + } + > : undefined} /> +
{totals}
+
{activeTab === 'given' ? ( - + ) : ( )} diff --git a/app/my-funding/components/MyFundingSidebar.tsx b/app/my-funding/components/MyFundingSidebar.tsx new file mode 100644 index 000000000..217d82d82 --- /dev/null +++ b/app/my-funding/components/MyFundingSidebar.tsx @@ -0,0 +1,41 @@ +'use client'; + +import { useMemo } from 'react'; +import { ActivitySidebar, ActivitySidebarSkeleton } from '@/components/Activity'; +import { useFunderActivity } from '@/components/Funding/dashboard/hooks/useFunderActivity'; +import { useActivityFeed } from '@/hooks/useActivityFeed'; +import type { ActivityCommentType } from '@/services/activity.service'; + +/** How many rows the rail shows; the RFP page's rail shows the same. */ +const SIDEBAR_ROWS = 15; + +/** + * The activity rail of Funds given: what the funder's applicants and + * reviewers have been up to — the same rail an RFP page has, fed by the + * funder's activity instead of the RFP's. + */ +export function FundsGivenActivity({ viewedUserId }: Readonly<{ viewedUserId: number }>) { + const { entries, isLoading } = useFunderActivity(viewedUserId); + const recent = useMemo(() => entries.slice(0, SIDEBAR_ROWS), [entries]); + if (isLoading && entries.length === 0) return ; + return ; +} + +/** Stable reference: a new array on every render would restart the activity feed. */ +const RECEIVED_ACTIVITY_TYPES: readonly ActivityCommentType[] = [ + 'AUTHOR_UPDATE', + 'REVIEW', + 'PEER_REVIEW', +]; + +/** The activity rail of Funds received: the researcher's own recent activity. */ +export function FundsReceivedActivity({ authorId }: Readonly<{ authorId?: number }>) { + const { entries, isLoading } = useActivityFeed({ + authorId, + commentTypes: RECEIVED_ACTIVITY_TYPES, + enabled: authorId != null, + }); + const recent = useMemo(() => entries.slice(0, SIDEBAR_ROWS), [entries]); + if (authorId != null && isLoading && entries.length === 0) return ; + return ; +} diff --git a/components/AIMode/AIModeContext.tsx b/components/AIMode/AIModeContext.tsx index 4aa9a81e0..a47aac2d9 100644 --- a/components/AIMode/AIModeContext.tsx +++ b/components/AIMode/AIModeContext.tsx @@ -13,6 +13,7 @@ import { } from 'react'; import dynamic from 'next/dynamic'; import { usePathname, useSearchParams } from 'next/navigation'; +import type { FundingIntent } from '@/components/Funding/fundingDirection'; /** * `?ai=1` opens the workspace. `aiChat=` selects a conversation; @@ -57,6 +58,15 @@ const CLOSED: AIModeUrlState = { isOpen: false, target: NEW_CONVERSATION }; export interface AIModeContextValue extends AIModeUrlState { /** Open on the last target, or the new-conversation screen. */ open: () => void; + /** + * Open on a fresh conversation for one side of the money. The door the + * user came through — a Publish menu item, a New RFP or New proposal + * button — says what the assistant will draft, so the start screen + * does not ask. + */ + openFor: (intent: FundingIntent) => void; + /** The intent handed over by {@link openFor}, once; null after. */ + takePendingIntent: () => FundingIntent | null; close: () => void; toggle: () => void; /** Open on a target. */ @@ -205,6 +215,22 @@ export function AIModeProvider({ children }: { readonly children: ReactNode }) { [selectTarget] ); + // Held until the workspace's chat hook mounts and asks for it, so the door + // that opens a conversation needs no chat state of its own. + const pendingIntentRef = useRef(null); + const openFor = useCallback( + (intent: FundingIntent) => { + pendingIntentRef.current = intent; + selectTarget(NEW_CONVERSATION); + }, + [selectTarget] + ); + const takePendingIntent = useCallback(() => { + const intent = pendingIntentRef.current; + pendingIntentRef.current = null; + return intent; + }, []); + const stateRef = useRef(state); stateRef.current = state; const toggle = useCallback(() => { @@ -213,8 +239,28 @@ export function AIModeProvider({ children }: { readonly children: ReactNode }) { }, [open, close]); const value = useMemo( - () => ({ ...state, open, close, toggle, selectTarget, selectChat, selectDocument }), - [state, open, close, toggle, selectTarget, selectChat, selectDocument] + () => ({ + ...state, + open, + close, + toggle, + selectTarget, + selectChat, + selectDocument, + openFor, + takePendingIntent, + }), + [ + state, + open, + close, + toggle, + selectTarget, + selectChat, + selectDocument, + openFor, + takePendingIntent, + ] ); return ( diff --git a/components/AIMode/AIModeOverlay.tsx b/components/AIMode/AIModeOverlay.tsx index fd26343a7..bf43974ca 100644 --- a/components/AIMode/AIModeOverlay.tsx +++ b/components/AIMode/AIModeOverlay.tsx @@ -17,7 +17,7 @@ import { useModalOverlayBehavior } from './shell/useModalOverlayBehavior'; import { WorkspacePanes } from './shell/WorkspacePanes'; import { WorkspaceSidebar } from './sidebar/WorkspaceSidebar'; import { useAIModeChat } from './useAIModeChat'; -import { AI_MODE_NAME } from './copy'; +import { AI_MODE_NAME, newConversationTitle } from './copy'; const LIST_MIN_WIDTH = 200; const LIST_MAX_WIDTH = 440; @@ -110,10 +110,15 @@ export function AIModeOverlay() { const showDocument = noteId != null && documentOpen; const documentTitle = doc.content?.title?.trim() || state.note?.title?.trim() || 'Document'; // The top strip names what is open: the conversation when the chat is the - // main pane, the document when it is; nothing on the new-conversation screen. + // main pane, the document when it is, and the new-conversation screen by + // what it is for — an RFP or a proposal. const { title: conversationTitle } = conversationTitleFor(state); const headerTitle = - layout === 'document' ? documentTitle : target.chatId != null ? conversationTitle : null; + layout === 'document' + ? documentTitle + : target.chatId != null + ? conversationTitle + : newConversationTitle(state.intent); // The document column puts its publish controls up in the header, where // they read as the workspace's, not the pane's. It renders into this slot. diff --git a/components/AIMode/chat/ChatPane.tsx b/components/AIMode/chat/ChatPane.tsx index 0acec2871..fd2cbcbc9 100644 --- a/components/AIMode/chat/ChatPane.tsx +++ b/components/AIMode/chat/ChatPane.tsx @@ -13,11 +13,18 @@ import { conversationTitleFor } from './conversationTitle'; import { Button } from '@/components/ui/Button'; import { ChatTranscriptSkeleton } from '@/components/skeletons/AIModeSkeleton'; import { cn } from '@/utils/styles'; +import { layoutFor } from '../AIModeContext'; import type { AIModeChatState } from '../useAIModeChat'; -import { aiModeGreeting, INTENT_COPY } from '../copy'; +import { AI_MODE_GREETING } from '../copy'; +import { StartContextChips } from '../start/StartContextChips'; +import { + START_COMPOSER_MIN_ROWS, + startComposerBoxClass, + startComposerPlaceholder, + startComposerSendClass, +} from '../start/startComposer'; import { StartScreen } from '../start/StartScreen'; import { DocumentChatEmptyState } from './DocumentChatEmptyState'; -import { useUser } from '@/contexts/UserContext'; interface ChatPaneProps { readonly state: AIModeChatState; @@ -44,25 +51,30 @@ export function ChatPane({ const { chatId, list, chat, modelSelection, draft, setDraft, notice, composerBusy, canStop } = state; const composerRef = useRef(null); + const onDocument = state.target.kind === 'document'; + // The new-conversation screen: white, untitled, the composer in the middle. + const onStart = chatId == null && !onDocument; // ---- 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. + // The start screen has no transcript to follow: on a phone it is taller + // than the viewport, and following would scroll the greeting away. const { scrollRef, handleScroll, isAtBottom, jumpToLatest, follow } = useJumpToLatest({ resetKey: chatId }); useEffect(() => { - follow(); - }, [chat.chat, chat.pendingSend, follow]); + if (!onStart) follow(); + }, [chat.chat, chat.pendingSend, follow, onStart]); // 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; + if (onStart || !el || typeof ResizeObserver === 'undefined') return; const observer = new ResizeObserver(() => follow()); observer.observe(el); return () => observer.disconnect(); - }, [follow, chatId]); + }, [follow, chatId, onStart]); // ---- title: inline rename from the header menu ---- const [renaming, setRenaming] = useState(false); @@ -70,7 +82,11 @@ export function ChatPane({ setRenaming(false); }, [chatId]); - const { user } = useUser(); + // The new-conversation screen is there to be typed into: the caret is in + // the box the moment it opens, whichever door it opened from. + useEffect(() => { + if (onStart) composerRef.current?.focus(); + }, [onStart, chatId]); const listBlocked = list.access === 'hidden'; const chatUnavailable = @@ -79,42 +95,58 @@ export function ChatPane({ listBlocked || chatUnavailable || (chatId != null && chat.access === 'loading'); const { currentTitle, title, loading: titleLoading } = conversationTitleFor(state); - const onDocument = state.target.kind === 'document'; + // With the chat as the main pane the workspace's top strip already names + // it, so the pane's own header carries only the controls. + const chatIsMain = layoutFor(state.target) === 'chat'; + + const modelControls = ( + + ); const composer = ( void state.send()} + onSend={state.send} onStop={state.stop} busy={composerBusy} canStop={canStop} disabled={composerDisabled} sendDisabled={state.sendBlocked} notice={notice} - className="border-t-0 bg-gray-50" - placeholder={ - chatId == null && !onDocument ? INTENT_COPY[state.intent].placeholder : undefined - } + className={cn('border-t-0', onStart ? 'bg-white pt-0' : 'bg-gray-50')} + boxClassName={onStart ? startComposerBoxClass(state.intent) : undefined} + minRows={onStart ? START_COMPOSER_MIN_ROWS : 1} + sendClassName={onStart ? startComposerSendClass(state.intent) : undefined} + placeholder={onStart ? startComposerPlaceholder(state.intent) : undefined} toolbar={ - + // A researcher's context rides with the first message, like attachments. + onStart && state.intent === 'need_funding' ? ( + + ) : undefined } + // The model and effort sit under the box, at its right, out of the message's way. + footer={
{modelControls}
} /> ); return ( -
+
{/* No border or fill: the title and its controls float over the pane. */}
{onOpenConversations && ( @@ -138,6 +170,8 @@ export function ChatPane({ if (next && next !== (currentTitle ?? '')) state.rename(chatId, next); }} /> + ) : chatIsMain ? ( + ) : titleLoading ? (
@@ -158,20 +192,20 @@ export function ChatPane({
-
+
{listBlocked ? ( ) : chatId == null && onDocument ? ( ) : chatId == null ? ( - + ) : chat.access === 'loading' && chat.chat == null ? ( ) : chat.access === 'not_found' ? ( @@ -219,7 +253,7 @@ export function ChatPane({ {/* A conversation keeps the composer docked at the bottom, as does a document's chat before it starts; the new-conversation screen seats - it in the middle with the intent toggle. */} + it in the middle. */} {(chatId != null || onDocument) && (
diff --git a/components/AIMode/chat/DocumentChatEmptyState.tsx b/components/AIMode/chat/DocumentChatEmptyState.tsx index b91f2c091..8d42eb01d 100644 --- a/components/AIMode/chat/DocumentChatEmptyState.tsx +++ b/components/AIMode/chat/DocumentChatEmptyState.tsx @@ -11,9 +11,6 @@ export function DocumentChatEmptyState() {

The assistant can read this document and propose edits you accept or reject.

-

- Send a message and this chat is kept under Conversations. -

); } diff --git a/components/AIMode/chat/conversationTitle.ts b/components/AIMode/chat/conversationTitle.ts index e6b130f91..23f555a9d 100644 --- a/components/AIMode/chat/conversationTitle.ts +++ b/components/AIMode/chat/conversationTitle.ts @@ -1,3 +1,4 @@ +import { newConversationTitle } from '../copy'; import type { AIModeChatState } from '../useAIModeChat'; export interface ConversationTitle { @@ -26,7 +27,7 @@ export function conversationTitleFor(state: AIModeChatState): ConversationTitle chatId == null ? target.kind === 'document' ? 'New chat' - : 'New conversation' + : newConversationTitle(state.intent) : (currentTitle?.trim() ?? '') || 'Untitled conversation'; return { currentTitle, title, loading }; } diff --git a/components/AIMode/copy.ts b/components/AIMode/copy.ts index 793a3baf1..b6c3c9463 100644 --- a/components/AIMode/copy.ts +++ b/components/AIMode/copy.ts @@ -6,18 +6,24 @@ import type { FundingIntent } from '@/components/Funding/fundingDirection'; */ export const AI_MODE_NAME = 'Workspace'; -/** 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-conversation screen is called, in the top strip and on the + * sidebar's button: named for what it will produce, not for the chat. + */ +export const newConversationTitle = (intent: FundingIntent): string => + intent === 'fund' ? 'New RFP' : 'New proposal'; + +/** The heading of the new-conversation screen. */ +export const AI_MODE_GREETING = 'Let’s get started'; -/** What the intent toggle calls each side, and what the composer asks for. */ -export const INTENT_COPY: Record = { +/** The line under the greeting and what the composer asks for, per side of the money. */ +export const INTENT_COPY: Record = { fund: { - label: 'I want to fund', + tagline: 'The most efficient way to fund science.', placeholder: 'Describe the research you want to fund…', }, need_funding: { - label: 'I need funding', + tagline: 'Turn your proposal into funded science.', placeholder: 'Describe the research you need funding for…', }, }; diff --git a/components/AIMode/document/DocumentPane.tsx b/components/AIMode/document/DocumentPane.tsx index f218122c4..aba3672d8 100644 --- a/components/AIMode/document/DocumentPane.tsx +++ b/components/AIMode/document/DocumentPane.tsx @@ -13,6 +13,7 @@ import { import { ButtonGroup } from '@/components/ui/ButtonGroup'; import { PublishingHostProvider, type PublishingHost } from '@/contexts/PublishingHostContext'; import { useNoteDetailsSaver } from '@/hooks/useNoteDetailsSaver'; +import { useAIMode } from '../AIModeContext'; import { NoteReviewControls } from '@/components/Notebook/NoteReview/NoteReviewControls'; import { noteDiffPersistableDoc } from '@/components/Notebook/NoteReview/noteDiffOverlay'; import { useNoteAgentReview } from '@/components/Notebook/NoteReview/useNoteAgentReview'; @@ -125,6 +126,9 @@ export function DocumentPane({ // 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); + // Publishing leaves for the work's page; the workspace gets out of the way + // first, so the page is what the user sees arrive. + const { close: closeWorkspace } = useAIMode(); const publishingHost = useMemo( () => ({ note: content, @@ -132,8 +136,9 @@ export function DocumentPane({ isLoading: loading, saveDetailsSoon, saveDetailsNow, + onPublished: closeWorkspace, }), - [content, editor, loading, saveDetailsSoon, saveDetailsNow] + [content, editor, loading, saveDetailsSoon, saveDetailsNow, closeWorkspace] ); const persistEditorState = useCallback(async () => { @@ -247,14 +252,16 @@ export function DocumentPane({ )}
-
- -
+ {/* Mounted only while showing: a block's popover registers the same + field, and two inputs on one name leave the form reading and + writing through whichever attached last — keystrokes in the + popover would land in, or be overwritten from, the hidden copy. + The values live in the provider, so nothing is lost by unmounting. */} + {view === 'details' && ( +
+ +
+ )} {review.review && view === 'document' && (
@@ -349,13 +356,7 @@ function DraftSection({ >
- - Drafting - - Preview updates live - + Drafting
diff --git a/components/AIMode/document/details/AllDetailsBlock.tsx b/components/AIMode/document/details/AllDetailsBlock.tsx index d6bc021a3..6a0d69615 100644 --- a/components/AIMode/document/details/AllDetailsBlock.tsx +++ b/components/AIMode/document/details/AllDetailsBlock.tsx @@ -34,10 +34,12 @@ export function AllDetailsBlock({ type="button" onClick={onClick} aria-pressed={active} - style={{ width: ALL_DETAILS_BLOCK_WIDTH }} + style={{ minWidth: ALL_DETAILS_BLOCK_WIDTH }} className={cn( - 'flex h-full shrink-0 flex-col justify-center px-2.5 text-left transition-colors', - active ? 'bg-primary-50 ring-[3px] ring-primary-100' : 'bg-gray-50 hover:bg-gray-100' + 'flex h-full flex-1 flex-col justify-center px-2.5 text-left transition-colors', + active + ? 'relative z-10 bg-primary-50 outline outline-2 -outline-offset-2 outline-primary-400' + : 'bg-gray-50 hover:bg-gray-100' )} > diff --git a/components/AIMode/document/details/DetailBlock.tsx b/components/AIMode/document/details/DetailBlock.tsx index 2ccc99414..456a411e2 100644 --- a/components/AIMode/document/details/DetailBlock.tsx +++ b/components/AIMode/document/details/DetailBlock.tsx @@ -67,8 +67,10 @@ export function DetailBlock({ config, noteId, value, open, onOpenChange }: Detai const blockClass = (filled: boolean, open: boolean) => cn( 'flex min-w-0 flex-1 flex-col justify-center border-r h-full bg-white px-2.5 text-left transition-colors', + // The outline sits inside the block, so it is the same on every side + // whatever the neighbours' borders do. open - ? 'border-primary-400 bg-primary-50 ring-[3px] ring-primary-100' + ? 'relative z-10 border-primary-400 bg-primary-50 outline outline-2 -outline-offset-2 outline-primary-400' : filled ? 'border-gray-200 hover:bg-gray-50' : 'border-gray-300 hover:bg-gray-50' diff --git a/components/AIMode/document/details/DetailBlockStrip.tsx b/components/AIMode/document/details/DetailBlockStrip.tsx index 12a51bca4..ec0fcb3fb 100644 --- a/components/AIMode/document/details/DetailBlockStrip.tsx +++ b/components/AIMode/document/details/DetailBlockStrip.tsx @@ -57,7 +57,7 @@ export function DetailBlockStrip({ detailsOpen, onToggleDetails }: DetailBlockSt
{note != null && blocks diff --git a/components/AIMode/shell/AIModeHeader.tsx b/components/AIMode/shell/AIModeHeader.tsx index 8e2954ac1..cb7c03f68 100644 --- a/components/AIMode/shell/AIModeHeader.tsx +++ b/components/AIMode/shell/AIModeHeader.tsx @@ -1,16 +1,14 @@ 'use client'; -import { BriefcaseBusiness } from 'lucide-react'; import { TopBarBackButton } from '@/app/layouts/topbar/TopBarBackButton'; import { AI_MODE_NAME } from '../copy'; interface AIModeHeaderProps { /** - * What is open, beside the name: the conversation's title when the chat is - * the main pane, the document's when it is; null on the new-conversation - * screen. + * What is open: the conversation's title when the chat is the main pane, + * the document's when it is, "New RFP" or "New proposal" on the start screen. */ - readonly title: string | null; + readonly title: string; /** * Receives the element the document pane renders its publish controls * into. Absent below the tablet breakpoint, where the drawer keeps them. @@ -20,7 +18,7 @@ interface AIModeHeaderProps { readonly onBack: () => void; } -/** The workspace's top strip: the way back, the name, what is open, and the open document's publishing state. */ +/** The workspace's top strip: the way back, what is open, and the open document's publishing state. */ export function AIModeHeader({ title, publishControlsRef, onBack }: AIModeHeaderProps) { const backLabel = `Leave ${AI_MODE_NAME}`; return ( @@ -28,16 +26,9 @@ export function AIModeHeader({ title, publishControlsRef, onBack }: AIModeHeader
-
{publishControlsRef && (
diff --git a/components/AIMode/shell/useModalOverlayBehavior.ts b/components/AIMode/shell/useModalOverlayBehavior.ts index ce9912628..913fcdc51 100644 --- a/components/AIMode/shell/useModalOverlayBehavior.ts +++ b/components/AIMode/shell/useModalOverlayBehavior.ts @@ -51,7 +51,9 @@ export function useModalOverlayBehavior({ rootEl, onEscape }: ModalOverlayBehavi useEffect(() => { if (!rootEl) return; - rootEl.focus(); + // Something inside may already have taken focus — the start screen's + // composer — and keeps it; the root is only the fallback. + if (!rootEl.contains(document.activeElement)) rootEl.focus(); const inerted: Element[] = []; for (const child of Array.from(document.body.children)) { if (child === rootEl || child.tagName === 'SCRIPT' || child.tagName === 'NEXTJS-PORTAL') { diff --git a/components/AIMode/sidebar/DocumentsSection.tsx b/components/AIMode/sidebar/DraftsSection.tsx similarity index 67% rename from components/AIMode/sidebar/DocumentsSection.tsx rename to components/AIMode/sidebar/DraftsSection.tsx index d9af6946d..957ea0cfb 100644 --- a/components/AIMode/sidebar/DocumentsSection.tsx +++ b/components/AIMode/sidebar/DraftsSection.tsx @@ -1,48 +1,43 @@ 'use client'; import { useEffect, useMemo } from 'react'; -import { File } from 'lucide-react'; -import { FundingDirectionIcon } from '@/components/Funding/FundingDirectionIcon'; +import { FileText } from 'lucide-react'; +import { NoteStatusDot } from '@/components/Notebook/NoteStatus'; import { Button } from '@/components/ui/Button'; import { ConversationListSkeleton } from '@/components/skeletons/AIModeSkeleton'; import { useOrganizationContext } from '@/contexts/OrganizationContext'; import { useUser } from '@/contexts/UserContext'; import { useOrganizationNotes } from '@/hooks/useOrganizationNotes'; -import { getNoteKind, isChangelogNote, type Note } from '@/types/note'; +import { isChangelogNote, isPublishedNote, type Note } from '@/types/note'; import { SidebarGroupHeading } from './SidebarGroupHeading'; import { SidebarRow } from './SidebarRow'; -interface DocumentsSectionProps { +interface DraftsSectionProps { readonly activeNoteId: number | null; /** A note the open conversation just created; the list fetches again to show it. */ readonly recentNoteId: number | null; readonly onSelect: (noteId: number) => void; } -/** The kind's mark: the money's direction for a proposal or an RFP, a plain file otherwise. */ -function NoteKindIcon({ note }: { readonly note: Note }) { - const kind = getNoteKind(note); - if (kind === 'rfp') - return ; - if (kind === 'proposal') { - return ; - } +/** A plain document, with a dot at its corner for whether it has gone out: blue published, amber draft. */ +function DraftIcon({ note }: { readonly note: Note }) { return ( -