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
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 (
-
- {children}
-
- );
-}
-
-function MyProposals({ userId }: Readonly<{ userId: number }>) {
- const {
- entries,
- isLoading,
- hasMore,
- loadMore,
- restoredScrollPosition,
- page,
- lastClickedEntryId,
- } = useFeed('all', {
- endpoint: 'funding_feed',
- contentType: 'PREREGISTRATION',
- createdBy: userId,
- ordering: 'newest',
- });
-
- return (
-
- My proposals
-
- You have no published proposals.
-
- Create a proposal
-
-
- }
- />
-
- );
-}
-
-function PeerReviewFeed({ authorId }: Readonly<{ authorId: number }>) {
- const {
- entries,
- isLoading,
- hasMore,
- loadMore,
- isLoadingMore,
- page,
- restorationTab,
- restoredScrollPosition,
- lastClickedEntryId,
- } = useActivityFeed({
- authorId,
- contentType: 'RHCOMMENTMODEL',
- commentTypes: PEER_REVIEW_COMMENT_TYPES,
- });
-
- return (
-
- You have no published peer reviews.
-
- }
- maxLength={150}
- showReadMoreCTA
- activeTab={restorationTab}
- restoredScrollPosition={restoredScrollPosition}
- page={page}
- lastClickedEntryId={lastClickedEntryId ?? undefined}
- shouldRenderBountyAsComment
- wideContent
- />
- );
-}
-
-function PeerReviews({ authorId }: Readonly<{ authorId?: number }>) {
- const hasAuthorId = authorId !== undefined && authorId > 0;
-
- return (
-
- Peer reviews
- {!hasAuthorId ? (
-
-
-
- Complete your researcher profile to publish peer reviews.
-
-
-
- ) : (
-
- )}
-
- );
-}
-
-export function FundsReceived({ userId, authorId }: Readonly) {
- return (
-
- );
-}
diff --git a/app/my-funding/components/MyFundingActivityContent.tsx b/app/my-funding/components/MyFundingActivityContent.tsx
new file mode 100644
index 000000000..242cf926d
--- /dev/null
+++ b/app/my-funding/components/MyFundingActivityContent.tsx
@@ -0,0 +1,35 @@
+'use client';
+
+import { ActivityCard, ActivityFeedList } from '@/components/Activity';
+import type { MyFundingActivity } from '@/components/Funding/dashboard/hooks/useMyFundingActivity';
+import { MyFundingDataError } from './MyFundingDataError';
+import { MyFundingActivityEmptyState } from './MyFundingActivityEmptyState';
+
+interface MyFundingActivityContentProps {
+ readonly activity: MyFundingActivity;
+}
+
+/** The full activity view shown by the Activity tab. */
+export function MyFundingActivityContent({ activity }: MyFundingActivityContentProps) {
+ return (
+
+ {activity.error && (
+
+
+
+ )}
+
}
+ >
+ {activity.entries.map((entry) => (
+
+ ))}
+
+
+ );
+}
diff --git a/app/my-funding/components/MyFundingActivityEmptyState.tsx b/app/my-funding/components/MyFundingActivityEmptyState.tsx
new file mode 100644
index 000000000..d2d07f24c
--- /dev/null
+++ b/app/my-funding/components/MyFundingActivityEmptyState.tsx
@@ -0,0 +1,16 @@
+import { Activity } from 'lucide-react';
+
+/** Empty state for the full My Funding activity tab. */
+export function MyFundingActivityEmptyState() {
+ return (
+
+
+
No activity yet
+
+ Updates from your RFPs, proposals, contributions, and peer reviews will appear here.
+
+
+ );
+}
diff --git a/app/my-funding/components/MyFundingContent.tsx b/app/my-funding/components/MyFundingContent.tsx
new file mode 100644
index 000000000..50b3aa3b6
--- /dev/null
+++ b/app/my-funding/components/MyFundingContent.tsx
@@ -0,0 +1,91 @@
+'use client';
+
+import { useEffect, useState } from 'react';
+import { DraftCarousel } from '@/components/Funding/dashboard/DraftCarousel';
+import { PeerReviewRows } from '@/components/Funding/dashboard/PeerReviewRows';
+import { PublishedRows } from '@/components/Funding/dashboard/PublishedRows';
+import { useMyFundingDocuments } from '@/components/Funding/dashboard/hooks/useMyFundingDocuments';
+import { useActivityFeed } from '@/hooks/useActivityFeed';
+import type { ActivityCommentType } from '@/services/activity.service';
+import { MyFundingDataError } from './MyFundingDataError';
+
+/** Stable reference: a new array on every render would restart the activity feed. */
+const PEER_REVIEW_COMMENT_TYPES: readonly ActivityCommentType[] = ['REVIEW', 'PEER_REVIEW'];
+
+interface MyFundingContentProps {
+ /** Whose page this is: the user, or the funder a moderator is viewing. */
+ readonly viewedUserId: number;
+ /** The user is looking at their own page, so everything they wrote belongs on it. */
+ readonly isOwnPage: boolean;
+ /** The user's author profile, whose activity and peer reviews the page reads. */
+ readonly authorId?: number;
+}
+
+/**
+ * The column of My Funding, under the stats: what the user has written,
+ * whichever side of funding they are on. Drafts waiting to be picked back up
+ * lead, as a row of cards; then what they published, RFPs and proposals
+ * together; then their peer reviews. The activity rail lives beside this
+ * column, as it does on an RFP page.
+ */
+export function MyFundingContent({ viewedUserId, isOwnPage, authorId }: MyFundingContentProps) {
+ const { published, drafts, isSettled, error, hasMore, loadMore, refreshDrafts } =
+ useMyFundingDocuments({
+ viewedUserId,
+ isOwnPage,
+ });
+
+ const readsReviews = isOwnPage && authorId != null && authorId > 0;
+ const reviews = useActivityFeed({
+ authorId: readsReviews ? authorId : undefined,
+ contentType: 'RHCOMMENTMODEL',
+ commentTypes: PEER_REVIEW_COMMENT_TYPES,
+ enabled: readsReviews,
+ });
+ const reviewEntries = readsReviews ? reviews.entries : [];
+
+ // Once the feed pages, its count becomes the number of rows loaded, so the
+ // total it first reported is the one kept.
+ const [reviewTotal, setReviewTotal] = useState(0);
+ useEffect(() => {
+ setReviewTotal((total) => Math.max(total, reviews.count));
+ }, [reviews.count]);
+
+ if (!isSettled) return ;
+
+ return (
+
+ {error &&
}
+ {drafts.length > 0 &&
}
+
+ {reviewEntries.length > 0 && (
+
+ )}
+
+ );
+}
+
+function ContentSkeleton() {
+ return (
+
+ {[0, 1, 2].map((index) => (
+
+ ))}
+
+ );
+}
diff --git a/app/my-funding/components/MyFundingDataError.tsx b/app/my-funding/components/MyFundingDataError.tsx
new file mode 100644
index 000000000..a8a009cf9
--- /dev/null
+++ b/app/my-funding/components/MyFundingDataError.tsx
@@ -0,0 +1,17 @@
+interface MyFundingDataErrorProps {
+ readonly message?: string;
+}
+
+/** Non-blocking error notice for a dashboard data source. */
+export function MyFundingDataError({
+ message = 'Data failed to load. Please refresh and try again.',
+}: MyFundingDataErrorProps) {
+ return (
+
+ {message}
+
+ );
+}
diff --git a/app/my-funding/components/MyFundingPage.tsx b/app/my-funding/components/MyFundingPage.tsx
index 7b99baa3b..e42e2fa6b 100644
--- a/app/my-funding/components/MyFundingPage.tsx
+++ b/app/my-funding/components/MyFundingPage.tsx
@@ -1,94 +1,202 @@
'use client';
-import { useEffect, type ReactNode } from 'react';
-import { FundingDirectionIcon } from '@/components/Funding/FundingDirectionIcon';
-import type { FundingDirection } from '@/components/Funding/fundingDirection';
+import { useEffect } from 'react';
+import { LayoutDashboard } from 'lucide-react';
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { useRouter, useSearchParams } from 'next/navigation';
import { PageLayout } from '@/app/layouts/PageLayout';
-import { FundsGiven } from '@/components/Funding/dashboard/FundsGiven';
+import { useEarningOverview } from '@/components/Earn/lib/hooks/useEarningOverview';
+import { FUNDING_KIND_ICON } from '@/components/Funding/fundingKind';
+import {
+ hasMyFundingGivingStats,
+ MyFundingSummary,
+} from '@/components/Funding/dashboard/MyFundingSummary/MyFundingSummary';
+import {
+ parseViewedFunderId,
+ useFunderOverview,
+} from '@/components/Funding/dashboard/hooks/useFunderOverview';
import { ModeratorViewAsFunder } from '@/components/Funding/dashboard/ModeratorViewAsFunder';
+import { useMyFundingActivity } from '@/components/Funding/dashboard/hooks/useMyFundingActivity';
+import { useFundingDrafting } from '@/components/Funding/useFundingDrafting';
+import { Button } from '@/components/ui/Button';
+import { HeroHeader } from '@/components/ui/HeroHeader';
+import {
+ RadiatingDotTabIcon,
+ RadiatingDotTabIconActive,
+} from '@/components/ui/RadiatingDotTabIcon';
import { Tabs } from '@/components/ui/Tabs';
import { useUser } from '@/contexts/UserContext';
-import { FundsReceived } from './FundsReceived';
+import type { EarningOverview } from '@/types/user';
+import { MyFundingActivityContent } from './MyFundingActivityContent';
+import { MyFundingContent } from './MyFundingContent';
+import { MyFundingDataError } from './MyFundingDataError';
+import { MyFundingSidebar } from './MyFundingSidebar';
-type MyFundingTab = 'given' | 'received';
+/** What the Funds received half shows for someone who has earned nothing yet. */
+const NO_EARNINGS: EarningOverview = {
+ totalEarned: { rsc: 0, rscUsdSnapshot: 0, usd: 0 },
+ bySource: {},
+};
/**
- * Tab label with the direction of the money in a circle: out of the wallet for
- * funds given, into it for funds received. The arrow inherits the tab's own
- * color so it picks up the active/inactive treatment for free.
+ * One dashboard for both sides of funding. The page does not ask whether its
+ * user gives or receives: it shows their numbers in a band, and under it
+ * what they have written. Beside the column is the same compact activity
+ * rail used by RFP pages.
*/
-function TabLabel({ direction, children }: { direction: FundingDirection; children: ReactNode }) {
- return (
-
-
- {children}
-
- );
-}
-
-const MY_FUNDING_TABS = [
- {
- id: 'given',
- label: Funds given,
- href: '/my-funding?tab=given',
- },
- {
- id: 'received',
- label: Funds received,
- href: '/my-funding?tab=received',
- // Money coming in reads green, matching the emerald treatment the Fund
- // tabs already use for the inbound side.
- activeClassName: 'text-emerald-600 border-b-emerald-600',
- },
-];
-
-function resolveMyFundingTab(tab: string | null): MyFundingTab {
- return tab === 'received' ? 'received' : 'given';
-}
-
export function MyFundingPage() {
const router = useRouter();
const searchParams = useSearchParams();
const { user, isLoading: isLoadingUser } = useUser();
- const activeTab = resolveMyFundingTab(searchParams.get('tab'));
+ const { startNew } = useFundingDrafting();
const isModerator = !!user?.isModerator;
- const hasModeratorOverrideOnReceivedTab =
- activeTab === 'received' && isModerator && searchParams.has('user_id');
- useEffect(() => {
- if (isLoadingUser) return;
+ // 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 isOwnPage = viewedUserId != null && viewedUserId === user?.id;
+ const activeTab = searchParams.get('tab') === 'activity' ? 'activity' : 'overview';
+ const activity = useMyFundingActivity({
+ viewedUserId,
+ authorId: isOwnPage ? user?.authorProfile?.id : undefined,
+ });
- if (!user) {
- router.replace('/');
- return;
+ const handleTabChange = (tabId: string) => {
+ const params = new URLSearchParams(searchParams.toString());
+ if (tabId === 'activity') {
+ params.set('tab', 'activity');
+ } else {
+ params.delete('tab');
}
+ const query = params.toString();
+ router.push(`/my-funding${query ? `?${query}` : ''}`, { scroll: false });
+ };
- if (!hasModeratorOverrideOnReceivedTab) return;
+ const {
+ overview,
+ isLoading: isLoadingOverview,
+ error: overviewError,
+ } = useFunderOverview(viewedUserId);
+ // Earnings are the user's own: a moderator's view of another funder reads
+ // that funder's giving and nothing of their receiving.
+ const {
+ overview: earnings,
+ isLoading: isLoadingEarnings,
+ error: earningsError,
+ } = useEarningOverview(isOwnPage ? user?.id : undefined);
- const params = new URLSearchParams(searchParams.toString());
- params.delete('user_id');
- router.replace(`/my-funding?${params.toString()}`, { scroll: false });
- }, [hasModeratorOverrideOnReceivedTab, isLoadingUser, router, searchParams, user]);
+ useEffect(() => {
+ if (!isLoadingUser && !user) router.replace('/');
+ }, [isLoadingUser, router, user]);
+
+ if (isLoadingUser || !user || viewedUserId == null) {
+ return null;
+ }
- if (isLoadingUser || !user || hasModeratorOverrideOnReceivedTab) return null;
+ // Funds given is there once something has been given. Funds received is
+ // always there on the user's own page, at zero until they earn something; a
+ // moderator's view of another funder has no earnings to show.
+ const giving = !overviewError && hasMyFundingGivingStats(overview) ? overview : null;
+ const earned = isOwnPage && !earningsError ? (earnings ?? NO_EARNINGS) : null;
+ const isLoadingStats = isLoadingOverview || (isOwnPage && isLoadingEarnings);
+ const showStats = isLoadingStats || giving != null || earned != null;
+ const hasStatsError = overviewError != null || (isOwnPage && earningsError != null);
return (
-
- {}}
- rightContent={isModerator && activeTab === 'given' ? : undefined}
- />
+ }
+ rightSidebarTopOffset="aligned"
+ topBanner={
+
+ {isOwnPage
+ ? 'Manage your RFPs, proposals, funding activity, and earnings.'
+ : "Review this funder's published RFPs and funding activity."}
+
+ }
+ cta={
+ isOwnPage && (
+
+
+
+
+ )
+ }
+ contentWidth="narrow"
+ >
+
+ : undefined}
+ />
+
+
+ }
+ >
+ {activeTab === 'activity' ? (
+
+ ) : (
+ <>
+ {hasStatsError && (
+
+
+
+ )}
+ {showStats && (
+
+ )}
-
- {activeTab === 'given' ? (
-
- ) : (
-
- )}
-
+
+ {/* Keyed by the funder, so a moderator switching funders starts from a clean load. */}
+
+
+ >
+ )}
);
}
diff --git a/app/my-funding/components/MyFundingSidebar.tsx b/app/my-funding/components/MyFundingSidebar.tsx
new file mode 100644
index 000000000..13c474b6a
--- /dev/null
+++ b/app/my-funding/components/MyFundingSidebar.tsx
@@ -0,0 +1,17 @@
+'use client';
+
+import { ActivitySidebar, ActivitySidebarSkeleton } from '@/components/Activity';
+import type { MyFundingActivity } from '@/components/Funding/dashboard/hooks/useMyFundingActivity';
+
+interface MyFundingSidebarProps {
+ readonly activity: MyFundingActivity;
+}
+
+/** The funding activity rail, using the same compact presentation as RFP pages. */
+export function MyFundingSidebar({ activity }: MyFundingSidebarProps) {
+ if (activity.isLoading) {
+ return ;
+ }
+
+ return ;
+}
diff --git a/app/my-funding/page.tsx b/app/my-funding/page.tsx
index b0a8a30a2..11a0454cc 100644
--- a/app/my-funding/page.tsx
+++ b/app/my-funding/page.tsx
@@ -4,7 +4,7 @@ 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/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 d18c4062d..ebd06e062 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;
@@ -45,25 +52,30 @@ export function ChatPane({
state;
const composerRef = useRef(null);
const paneRef = 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);
@@ -71,7 +83,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 =
@@ -80,14 +96,31 @@ 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}
@@ -96,28 +129,27 @@ export function ChatPane({
notice={notice}
attachments={state.attachments}
dropTargetRef={paneRef}
- 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. */}