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. */}