Skip to content
Draft
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
105 changes: 83 additions & 22 deletions components/Notebook/AgentChat/AgentChatPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@
import { ChatPresets } from './ChatPresets';
import { ChatSources, collectChatSources } from './ChatSources';
import { ChatTranscript } from './ChatTranscript';
import { CreditMeter } from './CreditMeter';
import { useResearchAI } from '@/hooks/useResearchAI';
import { canSelectAIModel } from '@/types/researchAI';
import { ModelControls } from './ModelControls';
import { Logo } from '@/components/ui/Logo';
import {
Expand All @@ -52,13 +55,18 @@
/** Pixels per arrow key press while the resize divider has focus. */
const RESIZE_KEY_STEP = 24;

function noticeFromOutcome(outcome: SendOutcome & { ok: false }): ComposerNotice {
function noticeFromOutcome(outcome: SendOutcome & { ok: false }): ComposerNotice | null {
switch (outcome.reason) {
case 'usage_limit':
// The shared meter owns this notice, including when the allowance resets.
return null;
case 'account_busy':
case 'busy':
return {
tone: 'warning',
text: outcome.detail ?? 'The assistant is still working on a previous message.',
};
case 'model_not_allowed':
case 'invalid':
return { tone: 'error', text: outcome.detail ?? 'That message can’t be sent.' };
case 'not_found':
Expand Down Expand Up @@ -220,8 +228,8 @@
/**
* The notebook AI assistant panel: chat picker, transcript with live turn
* progress, and composer. Stays mounted while the notebook is open so chat
* selection and drafts survive closing the panel; all network activity is
* gated on `open`.
* selection and drafts survive closing the panel. Chat requests are gated on
* `open`; user-wide allowances load with the notebook.
*/
export function AgentChatPanel({
noteId,
Expand All @@ -237,6 +245,11 @@
onReviewChange,
}: AgentChatPanelProps) {
const { editor, currentNote } = useNotebookContext();
// This panel stays mounted even when closed: load allowances on notebook open.
const researchAI = useResearchAI(true);
const canSelectModel =
canSelectAIModel(researchAI.budget?.tier) && researchAI.budgetStatus === 'ok';
const budgetSendDisabled = researchAI.budgetStatus !== 'ok' || researchAI.isSubmissionBlocked();
// Decide which writing preset the empty chat screen offers, and what it
// calls the document: the notebook holds RFPs as well as proposals.
const noteIsEmpty = useEditorIsEmpty(editor);
Expand Down Expand Up @@ -277,9 +290,15 @@
// ---- model selection ----
// The catalog loads with the panel. A chat that has already run a turn is
// locked to the model it started on, and reports it here; until then the
// browser-level preference decides.
// API default decides.
const modelSelection = useAgentModelSelection({
enabled: open,
enabled: false,
canSelect: canSelectModel,
conversationKey: `${noteId}:${selectedChatId ?? 'new'}`,
locked:
(chatState.chat?.executions.length ?? 0) > 0 ||
(chatState.chat?.messages.length ?? 0) > 0 ||
chatState.pendingSend !== null,
pinnedRef: chatState.pinnedModelRef,
});

Expand Down Expand Up @@ -344,10 +363,24 @@

// ---- server-side access gate ----
useEffect(() => {
if (list.access === 'hidden' || chatState.access === 'unauthorized') {
// Leave a visible restriction until the user closes the panel. A blocked
// account keeps the entry point so its unavailable state remains reachable.
if (
!open &&
researchAI.budgetStatus !== 'loading' &&
researchAI.budget?.tier !== 'blocked' &&
(list.access === 'hidden' || chatState.access === 'unauthorized')
) {
onUnavailable();
}
}, [list.access, chatState.access, onUnavailable]);
}, [
open,
list.access,
chatState.access,
onUnavailable,
researchAI.budgetStatus,
researchAI.budget?.tier,
]);

// ---- keep the listing fresh as the open chat evolves ----
// Derived titles land after the first turn, previews/spinners change as
Expand Down Expand Up @@ -384,7 +417,7 @@

const handleSend = useCallback(async () => {
const text = draft.trim();
if (!text) return;
if (!text || budgetSendDisabled || chatState.isBusy || creatingChat || queuedMessage) return;
setNotice(null);
const target = targetRef.current;
// Captured before the awaits: the turn runs on what was selected when the
Expand Down Expand Up @@ -435,6 +468,9 @@
modelSelection.request,
updateDraft,
isCurrentTarget,
budgetSendDisabled,
creatingChat,
queuedMessage,
]);

// Fire the queued first message once the freshly created chat is live.
Expand Down Expand Up @@ -926,16 +962,13 @@

// ---- derived composer state ----
// Sending before the catalog lands would run the turn on the server default
// and pin the conversation to it, silently losing the user's chosen model
// with no way back. Busy rather than disabled: the draft stays editable, only
// send waits. A catalog that fails resolves to `unavailable`, which sends on
// the server default by design.
// and pin the conversation to it. Keep the draft editable while send waits.
const composerBusy =
chatState.isBusy ||
chatState.isFinishing ||
creatingChat ||
queuedMessage != null ||
modelSelection.status === 'loading';
(canSelectModel && modelSelection.status === 'loading');
// Stop is only offered once something cancellable exists server-side. While
// the message POST is still in flight or the chat is being created, cancel
// would no-op and the turn would start anyway.
Expand All @@ -955,6 +988,20 @@
);

const renderBody = () => {
if (
researchAI.budget?.tier === 'blocked' ||
list.access === 'hidden' ||
chatState.access === 'unauthorized'
) {
return (
<div
role="status"
className="flex h-full items-center justify-center px-6 text-center text-sm text-gray-600"
>

Check warning on line 1000 in components/Notebook/AgentChat/AgentChatPanel.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use <output> instead of the "status" role to ensure accessibility across all devices.

See more on https://sonarcloud.io/project/issues?id=ResearchHub_web&issues=AaBtGZks2q7RbhuEWUUs&open=AaBtGZks2q7RbhuEWUUs&pullRequest=1082
You do not have access to the research assistant for this notebook.
</div>
);
}
if (selectedChatId == null) {
if (list.access === 'loading') return <CenteredLoader />;
if (list.access === 'error') {
Expand Down Expand Up @@ -1206,18 +1253,32 @@
busy={composerBusy}
canStop={canStop}
disabled={composerDisabled}
sendDisabled={budgetSendDisabled}
notice={notice}
toolbar={
<ModelControls
models={modelSelection.models}
model={modelSelection.model}
pinned={modelSelection.pinned}
options={modelSelection.options}
onSelectModel={modelSelection.selectModel}
onChangeOptions={modelSelection.setOptions}
disabled={composerDisabled}
footer={
<CreditMeter
budget={researchAI.budget}
budgetStatus={researchAI.budgetStatus}
limitResetAt={researchAI.limitResetAt}
onRefresh={() => {
void researchAI.refreshBudget(true);
}}
/>
}
toolbar={
canSelectModel && (
<ModelControls
models={modelSelection.models}
model={modelSelection.model}
pinned={modelSelection.pinned}
options={modelSelection.options}
onSelectModel={modelSelection.selectModel}
onChangeOptions={modelSelection.setOptions}
disabled={composerDisabled || composerBusy || budgetSendDisabled}
multiplierExplanation={modelSelection.multiplierExplanation}
/>
)
}
/>
</aside>
);
Expand Down
7 changes: 6 additions & 1 deletion components/Notebook/AgentChat/ChatComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ interface ChatComposerProps {
readonly canStop: boolean;
/** Hard-disable everything (chat unavailable). */
readonly disabled: boolean;
readonly sendDisabled?: boolean;
readonly footer?: ReactNode;
readonly notice: ComposerNotice | null;
readonly placeholder?: string;
/**
Expand Down Expand Up @@ -54,6 +56,8 @@ export function ChatComposer({
busy,
canStop,
disabled,
sendDisabled = false,
footer,
notice,
placeholder = 'Ask the assistant…',
textareaRef,
Expand All @@ -67,7 +71,7 @@ export function ChatComposer({
textarea.style.height = `${Math.min(textarea.scrollHeight, 160)}px`;
}, [value]);

const canSend = !disabled && !busy && value.trim().length > 0;
const canSend = !disabled && !sendDisabled && !busy && value.trim().length > 0;

const handleKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
if (event.key === 'Enter' && !event.shiftKey) {
Expand Down Expand Up @@ -143,6 +147,7 @@ export function ChatComposer({
)}
</div>
</div>
{footer}
{value.length >= COUNTER_THRESHOLD && (
<p className="mt-1 text-right text-[11px] text-gray-400">
{value.length.toLocaleString()} / {MAX_CHAT_MESSAGE_LENGTH.toLocaleString()}
Expand Down
65 changes: 65 additions & 0 deletions components/Notebook/AgentChat/CreditMeter.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
'use client';

import type { ResearchAIState } from '@/store/researchAI';
import { formatBudgetReset, formatCredits, isBudgetExhausted } from '@/types/researchAI';

export function CreditMeter({
budget,
budgetStatus,
limitResetAt,
onRefresh,
}: Pick<ResearchAIState, 'budget' | 'budgetStatus' | 'limitResetAt'> & { onRefresh: () => void }) {
if (budget?.tier === 'blocked') {
return (
<p role="status" className="mt-2 text-xs text-gray-600">

Check warning on line 14 in components/Notebook/AgentChat/CreditMeter.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use <output> instead of the "status" role to ensure accessibility across all devices.

See more on https://sonarcloud.io/project/issues?id=ResearchHub_web&issues=AaBtGZj82q7RbhuEWUUo&open=AaBtGZj82q7RbhuEWUUo&pullRequest=1082
Research AI is unavailable for this account.
</p>
);
}
if (!budget) {
return (
<p role="status" className="mt-2 text-xs text-gray-500">

Check warning on line 21 in components/Notebook/AgentChat/CreditMeter.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use <output> instead of the "status" role to ensure accessibility across all devices.

See more on https://sonarcloud.io/project/issues?id=ResearchHub_web&issues=AaBtGZj82q7RbhuEWUUp&open=AaBtGZj82q7RbhuEWUUp&pullRequest=1082
{budgetStatus === 'loading' ? 'Loading AI credits…' : 'Couldn’t load AI credits.'}
{budgetStatus === 'unavailable' && (
<button type="button" onClick={onRefresh} className="ml-2 underline">
Retry
</button>
)}
</p>
);
}
const exhausted = isBudgetExhausted(budget) || limitResetAt !== null;
const { remaining, daily_limit: limit } = budget.credits;
const reset = formatBudgetReset(budget.resets_at);
return (
<div className="mt-2 space-y-1 text-[11px] text-gray-500">
<div className="flex flex-wrap justify-between gap-x-3 gap-y-1">
<span
title={limit === null ? 'No daily credit limit' : `${formatCredits(limit)} daily credits`}
>
{limit === null
? 'Unlimited credits'
: remaining === null
? 'Credits unavailable'
: `${formatCredits(remaining)} credits remaining`}

Check warning on line 44 in components/Notebook/AgentChat/CreditMeter.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=AaBtGZj82q7RbhuEWUUq&open=AaBtGZj82q7RbhuEWUUq&pullRequest=1082
</span>
<time dateTime={budget.resets_at} title={new Date(budget.resets_at).toLocaleString()}>
Resets at {reset}
</time>
</div>
{exhausted && (
<p role="status" className="text-amber-700">

Check warning on line 51 in components/Notebook/AgentChat/CreditMeter.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use <output> instead of the "status" role to ensure accessibility across all devices.

See more on https://sonarcloud.io/project/issues?id=ResearchHub_web&issues=AaBtGZj82q7RbhuEWUUr&open=AaBtGZj82q7RbhuEWUUr&pullRequest=1082
Daily AI usage limit reached. Available again at {reset}.
</p>
)}
{budgetStatus === 'unavailable' && (
<p>
Credits may be out of date.{' '}
<button type="button" onClick={onRefresh} className="underline">
Refresh
</button>
</p>
)}
</div>
);
}
Loading
Loading