Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 14 additions & 2 deletions app/proposal/[id]/[slug]/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -115,12 +115,24 @@ export default async function ProposalSlugLayout({ params, children }: Props) {
pageTabs={
<ProposalTabs work={work} metadata={metadata} updatesCount={authorPosts.length} />
}
rightSidebar={<ProposalSidebar work={work} metadata={metadata} />}
rightSidebar={
<ProposalSidebar
work={work}
metadata={metadata}
updatesCount={authorPosts.length}
// Newest first, so the first is the latest.
latestUpdateDate={authorPosts[0]?.createdDate}
/>
}
>
<ProposalByline work={work} metadata={metadata} />
<Suspense>{children}</Suspense>
{metadata.fundraising && (
<ProposalActionBar fundraise={metadata.fundraising} linkedGrant={work.linkedGrant} />
<ProposalActionBar
work={work}
fundraise={metadata.fundraising}
linkedGrant={work.linkedGrant}
/>
)}
</PageLayout>
</FundPanelProvider>
Expand Down
7 changes: 6 additions & 1 deletion app/rfp/[id]/[slug]/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,12 @@ export default async function GrantSlugLayout({ params, children }: Props) {
// in the sidebar and an Apply and Fund bar where the sidebar is hidden.
return (
<FundPanelProvider>
<GrantPageProvider defaultTab="details" grant={grant}>
<GrantPageProvider
defaultTab="details"
grant={grant}
title={grantTitle}
imageUrl={work.image}
>
<PageLayout
contentWidth="narrow"
// The pool card's top lines up with the byline's.
Expand Down
12 changes: 9 additions & 3 deletions components/AIMode/WorkspacePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,11 @@
import { DocumentCard } from './chat/DocumentCard';
import { newDraftTitle } from './copy';
import { DocumentPane } from './document/DocumentPane';
import { PublishAction, type PublishRequestOutcome } from './document/PublishPill';
import {
PublishAction,
SUBMIT_APPLICATION_LABEL,
type PublishRequestOutcome,
} from './document/PublishPill';
import { useAIModeDocument, type AIModeDocument } from './document/useAIModeDocument';
import { usePaneReveal } from './shell/usePaneReveal';
import { ViewSwitch } from './shell/ViewSwitch';
Expand Down Expand Up @@ -308,6 +312,8 @@
// be accepted or rejected), the document comes into view instead.
const loadedNote = doc.details ?? doc.content;
const published = loadedNote != null && isPublishedNote(loadedNote);
// A draft proposal that answers an RFP is submitted, not published.
const applying = loadedNote?.selectedGrant != null;
const writing = doc.status === 'drafting' || doc.status === 'working';
// Held with the document it was for, so it never carries over to another.
const [publishRequestedFor, setPublishRequestedFor] = useState<number | null>(null);
Expand All @@ -324,7 +330,7 @@
() =>
documentHidden ? (
<PublishAction
label={published ? 'Update' : 'Publish'}
label={published ? 'Update' : applying ? SUBMIT_APPLICATION_LABEL : 'Publish'}

Check warning on line 333 in components/AIMode/WorkspacePage.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Extract this nested ternary operation into an independent statement.

See more on https://sonarcloud.io/project/issues?id=ResearchHub_web&issues=AaGn2Un_TAqW3vdTSx_k&open=AaGn2Un_TAqW3vdTSx_k&pullRequest=1275
busy={false}
// Not while the assistant writes: the document would only say to wait.
disabled={loadedNote == null || writing}
Expand All @@ -334,7 +340,7 @@
className={isBelowTablet ? 'h-8 gap-1.5 pl-3 pr-3.5 text-[13px]' : undefined}
/>
) : null,
[documentHidden, published, loadedNote, writing, requestPublish, isBelowTablet]
[documentHidden, published, applying, loadedNote, writing, requestPublish, isBelowTablet]
);
const topBarEnd = useMemo(
() =>
Expand Down
1 change: 1 addition & 0 deletions components/AIMode/chat/ChatPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,7 @@ export function ChatPane({
composer={composer}
greeting={AI_MODE_GREETING}
intent={state.intent}
applyingTo={state.intent === 'need_funding' ? state.selectedGrant : null}
presets={
// A funder sees what to ask for; a researcher brings their own topic.
state.intent === 'fund' ? (
Expand Down
18 changes: 11 additions & 7 deletions components/AIMode/document/ChoiceList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,9 @@ export interface ChoiceOption<T extends string> {
/** What the option is, in a few words under its label, for someone new to the idea. */
readonly description?: string;
/**
* What the option means, said as plain points rather than weighed with
* checks and crosses: for an option whose upsides depend on what the person
* choosing wants. Takes the counts' place for this option.
* What the option means, said as plain points, each with a check rather
* than weighed yes or no: for an option whose upsides depend on what the
* person choosing wants. Takes the counts' place for this option.
*/
readonly notes?: readonly string[];
/** Shown under the option while it is the one picked: what picking it asks for next. */
Expand Down Expand Up @@ -134,12 +134,16 @@ export function ChoiceList<T extends string>({
{mark ? (
<MarkIcon mark={mark} label={markLabel(mark)} />
) : (
// A plain point, neither for nor against: the marks' circle, empty, in
// blue so it reads as neither a check's green nor a cross's gray.
// A plain point: the yes check, unlabelled, since it is not a yes to anything.
<span
aria-hidden="true"
className="h-5 w-5 shrink-0 rounded-full bg-primary-100"
/>
className={cn(
'flex h-5 w-5 shrink-0 items-center justify-center rounded-full',
MARK_STYLE.yes.className
)}
>
<Check className="h-3 w-3" strokeWidth={3} />
</span>
)}
<span
className={cn(
Expand Down
14 changes: 11 additions & 3 deletions components/AIMode/document/PublishDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@
* screen of its own, over whatever was showing; from `md` up, a card in the
* middle.
*/
export function PublishDialog({ title, onRename }: PublishDialogProps) {

Check failure on line 53 in components/AIMode/document/PublishDialog.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 22 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=ResearchHub_web&issues=AaGn2Uj6TAqW3vdTSx_f&open=AaGn2Uj6TAqW3vdTSx_f&pullRequest=1275
const { articleType, workId, isPublishing, dismissConfirmation, confirmPublish } =
usePublishingController();
const { watch } = useFormContext<PublishingFormData>();
Expand All @@ -66,7 +66,9 @@
const isNewProposal = articleType === 'preregistration' && !isUpdate;
const privateNeedsRfp = useShowPrivateWarning() && isNewProposal;

const action = isUpdate ? 'Update' : 'Publish';
// A new proposal that answers an RFP is the researcher's application to it.
const isApplication = isNewProposal && watch('selectedGrant') != null;
const action = isUpdate ? 'Update' : isApplication ? 'Submit' : 'Publish';

Check warning on line 71 in components/AIMode/document/PublishDialog.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Extract this nested ternary operation into an independent statement.

See more on https://sonarcloud.io/project/issues?id=ResearchHub_web&issues=AaGn2Uj6TAqW3vdTSx_g&open=AaGn2Uj6TAqW3vdTSx_g&pullRequest=1275
const { missingNames } = readiness;
const canPublish = readiness.ready && hasAgreed && !isPublishing && !privateNeedsRfp;

Expand Down Expand Up @@ -119,7 +121,9 @@
as="h2"
className="text-lg font-semibold tracking-tight text-gray-900"
>
{action} {isRfp ? 'RFP' : 'proposal'}
{isApplication
? 'Submit application'
: `${action} ${isRfp ? 'RFP' : 'proposal'}`}

Check warning on line 126 in components/AIMode/document/PublishDialog.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Extract this nested ternary operation into an independent statement.

See more on https://sonarcloud.io/project/issues?id=ResearchHub_web&issues=AaGn2Uj6TAqW3vdTSx_h&open=AaGn2Uj6TAqW3vdTSx_h&pullRequest=1275
</DialogTitle>
{/* On a phone the Required heading that counts them scrolls away. */}
{missingNames.length > 0 && (
Expand Down Expand Up @@ -204,7 +208,11 @@
disabled={!canPublish}
className="h-12 w-full text-base disabled:cursor-not-allowed disabled:opacity-50 md:h-10 md:w-auto md:text-sm"
>
{isPublishing ? 'Publishing...' : action}
{isPublishing
? isApplication
? 'Submitting...'
: 'Publishing...'

Check warning on line 214 in components/AIMode/document/PublishDialog.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Extract this nested ternary operation into an independent statement.

See more on https://sonarcloud.io/project/issues?id=ResearchHub_web&issues=AaGn2Uj6TAqW3vdTSx_i&open=AaGn2Uj6TAqW3vdTSx_i&pullRequest=1275
: action}
</Button>
</div>
</div>
Expand Down
15 changes: 13 additions & 2 deletions components/AIMode/document/PublishPill.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
'use client';

import { useEffect, useRef } from 'react';
import { useFormContext } from 'react-hook-form';
import { ExternalLink, Send } from 'lucide-react';
import { usePublishingController } from '@/components/Notebook/PublishingForm';
import { getWorkPath } from '@/components/Notebook/PublishingForm/formMapping';
import type { PublishingFormData } from '@/components/Notebook/PublishingForm/schema';
import { Loader } from '@/components/ui/Loader';
import { cn } from '@/utils/styles';

Expand All @@ -14,16 +16,22 @@
* filled in there. Once published it says so, with a way to the page and an
* Update button.
*/
export function PublishPill() {

Check failure on line 19 in components/AIMode/document/PublishPill.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 16 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=ResearchHub_web&issues=AaGn2UjyTAqW3vdTSx_d&open=AaGn2UjyTAqW3vdTSx_d&pullRequest=1275
const controller = usePublishingController();
const { note, editor, articleType, workId, isPublished } = controller;
const { watch } = useFormContext<PublishingFormData>();
// A proposal that answers an RFP is the researcher's application to it:
// publishing it is what submits it.
const applying = articleType === 'preregistration' && watch('selectedGrant') != null;

if (!articleType || !hasPublishButton(controller)) return null;

const handleClick = () => void controller.requestPublish();

const busyLabel = controller.isUpserting
? 'Publishing...'
? applying && !isPublished
? 'Submitting...'
: 'Publishing...'

Check warning on line 34 in components/AIMode/document/PublishPill.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Extract this nested ternary operation into an independent statement.

See more on https://sonarcloud.io/project/issues?id=ResearchHub_web&issues=AaGn2UjyTAqW3vdTSx_e&open=AaGn2UjyTAqW3vdTSx_e&pullRequest=1275
: controller.isLinkingNonprofit
? 'Linking nonprofit...'
: controller.isRedirecting
Expand Down Expand Up @@ -59,7 +67,7 @@
</div>
) : (
<PublishAction
label={busyLabel ?? 'Publish'}
label={busyLabel ?? (applying ? SUBMIT_APPLICATION_LABEL : 'Publish')}
busy={busyLabel != null}
disabled={disabled}
onClick={handleClick}
Expand All @@ -71,6 +79,9 @@
);
}

/** Publish, for a draft proposal that answers an RFP. */
export const SUBMIT_APPLICATION_LABEL = 'Submit application';

type PublishingController = ReturnType<typeof usePublishingController>;

/** A type this surface cannot publish has no button to press. */
Expand Down
28 changes: 28 additions & 0 deletions components/AIMode/rfpHandoff.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import type { SelectedGrantDetails } from '@/types/grant';

/**
* An RFP page's Apply hands the RFP it was on to the workspace, so the start
* screen can show it picked without asking the server for it again. Kept for
* the tab's session, so a reload of the start screen still has it.
*/
const KEY_PREFIX = 'workspace:rfp:';

export function handOffRfp(grant: SelectedGrantDetails): void {
try {
window.sessionStorage.setItem(KEY_PREFIX + grant.id, JSON.stringify(grant));
} catch {
// Storage is unavailable: the start screen opens with no RFP picked.
}
}

/** The RFP an Apply handed over under this id, if this tab has it. */
export function readHandedOffRfp(grantId: string): SelectedGrantDetails | null {
try {
const stored = window.sessionStorage.getItem(KEY_PREFIX + grantId);
if (!stored) return null;
const grant = JSON.parse(stored) as SelectedGrantDetails;
return grant?.id === grantId && grant.shortTitle ? grant : null;
} catch {
return null;
}
}
58 changes: 58 additions & 0 deletions components/AIMode/start/ApplyingToCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { Target } from 'lucide-react';
import { isAwardMechanism } from '@/components/Funding/lib/fundingMechanism';
import type { SelectedGrantDetails } from '@/types/grant';
import { formatCompactAmount } from '@/utils/currency';
import { cn } from '@/utils/styles';

interface ApplyingToCardProps {
readonly grant: SelectedGrantDetails;
readonly className?: string;
}

/**
* Over a researcher's composer once an RFP is picked: says that what they
* draft here is their application to it, with the RFP's cover, name, funder
* and what it offers.
*/
export function ApplyingToCard({ grant, className }: ApplyingToCardProps) {
const offer =
grant.fundingAmount > 0
? `${formatCompactAmount(grant.fundingAmount)} ${isAwardMechanism(grant.fundingMechanism) ? 'prize' : 'funding'}`

Check warning on line 20 in components/AIMode/start/ApplyingToCard.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Extract this nested ternary operation into an independent statement.

See more on https://sonarcloud.io/project/issues?id=ResearchHub_web&issues=AaGn2Ul7TAqW3vdTSx_j&open=AaGn2Ul7TAqW3vdTSx_j&pullRequest=1275
: null;
const facts = [grant.organization.trim(), offer].filter(Boolean).join(' · ');

return (
<div
data-testid="workspace-applying-to"
className={cn(
'flex items-center gap-3 rounded-xl border border-primary-100 bg-primary-50/60 px-4 py-3',
className
)}
>
{grant.imageUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={grant.imageUrl}
alt=""
className="h-10 w-10 shrink-0 rounded-lg object-cover ring-1 ring-black/5"
/>
) : (
<span
aria-hidden="true"
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-white text-primary-600 ring-1 ring-primary-100"
>
<Target className="h-[18px] w-[18px]" />
</span>
)}
<span className="min-w-0 flex-1">
<span className="block text-[11px] font-medium uppercase tracking-[0.07em] text-primary-700">
Submitting an application to
</span>
<span className="block truncate text-sm font-semibold text-gray-900">
{grant.shortTitle}
</span>
{facts && <span className="block truncate text-xs text-gray-600">{facts}</span>}
</span>
</div>
);
}
8 changes: 7 additions & 1 deletion components/AIMode/start/StartScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@

import { useId, useState, type ReactNode } from 'react';
import type { FundingIntent } from '@/components/Funding/fundingDirection';
import type { SelectedGrantDetails } from '@/types/grant';
import { INTENT_COPY } from '../copy';
import { ApplyingToCard } from './ApplyingToCard';
import { ConciergeCard } from './ConciergeCard';
import { HowItWorksCard, HowItWorksSteps } from './HowItWorks';
import { VisibilityCard } from './VisibilityCard';
Expand All @@ -21,6 +23,8 @@ interface StartScreenProps {
readonly composer: ReactNode;
/** Examples to send, under the composer. */
readonly presets?: ReactNode;
/** The RFP a researcher is applying to, said over the composer. */
readonly applyingTo?: SelectedGrantDetails | null;
}

/**
Expand All @@ -29,14 +33,16 @@ interface StartScreenProps {
* — an RFP or a proposal — was settled by the way in, before a word is
* typed. One column; the document appears beside the chat once it exists.
*/
export function StartScreen({ greeting, intent, composer, presets }: StartScreenProps) {
export function StartScreen({ greeting, intent, composer, presets, applyingTo }: StartScreenProps) {
return (
<div className="mx-auto flex min-h-[60vh] w-full max-w-[680px] flex-col justify-center gap-5">
<div className="flex flex-col gap-1">
<h2 className="font-serif text-4xl tracking-tight text-gray-900">{greeting}</h2>
<p className="text-[15px] text-gray-500">{INTENT_COPY[intent].tagline}</p>
</div>

{applyingTo && <ApplyingToCard grant={applyingTo} />}

<div className="-mx-3">{composer}</div>

{presets && <div className="-mt-2 mb-4">{presets}</div>}
Expand Down
27 changes: 10 additions & 17 deletions components/AIMode/useAIModeChat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { toast } from 'react-hot-toast';
import { useAIMode } from './AIModeContext';
import { untitledDraftTitle } from './copy';
import { readHandedOffRfp } from './rfpHandoff';
import { rememberWorkspaceView } from './shell/workspaceView';
import { useFundingIntent } from './start/useFundingIntent';
import { mostRecentChat, useWorkspaceTarget } from './useWorkspaceTarget';
Expand All @@ -21,7 +22,6 @@ import { useUser } from '@/contexts/UserContext';
import { useAgentChatList, type UseAgentChatListResult } from '@/hooks/useAgentChat';
import { useChatSession, type ChatSession } from '@/hooks/useChatSession';
import { getChatTransport } from '@/services/chatTransport';
import { GrantService } from '@/services/grant.service';
import type { AgentChat, ChatNoteRef } from '@/types/agentChat';
import type { SelectedGrantDetails } from '@/types/grant';
import type { Note } from '@/types/note';
Expand Down Expand Up @@ -119,24 +119,16 @@ export function useAIModeChat(): AIModeChatState {
const intent = urlIntent ?? rememberedIntent;
const [selectedGrant, setSelectedGrant] = useState<SelectedGrantDetails | null>(null);

// An RFP page's Apply leads here with the RFP named (`?rfp=<id>`): it starts
// out picked, as if chosen on this screen, and can still be changed. The
// RFP is read signed in, so a visitor who logs in here gets it then.
const { user, isLoading: userLoading } = useUser();
const signedInUserId = user?.id ?? null;
// An RFP page's Apply leads here with the RFP named (`?rfp=<id>`) and hands
// over what it knows of it, so it starts out picked, as if chosen on this
// screen, with no request for it. It can still be changed. Without the
// handover (a link opened in another tab) nothing is picked.
const urlGrantId = target.kind === 'new' ? (target.grantId ?? null) : null;
useEffect(() => {
if (urlGrantId == null || signedInUserId == null) return;
let cancelled = false;
GrantService.getSelectedGrant(urlGrantId)
.then((grant) => {
if (!cancelled) setSelectedGrant(grant);
})
.catch((error) => console.error('Error loading the RFP to apply to:', error));
return () => {
cancelled = true;
};
}, [urlGrantId, signedInUserId]);
if (urlGrantId == null) return;
const handedOff = readHandedOffRfp(urlGrantId);
if (handedOff) setSelectedGrant(handedOff);
}, [urlGrantId]);

// ---- the first message of a new draft ----
// It creates the document, then a chat on it, then goes out there. A note
Expand Down Expand Up @@ -327,6 +319,7 @@ export function useAIModeChat(): AIModeChatState {
// ---- a signed-out visitor ----
// What would start a draft asks them to log in, and keeps what they typed
// (or the suggestion they picked) in the box for when they are back.
const { user, isLoading: userLoading } = useUser();
const { showAuthModal } = useAuthModalContext();
const signedOut = user == null && !userLoading;
const { setDraft } = session;
Expand Down
Loading