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
7 changes: 7 additions & 0 deletions .agents/skills/grill-me/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
name: grill-me
description: A relentless interview to sharpen a plan or design.
disable-model-invocation: true
---

Call the Skill tool with "grilling".
5 changes: 5 additions & 0 deletions .agents/skills/grill-me/agents/openai.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
interface:
display_name: "Grill Me"
short_description: "Sharpen a plan through interview"
policy:
allow_implicit_invocation: false
4 changes: 4 additions & 0 deletions app/(home)/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,16 @@ import { ReactNode } from 'react';
import { PageLayout } from '@/app/layouts/PageLayout';
import { HomeTabs } from '@/components/Funding/HomeTabs';
import { HomeFeedsProvider } from '@/components/Funding/HomeFeedsProvider';
import { DraftWithAICard, DraftWithAIToast } from '@/components/AIMode/DraftWithAICard';

export default function HomeLayout({ children }: { children: ReactNode }) {
return (
<PageLayout contentWidth="narrow">
<HomeFeedsProvider>
<HomeTabs />
{/* Phones; from the tablet breakpoint up it is the toast instead. */}
<DraftWithAICard className="mb-6 tablet:!hidden" />
<DraftWithAIToast />
{children}
</HomeFeedsProvider>
</PageLayout>
Expand Down
158 changes: 158 additions & 0 deletions components/AIMode/DraftWithAICard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
'use client';

import { useEffect, useState } from 'react';
import { Sparkles, X } from 'lucide-react';
import { Button } from '@/components/ui/Button';
import { useAuthenticatedAction } from '@/contexts/AuthModalContext';
import { useFundingDrafting } from '@/components/Funding/useFundingDrafting';
import type { FundingIntent } from '@/components/Funding/fundingDirection';
import { useDismissableFeature } from '@/hooks/useDismissableFeature';
import { cn } from '@/utils/styles';

/** One key for the toast and the card, so closing either closes both. */
export const DRAFT_WITH_AI_CARD_FEATURE = 'draft_with_ai_card';

/** Long enough that the page settles before the toast arrives. */
const TOAST_DELAY_MS = 1200;
/** Matches the toast's transition, so it finishes leaving before it unmounts. */
const TOAST_EXIT_MS = 300;

/**
* Tells people the workspace can draft an RFP or a proposal from a sentence,
* with a door to each. Above the feed on phones, where the bottom corner
* belongs to the nav and the funding power bar. Shown until dismissed.
*/
export function DraftWithAICard({ className }: { readonly className?: string }) {
const { isDismissed, dismissFeature, dismissStatus } = useDismissableFeature(
DRAFT_WITH_AI_CARD_FEATURE
);

if (dismissStatus !== 'checked' || isDismissed) return null;

return (
<section
aria-label="Draft with AI"
className={cn(
'relative rounded-xl border border-gray-200 bg-white p-3.5 shadow-sm',
className
)}
>
<DraftWithAIContent onDismiss={dismissFeature} buttonClassName="h-11" />
</section>
);
}

/**
* The same pitch as a toast in the bottom-right corner, from the tablet
* breakpoint up. For a signed-out visitor it rides above the Join banner
* while that is up, which publishes its height as `--join-banner-height`.
*/
export function DraftWithAIToast() {
const { isDismissed, dismissFeature, dismissStatus } = useDismissableFeature(
DRAFT_WITH_AI_CARD_FEATURE
);
const isEligible = dismissStatus === 'checked' && !isDismissed;
const [isShown, setIsShown] = useState(false);

useEffect(() => {
if (!isEligible) return;
const timer = window.setTimeout(() => setIsShown(true), TOAST_DELAY_MS);
return () => window.clearTimeout(timer);
}, [isEligible]);

if (!isEligible) return null;

const dismiss = () => {
setIsShown(false);
window.setTimeout(dismissFeature, TOAST_EXIT_MS);
};

return (
<section
aria-label="Draft with AI"
inert={!isShown}
className={cn(
'fixed bottom-[calc(1.5rem+var(--join-banner-height,0px))] right-6 z-50 hidden w-[360px] tablet:!block',
'rounded-2xl border border-gray-200 bg-white p-4',
'shadow-[0_16px_40px_-12px_rgba(15,23,42,0.28),0_2px_6px_-2px_rgba(15,23,42,0.08)]',
'transition-[opacity,transform,bottom] duration-300 ease-out motion-reduce:transition-none',
isShown ? 'translate-y-0 opacity-100' : 'pointer-events-none translate-y-4 opacity-0'
)}
>
<p className="mb-3 flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-[0.07em] text-primary-700">
<Sparkles className="h-3.5 w-3.5" aria-hidden />
New · Drafting with AI
</p>
<DraftWithAIContent onDismiss={dismiss} />
</section>
);
}

function DraftWithAIContent({
onDismiss,
buttonClassName,
}: {
readonly onDismiss: () => void;
readonly buttonClassName?: string;
}) {
const { executeAuthenticatedAction } = useAuthenticatedAction();
const { startNew } = useFundingDrafting();
const start = (intent: FundingIntent) => executeAuthenticatedAction(() => startNew(intent));

return (
<>
<button
type="button"
onClick={onDismiss}
aria-label="Dismiss"
className="absolute right-1.5 top-1.5 rounded-full p-2 text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-700"
>
<X className="h-4 w-4" />
</button>

<div className="flex items-center gap-3 pr-6">
<DraftPreview className="w-32 shrink-0" />
<div className="min-w-0">
<h2 className="font-serif text-xl leading-tight text-gray-900">
Let AI write the first draft
</h2>
<p className="mt-1 text-[13px] leading-snug text-gray-600">
One sentence in, a full RFP or proposal out.
</p>
</div>
</div>

<div className="mt-3 grid grid-cols-2 gap-2">
<Button className={cn('font-semibold', buttonClassName)} onClick={() => start('fund')}>
New RFP
</Button>
<Button
className={cn('font-semibold', buttonClassName)}
variant="outlined"
onClick={() => start('need_funding')}
>
New proposal
</Button>
</div>
</>
);
}

/** A request turning into an RFP: what the workspace does, drawn rather than said. */
function DraftPreview({ className }: { className?: string }) {

Check warning on line 142 in components/AIMode/DraftWithAICard.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Mark the props of the component as read-only.

See more on https://sonarcloud.io/project/issues?id=ResearchHub_web&issues=AaEcPYxJkfLeBcQcS-zP&open=AaEcPYxJkfLeBcQcS-zP&pullRequest=1157
return (
<div aria-hidden className={cn('flex flex-col gap-1.5 rounded-md bg-gray-100 p-2', className)}>
<p className="max-w-[84%] self-end rounded-[10px] rounded-br-sm bg-gray-900 px-1.5 py-1 text-[9.5px] leading-snug text-white">
Fund early-career labs testing antivirals for long COVID
</p>
<div className="flex flex-col gap-1 rounded-md border border-gray-200 bg-white p-1.5">
<p className="flex items-center gap-1 text-[8px] font-semibold tracking-wide text-primary-700">
<span className="h-1.5 w-1.5 rounded-full bg-primary-500" />

Check warning on line 150 in components/AIMode/DraftWithAICard.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Ambiguous spacing after previous element span

See more on https://sonarcloud.io/project/issues?id=ResearchHub_web&issues=AaEcPYxJkfLeBcQcS-zQ&open=AaEcPYxJkfLeBcQcS-zQ&pullRequest=1157
RFP · DRAFTING
</p>
<span className="h-[5px] w-full rounded-full bg-gray-200" />
<span className="h-[5px] w-[64%] rounded-full bg-gray-200" />
</div>
</div>
);
}
1 change: 1 addition & 0 deletions components/Funding/FundingPowerCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ export const FundingPowerCard = ({ className }: FundingPowerCardProps) => {
{/* Two-line so the subtitle can carry the methods, which is the whole
reason to open it — "Deposit" alone read as RSC-only. */}
<Button
variant="dark"
onClick={openAddFunds}
className="mt-3.5 h-auto w-full justify-start gap-2 px-3 py-2 text-left"
>
Expand Down
28 changes: 27 additions & 1 deletion components/banners/JoinResearchHubBanner.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client';

import { useEffect, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { X } from 'lucide-react';
import { Button } from '@/components/ui/Button';
import { Logo } from '@/components/ui/Logo';
Expand All @@ -14,6 +14,12 @@ import { cn } from '@/utils/styles';
const REVEAL_AT_SCROLL_Y = 120;
const HIDE_AT_SCROLL_Y = 48;

/**
* The banner's height while it is up, 0 otherwise, so things fixed to the
* bottom of the screen (the Draft with AI toast) can sit above it.
*/
const HEIGHT_VARIABLE = '--join-banner-height';

export const JoinResearchHubBanner = () => {
const { user, isLoading } = useUser();
const { showAuthModal } = useAuthModalContext();
Expand All @@ -25,6 +31,7 @@ export const JoinResearchHubBanner = () => {
// The banner stays mounted while hidden, so the sweep has to be held back
// until it is actually on screen. Latched on, never off, so it plays once.
const [hasShimmered, setHasShimmered] = useState(false);
const bannerRef = useRef<HTMLDivElement>(null);

const isEligible = !isLoading && !user && !isDismissed;

Expand All @@ -51,6 +58,24 @@ export const JoinResearchHubBanner = () => {
return () => target.removeEventListener('scroll', handleScroll);
}, [scrollContainerRef, isEligible]);

const isShowing = isEligible && isRevealed;

useEffect(() => {
const banner = bannerRef.current;
if (!isShowing || !banner) return;

const root = document.documentElement;
const publish = () => root.style.setProperty(HEIGHT_VARIABLE, `${banner.offsetHeight}px`);
publish();
const observer = new ResizeObserver(publish);
observer.observe(banner);

return () => {
observer.disconnect();
root.style.removeProperty(HEIGHT_VARIABLE);
};
}, [isShowing]);

if (!isEligible) return null;

return (
Expand All @@ -59,6 +84,7 @@ export const JoinResearchHubBanner = () => {
<div aria-hidden className="h-60" />

<div
ref={bannerRef}
role="region"
aria-label="Join ResearchHub"
inert={!isRevealed}
Expand Down
13 changes: 13 additions & 0 deletions contexts/DismissedFeaturesContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,19 @@ const DismissedFeaturesContext = createContext<DismissedFeaturesContextValue | n

const getLocalStorageKey = (feature: string) => `feature_${feature.toLowerCase()}_dismissed`;

/**
* Whether this browser has a dismissal stored for the feature. For a signed-out
* user that is the only record, and it can be needed before the cache below
* has been filled from it.
*/
export function isDismissedInStorage(feature: string): boolean {
try {
return window.localStorage.getItem(getLocalStorageKey(feature)) === 'true';
} catch {
return false;
}
}

interface DismissedFeaturesProviderProps {
children: ReactNode;
}
Expand Down
23 changes: 17 additions & 6 deletions hooks/useDismissableFeature.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { useEffect, useState, useCallback } from 'react';
import { SiteService } from '@/services/site.service';
import { useUser } from '@/contexts/UserContext';
import { useDismissedFeaturesContext } from '@/contexts/DismissedFeaturesContext';
import {
isDismissedInStorage,
useDismissedFeaturesContext,
} from '@/contexts/DismissedFeaturesContext';

type DismissStatus = 'unchecked' | 'checked' | 'checking';

Expand Down Expand Up @@ -61,9 +64,13 @@ export function useDismissableFeature(featureName: string): {
return;
}

// For logged-out users, cache as not dismissed and mark as checked
// For logged-out users, this browser's storage is the only record. Read it
// directly: this can run before the context's cache has been filled from
// it, and caching "not dismissed" here would erase the stored dismissal.
if (!user) {
setFeatureStatus(featureName, false);
const dismissed = isDismissedInStorage(featureName);
if (dismissed) setFeatureStatus(featureName, true);
setIsDismissed(dismissed);
setStatus('checked');
return;
}
Expand Down Expand Up @@ -221,20 +228,24 @@ export function useDismissableFeatures(featureNames: string[]): {
);

if (featuresToCache.length > 0) {
// Read from storage rather than caching "not dismissed", which would
// erase a dismissal the context's cache has not picked up yet.
const storedDismissals = new Set(featuresToCache.filter(isDismissedInStorage));

setFeatures((prev) => {
const updated = { ...prev };
featuresToCache.forEach((featureName) => {
updated[featureName] = {
isDismissed: false,
isDismissed: storedDismissals.has(featureName),
dismissStatus: 'checked',
};
});
return updated;
});

// Batch context updates outside of state update
featuresToCache.forEach((featureName) => {
setFeatureStatus(featureName, false);
storedDismissals.forEach((featureName) => {
setFeatureStatus(featureName, true);
});
}
}
Expand Down
11 changes: 11 additions & 0 deletions skills-lock.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"version": 1,
"skills": {
"grill-me": {
"source": "mattpocock/skills",
"sourceType": "github",
"skillPath": "skills/productivity/grill-me/SKILL.md",
"computedHash": "9cdbb4b8f7a3aeaef82e6230c9e823500d4a8a1214c726cf342cf9829d34fc7d"
}
}
}
Loading