From d7e715eb1e97c5e18d917c76f1589cedda7fb3ff Mon Sep 17 00:00:00 2001 From: Jorge Rivera Date: Sun, 25 Oct 2026 17:30:20 +0100 Subject: [PATCH 01/14] fix(search): group entity-choice menus by entity_choice (#66) An option or slot token carrying entity_choice joins the no_data.ambiguous_entities group of that index, so two groups written alike ("aid from Congo to Congo") open two menus, each from its own slot. Without the field, menus group by mention text as before. Co-authored-by: Claude --- .../organisms/DataSearch/SearchCards.tsx | 2 +- .../organisms/DataSearch/SearchSentence.tsx | 2 +- src/lib/search/parsedEdit.ts | 39 ++++++++--- .../search/test/fixtures/ambiguous-congo.ts | 67 ++++++++++++++++++- src/lib/search/test/parsedEdit.test.ts | 15 +++++ src/lib/search/test/view.test.ts | 30 +++++++++ src/lib/search/types.ts | 17 ++++- src/lib/search/view.ts | 51 ++++++++------ 8 files changed, 187 insertions(+), 36 deletions(-) diff --git a/src/components/organisms/DataSearch/SearchCards.tsx b/src/components/organisms/DataSearch/SearchCards.tsx index 24ead24..e288319 100644 --- a/src/components/organisms/DataSearch/SearchCards.tsx +++ b/src/components/organisms/DataSearch/SearchCards.tsx @@ -129,7 +129,7 @@ const SearchCards = ({ {groups.flatMap((group) => group.options.map((option, index) => ( + ))} )} diff --git a/src/components/organisms/Studio/workspace/ChatPanel.module.scss b/src/components/organisms/Studio/workspace/ChatPanel.module.scss index 701db02..1e3c264 100644 --- a/src/components/organisms/Studio/workspace/ChatPanel.module.scss +++ b/src/components/organisms/Studio/workspace/ChatPanel.module.scss @@ -247,6 +247,12 @@ } } +// A chip that switches the answer's reading is the follow-up chip, dashed, so +// it reads as a change to this answer. +.choiceChip { + border-style: dashed; +} + // Clarify picker: a card that stands in for the input while the agent waits // on a choice. rem(10) padding all round, options as bordered rows. .picker { diff --git a/src/components/organisms/Studio/workspace/ChatPanel.tsx b/src/components/organisms/Studio/workspace/ChatPanel.tsx index 4642b75..e90bffa 100644 --- a/src/components/organisms/Studio/workspace/ChatPanel.tsx +++ b/src/components/organisms/Studio/workspace/ChatPanel.tsx @@ -87,7 +87,8 @@ const ChatPanel = ({ }, [chat.messages, chat.queued, chat.status, chat.statusDetail, follow]); const sendFollow = () => { - if (!follow.trim()) { + // A plan switch in flight has the pause, so the text waits in the input. + if (!follow.trim() || chat.plan?.switching) { return; } @@ -149,6 +150,7 @@ const ChatPanel = ({ onAdd={onAdd} onRemove={onRemove} onFollowup={chat.send} + onApplyChoice={chat.applyChoice} readOnly={readOnly} /> {sharedAfter(index) && ( @@ -210,6 +212,9 @@ const ChatPanel = ({ plan={chat.plan} onApprove={() => chat.respondPlan('approve')} onCancel={() => chat.respondPlan('reject')} + // Switching goes with amending, which the + // wall takes away with the input. + onSwitch={wall ? undefined : chat.switchChoice} /> )} {wall ? ( diff --git a/src/components/organisms/Studio/workspace/PlanCard.module.scss b/src/components/organisms/Studio/workspace/PlanCard.module.scss index 035e403..94e644c 100644 --- a/src/components/organisms/Studio/workspace/PlanCard.module.scss +++ b/src/components/organisms/Studio/workspace/PlanCard.module.scss @@ -119,6 +119,18 @@ letter-spacing: -0.031em; } +// What the switch behind this plan changed, and why the harness refused the +// last answer to it, under the steps. +.moved, +.notice { + margin: 0; + padding: 0 rem(16) rem(14); + + &:empty { + padding: 0; + } +} + // --- "Start building?" ------------------------------------------------------ // Grows OUT of the input below: rounded on top only, its bottom edge diff --git a/src/components/organisms/Studio/workspace/PlanCard.tsx b/src/components/organisms/Studio/workspace/PlanCard.tsx index 976ef4b..8d1fab9 100644 --- a/src/components/organisms/Studio/workspace/PlanCard.tsx +++ b/src/components/organisms/Studio/workspace/PlanCard.tsx @@ -4,7 +4,7 @@ import classNames from 'classnames'; import { useState } from 'react'; import styles from './PlanCard.module.scss'; -import PlanModal from './PlanModal'; +import PlanModal, { PlanMovedNote, PlanNotice } from './PlanModal'; import type { ChatState } from '../hooks/useChat'; import Button from '@/components/atoms/Button/Button'; @@ -16,6 +16,7 @@ type PlanCardProps = { plan: NonNullable; onApprove: () => void; onCancel: () => void; + onSwitch?: (choiceId: string, optionId: string) => void; }; /** @@ -27,7 +28,7 @@ type PlanCardProps = { * changes or ask a follow up…" while the plan is pending, wired to the * amend decision). */ -const PlanCard = ({ plan, onApprove, onCancel }: PlanCardProps) => { +const PlanCard = ({ plan, onApprove, onCancel, onSwitch }: PlanCardProps) => { const [open, setOpen] = useState(false); const preview = plan.steps.slice(0, PREVIEW_STEPS); const more = plan.steps.length > PREVIEW_STEPS; @@ -57,14 +58,16 @@ const PlanCard = ({ plan, onApprove, onCancel }: PlanCardProps) => { ))} + +

Start building?

- -
@@ -73,6 +76,7 @@ const PlanCard = ({ plan, onApprove, onCancel }: PlanCardProps) => { plan={plan} open={open} onOpenChange={setOpen} + onSwitch={onSwitch} onApprove={() => { setOpen(false); onApprove(); diff --git a/src/components/organisms/Studio/workspace/PlanModal.module.scss b/src/components/organisms/Studio/workspace/PlanModal.module.scss index d862f54..0b16266 100644 --- a/src/components/organisms/Studio/workspace/PlanModal.module.scss +++ b/src/components/organisms/Studio/workspace/PlanModal.module.scss @@ -139,6 +139,97 @@ } } +// A scope decision's options, the clarify picker's option buttons at the +// size of the decision text. +.options { + @include flex(row, flex-start, stretch); + + flex-wrap: wrap; + gap: rem(4); + margin-top: rem(6); +} + +.option { + @include flex(column, flex-start, flex-start); + @include font-size(11px, 1.34); + + gap: rem(2); + max-width: 100%; + padding: rem(6) rem(8); + font-family: var(--font-outfit); + font-weight: 400; + color: var(--color-charcoal); + text-align: left; + letter-spacing: -0.031em; + cursor: pointer; + background: none; + border: 1px solid var(--color-sage-light); + border-radius: rem(8); + transition: + border-color 0.25s ease, + background-color 0.25s ease, + opacity 0.25s ease; + + &:hover:not(:disabled, [aria-disabled="true"]) { + background-color: var(--color-net-mid); + } + + &:disabled, + &[aria-disabled="true"] { + cursor: default; + } + + // Unavailable, or waiting on a switch in flight. + &:is(:disabled, [aria-disabled="true"]):not(.optionActive, .optionPending) { + opacity: 0.55; + } +} + +.optionActive { + background-color: var(--color-net-light); + border-color: var(--color-charcoal); +} + +.optionPending { + border-style: dashed; + border-color: var(--color-charcoal); +} + +.optionLabel { + font-weight: 500; +} + +.optionNote { + color: var(--color-stone); +} + +// What a switch changed, and why a switch was refused. +.moved, +.notice { + @include font-size(11px, 1.4); + + margin: rem(10) rem(6) 0; + font-family: var(--font-outfit); + font-weight: 400; + color: var(--color-charcoal); + letter-spacing: -0.031em; +} + +.moved { + @include flex(column, flex-start, stretch); + + gap: rem(4); + padding: 0; + list-style: none; +} + +// Both stay mounted as live regions and take no room when empty. +.moved:empty, +.notice:empty { + margin: 0; + padding: 0; +} + // --- "Start building?" ------------------------------------------------------ // Pinned to the dialog's bottom edge, under the scrolling steps; the diff --git a/src/components/organisms/Studio/workspace/PlanModal.tsx b/src/components/organisms/Studio/workspace/PlanModal.tsx index c673b80..dfb80c9 100644 --- a/src/components/organisms/Studio/workspace/PlanModal.tsx +++ b/src/components/organisms/Studio/workspace/PlanModal.tsx @@ -9,6 +9,16 @@ import type { ChatState } from '../hooks/useChat'; import Button from '@/components/atoms/Button/Button'; import Icon from '@/components/atoms/Icon/Icon'; import Modal from '@/components/molecules/Modal/Modal'; +import { + canSwitch, + isActiveOption, + isSelectable, + movedSentence, + switchOptions, + yearsText, + type PlanState, +} from '@/lib/studio/plan'; +import type { PlanDecision } from '@/lib/studio/wire'; type PlanModalProps = { plan: NonNullable; @@ -16,15 +26,103 @@ type PlanModalProps = { onOpenChange: (open: boolean) => void; onApprove: () => void; onCancel: () => void; + // Switches a scope decision to another option. Left out where the + // viewer cannot change the plan (a guest, a read-only view). + onSwitch?: (choiceId: string, optionId: string) => void; +}; + +/** What the switch that produced this plan changed, said once on the plan + * it re-offered. The live region stays mounted, empty between switches, + * so a screen reader announces the sentences when they arrive. */ +export const PlanMovedNote = ({ plan, className }: { plan: PlanState; className?: string }) => ( +
    + {plan.moved.map((moved) => ( +
  • {movedSentence(moved)}
  • + ))} +
+); + +/** Why the harness refused the last answer to this plan, for the user to + * answer it again. A live region, mounted while empty, as above. */ +export const PlanNotice = ({ plan, className }: { plan: PlanState; className?: string }) => ( +

+ {plan.notice} +

+); + +/** + * One decision: the question, the chosen answer and why. A scope decision + * the plan lets the user switch also lists its options, the active one + * marked; a partial option names the years it covers, an unavailable one + * says why and cannot be picked. While a switch is in flight every option + * ignores clicks but keeps focus (`aria-disabled`). Only unavailable ones + * are natively disabled. + */ +const DecisionRow = ({ + decision, + plan, + onSwitch, +}: { + decision: PlanDecision; + plan: PlanState; + onSwitch?: (choiceId: string, optionId: string) => void; +}) => { + const options = onSwitch && canSwitch(plan) ? switchOptions(decision) : null; + const choiceId = decision.choice_id; + + return ( +
  • + {decision.question} — {decision.chosen} + {decision.reason ? ` (${decision.reason})` : ''} + {options && choiceId && ( +
    + {options.map((option) => { + const active = isActiveOption(decision, option); + const pending = + plan.switching?.choiceId === choiceId && + plan.switching.optionId === option.option_id; + const inert = !isSelectable(decision, option) || !!plan.switching; + const note = + option.status === 'unavailable' + ? option.reason + : option.status === 'partial' && option.years + ? `Covers ${yearsText(option.years)}` + : null; + + return ( + + ); + })} +
    + )} +
  • + ); }; /** * The full plan (design: "Proposed Research Plan – N Steps" modal): every * step on the dot rail with its description unfolded (a chevron folds it), - * the agent's decisions under them, and the same "Start building?" bar - * with Cancel and Approve pinned to the bottom. + * the agent's decisions under them (a scope decision with its options to + * switch between), and the same "Start building?" bar with Cancel and + * Approve pinned to the bottom. */ -const PlanModal = ({ plan, open, onOpenChange, onApprove, onCancel }: PlanModalProps) => { +const PlanModal = ({ plan, open, onOpenChange, onApprove, onCancel, onSwitch }: PlanModalProps) => { // Steps start unfolded — the modal exists to read them. const [folded, setFolded] = useState>(new Set()); @@ -99,21 +197,25 @@ const PlanModal = ({ plan, open, onOpenChange, onApprove, onCancel }: PlanModalP {plan.decisions.length > 0 && (
      {plan.decisions.map((decision) => ( -
    • - {decision.question} — {decision.chosen} - {decision.reason ? ` (${decision.reason})` : ''} -
    • + ))}
    )} + +

    Start building?

    - -
    diff --git a/src/lib/studio/chips.ts b/src/lib/studio/chips.ts new file mode 100644 index 0000000..89def2b --- /dev/null +++ b/src/lib/studio/chips.ts @@ -0,0 +1,95 @@ +/** + * The chips a `suggestions` frame offers after an answer: prompt follow-ups, + * sent as the user's next message, and `apply_choice` chips, which switch a + * scope choice of the answer's readings in one turn. Also what the + * transcript shows when the harness refuses a chip that no longer applies. + */ + +import type { ChatMessage } from '@/components/organisms/Studio/hooks/useChat'; + +import type { StreamRefusal } from './studioClient'; +import type { ApplyChoiceSuggestion, StudioFrame } from './wire'; + +type SuggestionItems = Extract['items']; + +/** The frame's items split by kind: prompt follow-ups (their prompts, as + * sent) and `apply_choice` chips, each in frame order. An item of another + * kind is left out. */ +export const splitSuggestions = ( + items: SuggestionItems, +): { followups: string[]; chips: ApplyChoiceSuggestion[] } => { + const followups: string[] = []; + const chips: ApplyChoiceSuggestion[] = []; + + for (const item of items) { + if ('kind' in item) { + if (item.kind === 'apply_choice') { + chips.push(item); + } + } else { + followups.push(item.prompt); + } + } + + return { followups, chips }; +}; + +/** What tells chips apart: the reading, the choice and the option. */ +export const chipKey = (chip: ApplyChoiceSuggestion): string => + `${chip.spec_id} ${chip.choice_id} ${chip.option_id}`; + +const sameChip = (a: ApplyChoiceSuggestion, b: ApplyChoiceSuggestion): boolean => + chipKey(a) === chipKey(b); + +/** The transcript of a conversation restored without its harness session, + * such as a fork of a share: its chips go, since the harness holds none of + * the answers that offered them. */ +export const withoutChips = (messages: ChatMessage[]): ChatMessage[] => + messages.map((message) => + message.role === 'bot' && message.chips + ? { + role: 'bot', + text: message.text, + ...(message.followups && { followups: message.followups }), + } + : message, + ); + +/** The `detail` of the harness's refusal of a chip (422 with pointer + * `/apply_choice`), or null for any other refusal. */ +export const chipRefusalDetail = (refusal: StreamRefusal): string | null => + refusal.status === 422 && refusal.error?.pointer === '/apply_choice' + ? refusal.error.detail + : null; + +/** + * The transcript after the harness refused `chip`: the reply the click + * opened says why, and carries the answer's other chips and follow-ups, so + * the user can still pick one. `messages` ends with the user's chip label + * and its empty reply; the answer that offered the chip comes before them. + */ +export const withRefusedChip = ( + messages: ChatMessage[], + chip: ApplyChoiceSuggestion, + detail: string, +): ChatMessage[] => { + const reply = messages.at(-1); + const answer = messages.at(-3); + + if (reply?.role !== 'bot') { + return messages; + } + + const offered = answer?.role === 'bot' ? answer : null; + const chips = (offered?.chips ?? []).filter((other) => !sameChip(other, chip)); + + return [ + ...messages.slice(0, -1), + { + ...reply, + text: `_${detail}_`, + ...(offered?.followups?.length && { followups: offered.followups }), + ...(chips.length > 0 && { chips }), + }, + ]; +}; diff --git a/src/lib/studio/plan.ts b/src/lib/studio/plan.ts new file mode 100644 index 0000000..71c9c4b --- /dev/null +++ b/src/lib/studio/plan.ts @@ -0,0 +1,190 @@ +/** + * The plan card's state and its `switch`: how a scope decision's options + * read, which of them the user can pick, the interrupt reply a pick sends, + * and what the card says when the harness refuses one. + */ + +import type { StreamRefusal } from './studioClient'; +import type { + InterruptResponse, + PlanAction, + PlanDecision, + PlanDecisionOption, + PlanMoved, + PlanProposed, + PlanStep, +} from './wire'; + +/** A switch the user asked for and the harness has not answered yet. */ +export type PlanSwitch = { choiceId: string; optionId: string }; + +/** A plan awaiting the user's approve, amend, reject or switch (the + * harness's `plan.proposed` pause). */ +export type PlanState = { + title: string; + steps: PlanStep[]; + decisions: PlanDecision[]; + version: number; + actions: PlanAction[]; + interruptId: string; + // The approval key a switch echoes, null from a harness that predates it. + revision: string | null; + // What the switch that produced this plan changed, empty otherwise. + moved: PlanMoved[]; + switching: PlanSwitch | null; + // Why the harness refused the last switch, for the card to say. + notice: string | null; +}; + +export type SwitchReply = Required< + Pick +>; + +/** The ToolError `constraint_id` of a switch or approve sent against an + * older plan. */ +export const PLAN_REVISION_STALE = 'plan_revision_stale'; + +/** Shown when a stale refusal carries no current plan (a pause the harness + * stored before it kept cards). */ +export const STALE_NOTICE = + 'This plan changed after this page received it, so it can no longer be switched here. Reload the conversation to see the current plan.'; + +export const planFromFrame = (frame: PlanProposed): PlanState => ({ + title: frame.title, + steps: frame.steps, + decisions: frame.decisions ?? [], + version: frame.version, + actions: frame.actions, + interruptId: frame.interrupt_id, + revision: frame.revision ?? null, + moved: frame.moved ?? [], + switching: null, + notice: null, +}); + +/** The plan offers switching: it lists the action and carries the revision + * a switch must echo. */ +export const canSwitch = (plan: PlanState): boolean => + plan.actions.includes('switch') && plan.revision !== null; + +/** A decision's switchable options, or null for an ordinary decision. */ +export const switchOptions = (decision: PlanDecision): PlanDecisionOption[] | null => + decision.choice_id && decision.options && decision.options.length > 0 ? decision.options : null; + +/** The option the plan counts by now: `active_option_id`, else the option + * whose label is the decision's `chosen`. */ +export const isActiveOption = (decision: PlanDecision, option: PlanDecisionOption): boolean => + decision.active_option_id + ? option.option_id === decision.active_option_id + : option.label === decision.chosen; + +/** The option can be switched to: offered as available or partial, and not + * already active. */ +export const isSelectable = (decision: PlanDecision, option: PlanDecisionOption): boolean => + option.status !== 'unavailable' && !isActiveOption(decision, option); + +/** Year ranges as the card prints them: "2015–2019, 2022". */ +export const yearsText = (years: readonly (readonly [number, number])[]): string => + years.map(([first, last]) => (first === last ? `${first}` : `${first}–${last}`)).join(', '); + +export const movedSentence = (moved: PlanMoved): string => + `${moved.from_label} changed to ${moved.to_label}. ${moved.reason}`.trim(); + +/** + * The interrupt reply that switches `choiceId` to `optionId`, or null when + * the plan offers no such switch: switching is off, another switch is in + * flight, or the option is unknown, unavailable or already active. + */ +export const switchReply = ( + plan: PlanState, + choiceId: string, + optionId: string, +): SwitchReply | null => { + if (!canSwitch(plan) || plan.switching || plan.revision === null) { + return null; + } + + const decision = plan.decisions.find((candidate) => candidate.choice_id === choiceId); + const option = decision?.options?.find((candidate) => candidate.option_id === optionId); + + if (!decision || !option || !isSelectable(decision, option)) { + return null; + } + + return { + decision: 'switch', + choice_id: choiceId, + option_id: optionId, + plan_revision: plan.revision, + }; +}; + +/** The plan while its switch is in flight: the card stays, its controls + * wait, and an earlier refusal's notice goes. */ +export const startSwitch = (plan: PlanState, pick: PlanSwitch): PlanState => ({ + ...plan, + switching: pick, + notice: null, +}); + +const isStale = (refusal: StreamRefusal): boolean => + refusal.error?.constraint_id === PLAN_REVISION_STALE; + +/** The constraint_id of a switch the harness refused because the plan does + * not offer the option. */ +export const SWITCH_OPTION_UNAVAILABLE = 'switch_option_unavailable'; + +/** The card a stale switch or approve is answered with, the harness's + * current plan, saying why it replaced the one the user answered. Null for + * any other refusal, or a stale one without a current plan. */ +export const stalePlan = (refusal: StreamRefusal): PlanState | null => { + const current = isStale(refusal) ? refusal.error?.current_plan : null; + + return current ? { ...planFromFrame(current), notice: refusal.detail } : null; +}; + +/** + * The card after the harness refused its switch, saying why, or null when + * the refusal is not one the harness gives a switch. Those are a 409 + * `plan_revision_stale` and a 422 `switch_option_unavailable`; any other + * status (a 404 for an interrupt that is gone, 401, 429, 5xx) is a failed + * request, and the pause it answered is over. + * + * A stale revision brings the current plan, and switching works again on + * it. Without one the card keeps its revision, so an approve from it is + * refused as stale, and drops the switch action. + */ +export const refusedSwitch = (plan: PlanState, refusal: StreamRefusal): PlanState | null => { + const constraint = refusal.error?.constraint_id; + + if (refusal.status === 409 && constraint === PLAN_REVISION_STALE) { + return ( + stalePlan(refusal) ?? { + ...plan, + actions: plan.actions.filter((action) => action !== 'switch'), + switching: null, + notice: STALE_NOTICE, + } + ); + } + + if (refusal.status === 422 && constraint === SWITCH_OPTION_UNAVAILABLE) { + return { ...plan, switching: null, notice: refusal.detail }; + } + + return null; +}; + +/** An `approve` of `plan`, carrying its revision so the harness can tell an + * approval from an out-of-date card. */ +export const approveReply = ( + plan: PlanState | undefined, +): Pick => + plan?.revision + ? { decision: 'approve', plan_revision: plan.revision } + : { decision: 'approve' }; + +/** A plan whose switch ended without a fresh plan (an error, a stop, a turn + * that finished) leaves the card. Any other plan stays. */ +export const settledPlan = (plan: PlanState | null): PlanState | null => + plan?.switching ? null : plan; diff --git a/src/lib/studio/promptAllowance.ts b/src/lib/studio/promptAllowance.ts index 811eb26..545905c 100644 --- a/src/lib/studio/promptAllowance.ts +++ b/src/lib/studio/promptAllowance.ts @@ -10,3 +10,45 @@ export const GUEST_PROMPTS = 5; // Prompts a day for a signed-in user, shared by every account on one inbox. export const USER_DAILY_PROMPTS = 75; + +/** What a Studio prompt asks: the first question, a follow-up, an edit of a + * card, or a clicked `apply_choice` chip. */ +export type PromptIntent = 'new' | 'follow_up' | 'edit' | 'apply_choice'; + +export type PromptEvent = { + turn: number; + intent: PromptIntent; + length?: number; + guest: boolean; +}; + +/** + * The `studio.prompt` event for a prompt about to be sent, or null when a + * guest has no free prompt left for it. `used` is the guest's prompts spent + * so far, `queued` the prompts waiting behind the running turn (they hold + * their place in the limit), and `sent` the prompts in the transcript. + * `length` is the typed text's length; a chip has none. + */ +export const promptEvent = ({ + guest, + used, + queued, + sent, + intent, + length, +}: { + guest: boolean; + used: number; + queued: number; + sent: number; + intent?: PromptIntent; + length?: number; +}): PromptEvent | null => + guest && used + queued >= GUEST_PROMPTS + ? null + : { + turn: sent + queued + 1, + intent: intent ?? (sent === 0 ? 'new' : 'follow_up'), + ...(length !== undefined && { length }), + guest, + }; diff --git a/src/lib/studio/studioClient.ts b/src/lib/studio/studioClient.ts index e39604a..549d43d 100644 --- a/src/lib/studio/studioClient.ts +++ b/src/lib/studio/studioClient.ts @@ -10,11 +10,13 @@ */ import type { + ApplyChoice, CancelRequest, CompositionManifest, CompositionMutation, InterruptResponse, MutationOperation, + PlanRevisionStaleError, StudioFrame, ToolError, TurnRequest, @@ -37,16 +39,36 @@ const transportError = (detail: string): StudioFrame => ({ } as ToolError, }); -/** What a stream reports to its caller. */ +/** An HTTP refusal of a stream request: its status, the body's `detail` + * when it has one, and the harness's ToolError when the body is one + * (with `current_plan` on a stale plan answer). */ +export type StreamRefusal = { + status: number; + detail: string | null; + error: PlanRevisionStaleError | null; +}; + +/** What a stream reports to its caller. `onRefusal` sees an HTTP refusal + * before the stream would report it as an `error` frame, and returns true + * when it has dealt with it. */ export type StreamHandlers = { onFrame: (frame: StudioFrame) => void; onKeepAlive?: () => void; + onRefusal?: (refusal: StreamRefusal) => boolean; }; +const asToolError = (body: unknown): PlanRevisionStaleError | null => + body !== null && + typeof body === 'object' && + typeof (body as ToolError).error === 'string' && + typeof (body as ToolError).detail === 'string' + ? (body as ToolError) + : null; + const streamStudio = async ( endpoint: 'turn' | 'interrupt', body: TurnRequest | InterruptResponse, - { onFrame, onKeepAlive }: StreamHandlers, + { onFrame, onKeepAlive, onRefusal }: StreamHandlers, signal?: AbortSignal, ): Promise => { let res: Response; @@ -69,11 +91,14 @@ const streamStudio = async ( } if (!res.ok || !res.body) { - let detail = `HTTP ${res.status}`; + let detail: string | null = null; + let error: PlanRevisionStaleError | null = null; try { const parsed = (await res.json()) as { detail?: unknown }; + error = asToolError(parsed); + if (typeof parsed.detail === 'string') { detail = parsed.detail; } @@ -81,7 +106,11 @@ const streamStudio = async ( // Non-JSON body — the status line stands. } - onFrame(transportError(detail)); + if (!res.ok && onRefusal?.({ status: res.status, detail, error })) { + return; + } + + onFrame(transportError(detail ?? `HTTP ${res.status}`)); return; } @@ -158,11 +187,34 @@ export const runTurn = ( signal, ); +/** Run a turn from a clicked `apply_choice` chip: the chip's choice, option, + * turn and reading, copied as offered, and no message. */ +export const runApplyChoice = ( + conversationId: string, + { choice_id, option_id, turn, spec_id }: ApplyChoice, + handlers: StreamHandlers, + signal?: AbortSignal, +): Promise => + streamStudio( + 'turn', + { + kind: 'turn.request', + schema_version: 1, + conversation_id: conversationId, + apply_choice: { choice_id, option_id, turn, spec_id }, + }, + handlers, + signal, + ); + /** Answer a pause; the response is a NEW stream continuing the same turn. */ export const runInterrupt = ( conversationId: string, interruptId: string, - reply: Pick, + reply: Pick< + InterruptResponse, + 'decision' | 'payload' | 'answers' | 'choice_id' | 'option_id' | 'plan_revision' + >, handlers: StreamHandlers, signal?: AbortSignal, ): Promise => diff --git a/src/lib/studio/test/chips.test.ts b/src/lib/studio/test/chips.test.ts new file mode 100644 index 0000000..c8a4c92 --- /dev/null +++ b/src/lib/studio/test/chips.test.ts @@ -0,0 +1,150 @@ +/** + * The chips after an answer: a `suggestions` frame split into prompt + * follow-ups and `apply_choice` chips, and the transcript after the harness + * refuses a chip. + */ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; + +import { + chipKey, + chipRefusalDetail, + splitSuggestions, + withoutChips, + withRefusedChip, +} from '../chips.ts'; +import type { ChatMessage } from '../../../components/organisms/Studio/hooks/useChat.ts'; +import type { StudioFrame } from '../wire.ts'; + +type Suggestions = Extract; + +/** The harness's wire fixture (`services/harness/tests/wire_fixtures/studio` + * in dfo-ai), copied. */ +const frame = JSON.parse( + readFileSync( + new URL('./fixtures/event-15-suggestions-apply-choice.json', import.meta.url), + 'utf8', + ), +) as Suggestions; + +const [sector, region] = splitSuggestions(frame.items).chips; + +test('splitSuggestions: the fixture frame is two chips in frame order, no follow-ups', () => { + assert.deepEqual(splitSuggestions(frame.items), { followups: [], chips: frame.items }); +}); + +test('splitSuggestions: each chip keeps the turn and spec_id it was offered with', () => { + const { chips } = splitSuggestions(frame.items); + + assert.deepEqual( + chips.map(({ turn, spec_id }) => ({ turn, spec_id })), + frame.items.map((item) => ({ + turn: 'turn' in item ? item.turn : undefined, + spec_id: 'spec_id' in item ? item.spec_id : undefined, + })), + ); + assert.ok(chips.every((chip) => chip.turn >= 1 && chip.spec_id.startsWith('spec_'))); +}); + +test('splitSuggestions: prompt follow-ups keep their prompts beside the chips', () => { + const prompt = { label: 'By donor', prompt: 'Break this down by donor' }; + + assert.deepEqual(splitSuggestions([prompt, ...frame.items]), { + followups: ['Break this down by donor'], + chips: frame.items, + }); +}); + +test('splitSuggestions: an item of another kind is neither a chip nor a follow-up', () => { + const other = { kind: 'open_card', label: 'Open the card', prompt: 'Open it' }; + + assert.deepEqual(splitSuggestions([other as unknown as Suggestions['items'][number]]), { + followups: [], + chips: [], + }); +}); + +const toolError = (pointer: string | null) => ({ + error: 'constraint_violation' as const, + retry: 'do_not_retry' as const, + detail: 'This answer has changed since that suggestion was offered, so nothing changed.', + constraint_id: 'scope_choice_stale', + pointer, +}); + +test('chipRefusalDetail: a 422 pointing at /apply_choice gives its detail; others give null', () => { + const chip = toolError('/apply_choice'); + + assert.equal(chipRefusalDetail({ status: 422, detail: chip.detail, error: chip }), chip.detail); + assert.equal( + chipRefusalDetail({ status: 422, detail: 'x', error: toolError('/message') }), + null, + ); + assert.equal(chipRefusalDetail({ status: 502, detail: 'proxy failed', error: null }), null); +}); + +test('withRefusedChip: the reply says why and keeps the answer’s other chips and follow-ups', () => { + assert.ok(sector && region); + + const messages: ChatMessage[] = [ + { role: 'user', text: 'Aid to Africa for health' }, + { role: 'bot', text: 'USD 4.1bn', followups: ['By donor'], chips: [sector, region] }, + { role: 'user', text: sector.label }, + { role: 'bot', text: '' }, + ]; + + assert.deepEqual(withRefusedChip(messages, sector, 'Nothing changed.'), [ + ...messages.slice(0, 3), + { role: 'bot', text: '_Nothing changed._', followups: ['By donor'], chips: [region] }, + ]); +}); + +test('withRefusedChip: the last chip refused leaves a reply with no chips', () => { + assert.ok(sector); + + const messages: ChatMessage[] = [ + { role: 'bot', text: 'USD 4.1bn', chips: [sector] }, + { role: 'user', text: sector.label }, + { role: 'bot', text: '' }, + ]; + + assert.deepEqual(withRefusedChip(messages, sector, 'Nothing changed.').at(-1), { + role: 'bot', + text: '_Nothing changed._', + }); +}); + +test('withRefusedChip: a chip for another reading of the same choice and option stays', () => { + assert.ok(sector); + + const other = { ...sector, spec_id: `${sector.spec_id}_other` }; + const messages: ChatMessage[] = [ + { role: 'bot', text: 'USD 4.1bn', chips: [sector, other] }, + { role: 'user', text: sector.label }, + { role: 'bot', text: '' }, + ]; + + assert.notEqual(chipKey(sector), chipKey(other)); + assert.deepEqual(withRefusedChip(messages, sector, 'Nothing changed.').at(-1), { + role: 'bot', + text: '_Nothing changed._', + chips: [other], + }); +}); + +test('withoutChips: a restored transcript keeps its follow-ups and loses its chips', () => { + assert.ok(sector); + + const messages: ChatMessage[] = [ + { role: 'user', text: 'Aid to Africa for health' }, + { role: 'bot', text: 'USD 4.1bn', followups: ['By donor'], chips: [sector] }, + { role: 'bot', text: 'Done', chips: [sector] }, + ]; + + assert.deepEqual(withoutChips(messages), [ + { role: 'user', text: 'Aid to Africa for health' }, + { role: 'bot', text: 'USD 4.1bn', followups: ['By donor'] }, + { role: 'bot', text: 'Done' }, + ]); +}); diff --git a/src/lib/studio/test/fixtures/event-15-suggestions-apply-choice.json b/src/lib/studio/test/fixtures/event-15-suggestions-apply-choice.json new file mode 100644 index 0000000..11dacc1 --- /dev/null +++ b/src/lib/studio/test/fixtures/event-15-suggestions-apply-choice.json @@ -0,0 +1,22 @@ +{ + "event": "suggestions", + "seq": 9, + "items": [ + { + "kind": "apply_choice", + "label": "health, CRS 120 alone", + "choice_id": "spec_6176d83382fca6c596e00cb242a06803:sector", + "option_id": "sector:120", + "turn": 2, + "spec_id": "spec_0b7d6c7e1f2a4b5c8d9e0f1a2b3c4d5e" + }, + { + "kind": "apply_choice", + "label": "Africa's countries only", + "choice_id": "spec_6176d83382fca6c596e00cb242a06803:region", + "option_id": "region:members_only", + "turn": 2, + "spec_id": "spec_0b7d6c7e1f2a4b5c8d9e0f1a2b3c4d5e" + } + ] +} diff --git a/src/lib/studio/test/fixtures/request-13-turn-apply-choice.json b/src/lib/studio/test/fixtures/request-13-turn-apply-choice.json new file mode 100644 index 0000000..9024428 --- /dev/null +++ b/src/lib/studio/test/fixtures/request-13-turn-apply-choice.json @@ -0,0 +1,11 @@ +{ + "kind": "turn.request", + "schema_version": 1, + "session_id": "sess_01j9zc0000", + "apply_choice": { + "choice_id": "spec_6176d83382fca6c596e00cb242a06803:sector", + "option_id": "sector:120", + "turn": 2, + "spec_id": "spec_0b7d6c7e1f2a4b5c8d9e0f1a2b3c4d5e" + } +} diff --git a/src/lib/studio/test/plan.test.ts b/src/lib/studio/test/plan.test.ts new file mode 100644 index 0000000..49e4e14 --- /dev/null +++ b/src/lib/studio/test/plan.test.ts @@ -0,0 +1,358 @@ +/** + * The plan card's switch: the reply a pick sends, the picks the card + * refuses before sending, and the card after a refusal or a fresh plan. + */ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + PLAN_REVISION_STALE, + approveReply, + canSwitch, + STALE_NOTICE, + SWITCH_OPTION_UNAVAILABLE, + isActiveOption, + isSelectable, + movedSentence, + planFromFrame, + refusedSwitch, + settledPlan, + stalePlan, + startSwitch, + switchReply, + yearsText, +} from '../plan.ts'; +import type { StreamRefusal } from '../studioClient.ts'; +import type { PlanDecision, PlanProposed } from '../wire.ts'; + +const countDecision: PlanDecision = { + id: 'd1', + question: 'How is aid counted?', + chosen: 'Gross disbursements', + reason: 'the source publishes this basis', + resolved_by: 'default', + choice_id: 'spec_1:measure_basis', + active_option_id: 'measure_basis:gross', + options: [ + { + option_id: 'measure_basis:gross', + label: 'Gross disbursements', + status: 'available', + reason: null, + years: null, + }, + { + option_id: 'measure_basis:net', + label: 'Net disbursements', + status: 'partial', + reason: null, + years: [ + [2015, 2019], + [2022, 2022], + ], + }, + { + option_id: 'measure_basis:grant_equivalent', + label: 'Grant equivalent', + status: 'unavailable', + reason: 'The source does not publish this basis for these donors.', + years: null, + }, + ], +}; + +const frame = (overrides: Partial = {}): PlanProposed => ({ + event: 'plan.proposed', + seq: 4, + interrupt_id: 'int-1', + plan_version_id: 'pv-1', + version: 1, + title: 'Health aid to Ghana', + steps: [{ id: 's1', label: 'Retrieve health aid' }], + actions: ['approve', 'amend', 'reject', 'switch'], + decisions: [countDecision, { ...countDecision, id: 'd2', choice_id: null, options: null }], + revision: 'rev-a', + ...overrides, +}); + +test('planFromFrame keeps the revision and what moved', () => { + const moved = [ + { + choice_id: 'spec_1:measure_basis', + from_label: 'Gross disbursements', + to_label: 'Net disbursements', + reason: 'Repayments are now subtracted.', + }, + ]; + const plan = planFromFrame(frame({ revision: 'rev-b', moved })); + + assert.equal(plan.revision, 'rev-b'); + assert.deepEqual(plan.moved, moved); + assert.equal(plan.switching, null); + assert.equal(plan.notice, null); +}); + +test('planFromFrame reads a harness without revision or moved as none', () => { + const plan = planFromFrame(frame({ revision: undefined, moved: undefined })); + + assert.equal(plan.revision, null); + assert.deepEqual(plan.moved, []); +}); + +test('switchReply sends the choice, the option and the revision the user saw', () => { + const reply = switchReply(planFromFrame(frame()), 'spec_1:measure_basis', 'measure_basis:net'); + + assert.deepEqual(reply, { + decision: 'switch', + choice_id: 'spec_1:measure_basis', + option_id: 'measure_basis:net', + plan_revision: 'rev-a', + }); +}); + +test('switchReply refuses a pick the plan does not offer', async (t) => { + const plan = planFromFrame(frame()); + const cases = [ + ['an unavailable option', plan, 'measure_basis:grant_equivalent'], + ['the active option', plan, 'measure_basis:gross'], + ['an unknown option', plan, 'measure_basis:commitments'], + [ + 'a plan that does not list switch', + planFromFrame(frame({ actions: ['approve', 'amend', 'reject'] })), + 'measure_basis:net', + ], + [ + 'a plan without a revision', + planFromFrame(frame({ revision: null })), + 'measure_basis:net', + ], + [ + 'a switch already in flight', + startSwitch(plan, { choiceId: 'spec_1:measure_basis', optionId: 'measure_basis:net' }), + 'measure_basis:net', + ], + ] as const; + + for (const [name, candidate, optionId] of cases) { + await t.test(name, () => { + assert.equal(switchReply(candidate, 'spec_1:measure_basis', optionId), null); + }); + } +}); + +test('the active option is the chosen one, and only other offered options are selectable', () => { + const [gross, net, grant] = countDecision.options!; + + assert.deepEqual( + [gross!, net!, grant!].map((option) => [ + isActiveOption(countDecision, option), + isSelectable(countDecision, option), + ]), + [ + [true, false], + [false, true], + [false, false], + ], + ); +}); + +test('active_option_id marks the active option over a label that matches another', () => { + const [gross, net] = countDecision.options!; + const decision = { ...countDecision, active_option_id: 'measure_basis:net' }; + + assert.equal(isActiveOption(decision, net!), true); + assert.equal(isActiveOption(decision, gross!), false); +}); + +test('without active_option_id the chosen label marks the active option', () => { + const [gross, net] = countDecision.options!; + const decision = { ...countDecision, active_option_id: undefined }; + + assert.equal(isActiveOption(decision, gross!), true); + assert.equal(isActiveOption(decision, net!), false); +}); + +const STALE_DETAIL = 'This plan changed in another window. Here is the current plan.'; + +/** A 409 `plan_revision_stale`, with the harness's current card when given. */ +const staleRefusal = (currentPlan?: PlanProposed): StreamRefusal => ({ + status: 409, + detail: STALE_DETAIL, + error: { + error: 'edit_conflict', + retry: 'refresh_then_retry', + detail: STALE_DETAIL, + constraint_id: PLAN_REVISION_STALE, + ...(currentPlan && { current_plan: currentPlan }), + }, +}); + +const currentFrame = frame({ seq: 9, interrupt_id: 'int-2', version: 2, revision: 'rev-b' }); + +test('a stale switch is answered with the current card, which switches again', () => { + const waiting = startSwitch(planFromFrame(frame()), { + choiceId: 'spec_1:measure_basis', + optionId: 'measure_basis:net', + }); + const replaced = refusedSwitch(waiting, staleRefusal(currentFrame)); + + assert.deepEqual(replaced, { ...planFromFrame(currentFrame), notice: STALE_DETAIL }); + assert.deepEqual(switchReply(replaced, 'spec_1:measure_basis', 'measure_basis:net'), { + decision: 'switch', + choice_id: 'spec_1:measure_basis', + option_id: 'measure_basis:net', + plan_revision: 'rev-b', + }); +}); + +test('a stale approve is answered with the current card; other refusals are not', () => { + assert.deepEqual(stalePlan(staleRefusal(currentFrame)), { + ...planFromFrame(currentFrame), + notice: STALE_DETAIL, + }); + assert.equal(stalePlan(staleRefusal()), null); + assert.equal( + stalePlan({ + status: 422, + detail: 'decision approve does not take a payload', + error: null, + }), + null, + ); +}); + +test('an approve carries the revision of the card the user approved', () => { + assert.deepEqual(approveReply(planFromFrame(frame())), { + decision: 'approve', + plan_revision: 'rev-a', + }); + assert.deepEqual(approveReply(planFromFrame(frame({ revision: null }))), { + decision: 'approve', + }); +}); + +test('a stale switch without a current card keeps its revision, so an approve is refused', () => { + const plan = startSwitch(planFromFrame(frame()), { + choiceId: 'spec_1:measure_basis', + optionId: 'measure_basis:net', + }); + const refused = refusedSwitch(plan, staleRefusal()); + + assert.ok(refused); + assert.equal(refused.switching, null); + assert.equal(refused.notice, STALE_NOTICE); + assert.equal(canSwitch(refused), false); + assert.equal(switchReply(refused, 'spec_1:measure_basis', 'measure_basis:net'), null); + assert.deepEqual(approveReply(refused), { decision: 'approve', plan_revision: 'rev-a' }); +}); + +test('an unavailable option keeps the card and shows the harness reason', () => { + const plan = startSwitch(planFromFrame(frame()), { + choiceId: 'spec_1:measure_basis', + optionId: 'measure_basis:net', + }); + const refused = refusedSwitch(plan, { + status: 422, + detail: 'Net disbursements cannot be counted for this plan.', + error: { + error: 'constraint_violation', + retry: 'correct_and_retry', + detail: 'Net disbursements cannot be counted for this plan.', + constraint_id: SWITCH_OPTION_UNAVAILABLE, + }, + }); + + assert.ok(refused); + assert.equal(refused.switching, null); + assert.equal(refused.notice, 'Net disbursements cannot be counted for this plan.'); + assert.equal(refused.revision, 'rev-a'); + assert.equal(canSwitch(refused), true); +}); + +test('a refusal the harness does not give a switch is a failed request, with no card', async (t) => { + const plan = startSwitch(planFromFrame(frame()), { + choiceId: 'spec_1:measure_basis', + optionId: 'measure_basis:net', + }); + const toolError = (error: string, constraint_id?: string) => ({ + error: error as 'not_found', + retry: 'do_not_retry' as const, + detail: error, + ...(constraint_id && { constraint_id }), + }); + const cases: [string, StreamRefusal][] = [ + [ + 'a 404 for an interrupt that is gone', + { status: 404, detail: 'gone', error: toolError('not_found') }, + ], + ['a 401', { status: 401, detail: 'Sign in again.', error: null }], + ['a 429', { status: 429, detail: null, error: null }], + ['a 502', { status: 502, detail: null, error: null }], + [ + 'a 409 transient_unavailable', + { status: 409, detail: 'busy', error: toolError('transient_unavailable') }, + ], + [ + 'a 422 binding_invalid', + { status: 422, detail: 'bad', error: toolError('binding_invalid') }, + ], + [ + 'a stale constraint on a 422', + { status: 422, detail: 'x', error: toolError('edit_conflict', PLAN_REVISION_STALE) }, + ], + ]; + + for (const [name, refusal] of cases) { + await t.test(name, () => { + assert.equal(refusedSwitch(plan, refusal), null); + }); + } +}); + +test('a fresh plan after a switch replaces the waiting card with what moved', () => { + const waiting = startSwitch(planFromFrame(frame()), { + choiceId: 'spec_1:measure_basis', + optionId: 'measure_basis:net', + }); + const fresh = planFromFrame( + frame({ + interrupt_id: 'int-2', + version: 2, + revision: 'rev-b', + moved: [ + { + choice_id: 'spec_1:measure_basis', + from_label: 'Gross disbursements', + to_label: 'Net disbursements', + reason: 'Repayments are now subtracted.', + }, + ], + }), + ); + + assert.equal(waiting.switching?.optionId, 'measure_basis:net'); + assert.equal(fresh.switching, null); + assert.deepEqual(fresh.moved.map(movedSentence), [ + 'Gross disbursements changed to Net disbursements. Repayments are now subtracted.', + ]); + assert.equal( + switchReply(fresh, 'spec_1:measure_basis', 'measure_basis:net')?.plan_revision, + 'rev-b', + ); +}); + +test('a switch that ends without a fresh plan drops the card; other plans stay', () => { + const plan = planFromFrame(frame()); + const waiting = startSwitch(plan, { + choiceId: 'spec_1:measure_basis', + optionId: 'measure_basis:net', + }); + + assert.equal(settledPlan(waiting), null); + assert.equal(settledPlan(plan), plan); + assert.equal(settledPlan(null), null); +}); + +test('yearsText joins ranges and prints a one-year range once', () => { + assert.equal(yearsText(countDecision.options![1]!.years!), '2015–2019, 2022'); +}); diff --git a/src/lib/studio/test/promptAllowance.test.ts b/src/lib/studio/test/promptAllowance.test.ts new file mode 100644 index 0000000..9246474 --- /dev/null +++ b/src/lib/studio/test/promptAllowance.test.ts @@ -0,0 +1,47 @@ +/** + * The `studio.prompt` event and the guest limit every Studio prompt passes: + * typed text and clicked chips alike. + */ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { GUEST_PROMPTS, promptEvent } from '../promptAllowance.ts'; + +test('promptEvent: a guest with no free prompt left sends nothing, queued prompts included', () => { + assert.equal(promptEvent({ guest: true, used: GUEST_PROMPTS, queued: 0, sent: 3 }), null); + assert.equal( + promptEvent({ + guest: true, + used: GUEST_PROMPTS - 1, + queued: 1, + sent: 3, + intent: 'apply_choice', + }), + null, + ); +}); + +test('promptEvent: a signed-in user is never walled', () => { + assert.deepEqual(promptEvent({ guest: false, used: 99, queued: 2, sent: 7, length: 12 }), { + turn: 10, + intent: 'follow_up', + length: 12, + guest: false, + }); +}); + +test('promptEvent: the first prompt is new, a later one a follow-up, unless an intent is given', () => { + assert.equal(promptEvent({ guest: true, used: 0, queued: 0, sent: 0 })?.intent, 'new'); + assert.equal(promptEvent({ guest: true, used: 1, queued: 0, sent: 1 })?.intent, 'follow_up'); + assert.equal( + promptEvent({ guest: true, used: 1, queued: 0, sent: 1, intent: 'edit' })?.intent, + 'edit', + ); +}); + +test('promptEvent: a chip counts as a turn, with its intent and no length', () => { + assert.deepEqual( + promptEvent({ guest: true, used: 1, queued: 1, sent: 1, intent: 'apply_choice' }), + { turn: 3, intent: 'apply_choice', guest: true }, + ); +}); diff --git a/src/lib/studio/test/studioClient.test.ts b/src/lib/studio/test/studioClient.test.ts index 6c87c4c..3ac6d67 100644 --- a/src/lib/studio/test/studioClient.test.ts +++ b/src/lib/studio/test/studioClient.test.ts @@ -3,9 +3,17 @@ * reason when the read fails. */ import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; import test from 'node:test'; -import { cancelTurn, fetchComponentRevision, runTurn } from '../studioClient.ts'; +import { + cancelTurn, + fetchComponentRevision, + runApplyChoice, + runInterrupt, + runTurn, +} from '../studioClient.ts'; +import type { ApplyChoiceSuggestion, StudioFrame } from '../wire.ts'; test('fetchComponentRevision: a refused read is null and logs the ids and status', async (t) => { t.mock.method(globalThis, 'fetch', async () => new Response('', { status: 502 })); @@ -77,3 +85,165 @@ test('cancelTurn: a cancel request that never answers settles once the bound pas assert.equal(outcome, 'settled'); }); + +const switchReply = { + decision: 'switch', + choice_id: 'spec_1:measure_basis', + option_id: 'measure_basis:net', + plan_revision: 'rev-a', +} as const; + +const staleBody = { + error: 'edit_conflict', + retry: 'refresh_then_retry', + detail: 'plan revision rev-a is not the pending plan', + constraint_id: 'plan_revision_stale', +}; + +test('runInterrupt: a switch posts exactly the interrupt members and the switch fields', async (t) => { + const fetch = t.mock.method(globalThis, 'fetch', async () => new Response('')); + + await runInterrupt('conv', 'int-1', switchReply, { onFrame: () => {} }); + + const init = fetch.mock.calls[0]?.arguments[1] as RequestInit; + + assert.deepEqual(JSON.parse(init.body as string), { + kind: 'interrupt.response', + schema_version: 1, + conversation_id: 'conv', + interrupt_id: 'int-1', + ...switchReply, + }); +}); + +test('runInterrupt: a refusal reaches onRefusal with its status and ToolError, and no error frame', async (t) => { + t.mock.method(globalThis, 'fetch', async () => Response.json(staleBody, { status: 409 })); + const frames: StudioFrame[] = []; + const refusals: unknown[] = []; + + await runInterrupt('conv', 'int-1', switchReply, { + onFrame: (frame) => frames.push(frame), + onRefusal: (refusal) => { + refusals.push(refusal); + + return true; + }, + }); + + assert.deepEqual(refusals, [{ status: 409, detail: staleBody.detail, error: staleBody }]); + assert.deepEqual(frames, []); +}); + +test('runInterrupt: a refusal onRefusal leaves is an error frame with its detail', async (t) => { + t.mock.method(globalThis, 'fetch', async () => + Response.json({ detail: 'proxy failed' }, { status: 502 }), + ); + const frames: StudioFrame[] = []; + const refusals: unknown[] = []; + + await runInterrupt('conv', 'int-1', switchReply, { + onFrame: (frame) => frames.push(frame), + onRefusal: (refusal) => { + refusals.push(refusal); + + return false; + }, + }); + + assert.deepEqual(refusals, [{ status: 502, detail: 'proxy failed', error: null }]); + assert.equal(frames.length, 1); + assert.equal(frames[0]?.event === 'error' && frames[0].error.detail, 'proxy failed'); +}); + +test('runInterrupt: a stale refusal hands onRefusal the current plan as sent', async (t) => { + const currentPlan = { + event: 'plan.proposed', + seq: 9, + interrupt_id: 'int-2', + plan_version_id: 'pv-2', + version: 2, + title: 'Health aid to Ghana', + steps: [], + actions: ['approve', 'amend', 'reject'], + revision: 'rev-b', + }; + + t.mock.method(globalThis, 'fetch', async () => + Response.json({ ...staleBody, current_plan: currentPlan }, { status: 409 }), + ); + let seen: unknown = null; + + await runInterrupt( + 'conv', + 'int-1', + { decision: 'approve', plan_revision: 'rev-a' }, + { + onFrame: () => {}, + onRefusal: (refusal) => { + seen = refusal.error?.current_plan; + + return true; + }, + }, + ); + + assert.deepEqual(seen, currentPlan); +}); + +test('runApplyChoice: a chip posts the harness fixture request, with no message', async (t) => { + const fetch = t.mock.method(globalThis, 'fetch', async () => new Response('')); + // The harness's wire fixtures (`services/harness/tests/wire_fixtures/studio` + // in dfo-ai), copied. The request names the conversation by the + // `session_id` alias the harness also accepts. + const read = (name: string): unknown => + JSON.parse(readFileSync(new URL(`./fixtures/${name}`, import.meta.url), 'utf8')); + const { session_id: conversation, ...request } = read( + 'request-13-turn-apply-choice.json', + ) as Record & { session_id: string }; + const { items } = read('event-15-suggestions-apply-choice.json') as { + items: ApplyChoiceSuggestion[]; + }; + const chip = items[0]; + + assert.ok(chip); + await runApplyChoice(conversation, chip, { onFrame: () => {} }); + + const init = fetch.mock.calls[0]?.arguments[1] as RequestInit; + + assert.equal(fetch.mock.calls[0]?.arguments[0], '/api/studio/turn'); + const body = JSON.parse(init.body as string) as { apply_choice: Record }; + + assert.deepEqual(body, { ...request, conversation_id: conversation }); + assert.equal(body.apply_choice.turn, chip.turn); + assert.equal(body.apply_choice.spec_id, chip.spec_id); +}); + +test('runApplyChoice: a 422 chip refusal reaches onRefusal, and no error frame', async (t) => { + const body = { + error: 'constraint_violation', + retry: 'do_not_retry', + detail: 'That option is not offered here.', + constraint_id: 'switch_option_unavailable', + pointer: '/apply_choice', + }; + + t.mock.method(globalThis, 'fetch', async () => Response.json(body, { status: 422 })); + const frames: StudioFrame[] = []; + const refusals: unknown[] = []; + + await runApplyChoice( + 'conv', + { choice_id: 'spec_1:sector', option_id: 'sector:120', turn: 1, spec_id: 'spec_1' }, + { + onFrame: (frame) => frames.push(frame), + onRefusal: (refusal) => { + refusals.push(refusal); + + return true; + }, + }, + ); + + assert.deepEqual(refusals, [{ status: 422, detail: body.detail, error: body }]); + assert.deepEqual(frames, []); +}); diff --git a/src/lib/studio/wire.ts b/src/lib/studio/wire.ts index 5ffd7a8..8c90c19 100644 --- a/src/lib/studio/wire.ts +++ b/src/lib/studio/wire.ts @@ -89,6 +89,12 @@ export type ToolError = { retry_after_seconds?: number | null; }; +/** The 409 `plan_revision_stale` body of POST /api/studio/interrupt: a + * ToolError plus `current_plan`, the `plan.proposed` frame of the plan now + * awaiting an answer, exactly as the stream sent it (`seq` included). Absent + * only for a pause stored before the harness kept cards. */ +export type PlanRevisionStaleError = ToolError & { current_plan?: PlanProposed | null }; + export type ComponentType = | 'stat_card' | 'line_chart' @@ -205,11 +211,23 @@ export type Seed = { operations: SeedOperation[]; }; +/** A clicked `ApplyChoiceSuggestion`, echoed whole: the scope choice of a + * stored reading, the option it moves to, the conversation turn whose answer + * offered the chip (`turn`, from 1) and the reading it switches (`spec_id`). */ +export type ApplyChoice = { + choice_id: string; + option_id: string; + turn: number; + spec_id: string; +}; + export type TurnRequest = { kind: 'turn.request'; schema_version: 1; conversation_id: string; - message: string; + /** Absent only beside `apply_choice`. The harness reads an absent + * message as "". */ + message?: string; surface?: 'studio' | 'download' | 'embed'; component_ref?: ComponentRef | null; /** The Search results a "… in Studio" action hands over, one per @@ -217,9 +235,17 @@ export type TurnRequest = { * planning and plans from those datasets and their operations. */ seed_spec?: Seed[] | null; auto_add?: boolean; + /** A clicked chip. The turn applies the option to the reading the + * answer used, with no search. It comes without `message`, `seed_spec` + * or `component_ref`. A chip that no longer applies is refused 422 + * `constraint_violation` with `pointer: "/apply_choice"` + * (`scope_choice_stale`, when a later turn has answered or a later + * reading holds the choice, or `switch_option_unavailable`) and a + * `detail` to show. */ + apply_choice?: ApplyChoice | null; }; -export type InterruptDecision = 'approve' | 'amend' | 'reject' | 'answer' | 'skip'; +export type InterruptDecision = 'approve' | 'amend' | 'reject' | 'switch' | 'answer' | 'skip'; export type InterruptResponse = { kind: 'interrupt.response'; @@ -234,6 +260,15 @@ export type InterruptResponse = { // One answer per question of an `elicitation.request`, keyed by the // frame's question ids exactly. Sent with `answer` only. answers?: Record; + // A plan's `switch`: the decision's `choice_id`, one of its options' + // `option_id`, and the `revision` of the plan the user saw. `approve` + // sends `plan_revision` alone. The harness refuses a stale revision with + // 409 `plan_revision_stale` (a `PlanRevisionStaleError`) and an option it + // did not offer, or offered as unavailable, with 422 + // `switch_option_unavailable`. + choice_id?: string; + option_id?: string; + plan_revision?: string; }; export type ElicitationAnswer = @@ -269,6 +304,17 @@ export type CancelRequest = { export type PlanStep = { id: string; label: string; description?: string | null }; +/** One way a scope decision can be counted. `partial` options cover only + * `years` (inclusive `[first, last]` ranges); `unavailable` ones say why in + * `reason`, reader copy written by the harness. */ +export type PlanDecisionOption = { + option_id: string; + label: string; + status: 'available' | 'partial' | 'unavailable'; + reason: string | null; + years: [number, number][] | null; +}; + export type PlanDecision = { id: string; question: string; @@ -276,6 +322,47 @@ export type PlanDecision = { reason: string; resolved_by: 'default' | 'user'; alternatives?: string[] | null; + // A scope decision the user can switch: the harness's ScopeChoice id, + // every option it offers, and the one the plan counts by now. A + // decision without `choice_id` is an ordinary planner decision. + choice_id?: string | null; + options?: PlanDecisionOption[] | null; + active_option_id?: string | null; +}; + +export type PlanAction = 'approve' | 'amend' | 'reject' | 'switch'; + +/** What a switch changed, one entry per scope decision it moved. */ +export type PlanMoved = { + choice_id: string; + from_label: string; + to_label: string; + reason: string; +}; + +/** One piece of evidence the plan needs, in words the harness composed. */ +export type PlanRequirement = { + id: string; + kind: string; + sentence: string; + decision_ids?: string[]; +}; + +/** One computed step of the plan, in words. */ +export type PlanOperation = { + id: string; + kind: string; + sentence: string; + requirement_id?: string | null; +}; + +/** One output the user asked for: the step ids that answer it, or why the + * plan does not compute it, or both when it answers in part. */ +export type PlanAsk = { + ask: string; + answered_by?: string[]; + declined?: string | null; + not_computed?: string | null; }; export type PlanProposed = { @@ -286,9 +373,19 @@ export type PlanProposed = { version: number; title: string; steps: PlanStep[]; - actions: ('approve' | 'amend' | 'reject')[]; + // `switch` is listed only when some decision has a `choice_id` with + // another available option. + actions: PlanAction[]; depth?: 'shallow' | 'standard' | 'deep' | null; decisions?: PlanDecision[] | null; + // The plan's approval key, a string distinct from the integer + // `version`. A `switch` echoes it as `plan_revision`. + revision?: string | null; + requirements?: PlanRequirement[] | null; + operations?: PlanOperation[] | null; + asks?: PlanAsk[] | null; + // Set on the plan a switch re-offers. + moved?: PlanMoved[] | null; }; export type StepProgress = { completed: number; total: number }; @@ -385,6 +482,22 @@ export type LegacyElicitationRequest = { rationale?: string | null; }; +/** A chip that sends `prompt` as the user's next message. */ +export type SuggestionItem = { label: string; prompt: string }; + +/** A chip that moves one scope choice of the answer's readings to another + * option. A click sends a turn with `apply_choice` and no message. `label` + * is the option's label, `turn` the conversation turn whose answer offers + * the chip, counted from 1, and `spec_id` the reading the chip switches. */ +export type ApplyChoiceSuggestion = { + kind: 'apply_choice'; + label: string; + choice_id: string; + option_id: string; + turn: number; + spec_id: string; +}; + export type StudioFrame = | { event: 'message.delta'; seq: number; text: string } | { event: 'message.complete'; seq: number; text: string } @@ -403,6 +516,6 @@ export type StudioFrame = | { event: 'composition.updated'; seq: number; manifest: CompositionManifest } | ElicitationRequest | LegacyElicitationRequest - | { event: 'suggestions'; seq: number; items: { label: string; prompt: string }[] } + | { event: 'suggestions'; seq: number; items: (SuggestionItem | ApplyChoiceSuggestion)[] } | { event: 'error'; seq: number; error: ToolError } | { event: 'done'; seq: number; trace_id: string; composition?: CompositionManifest | null }; From b158fb66d63b36fc5e51513c80a1ccf07ca268a6 Mon Sep 17 00:00:00 2001 From: Jorge Rivera Date: Wed, 28 Oct 2026 07:39:29 +0100 Subject: [PATCH 09/14] feat(studio): lead the plan card with its scope choices and keep the approved plan under its answer (#74) * feat(studio): lead the plan card with what is counted and keep the approved plan under its answer The plan card shows the plan's title, a one-line summary, each scope choice with Change, the step that gives the answer and a View full plan button. A failed switch keeps the card and states its reason under the choice it concerns. A changed value carries a Changed tag. plan.approved attaches an approved-plan pill to the answer, stored with the message and opened read-only. A harness that sends none of the new fields still gets a card. * fix(studio): settle a switch that ends without a plan and make the choice rows accessible A switch stream that closes, errors or is stopped without a plan returns the card to idle with its notice and leaves the paused reply untouched. Every refusal shows the harness's detail; 401 and 429 offer no retry. Options are buttons with aria-pressed in a group, focus returns to the switched row, and the notice sits in a live region mounted empty. An older harness's card leads with the question. * docs(studio): tighten the plan card's comments * fix(studio): mark a row Changed against the turn's first plan A switch re-pins a part, so a choice's id carries a new spec id; rows are matched by their axis. A row is Changed when its option differs from the turn's first plan, so two switches mark both rows and a switch back marks none. * fix(studio): keep the plan card live only on a server pause and key choices on their stable ids Stop on a switch sends cancel and keeps reading: a re-offered plan makes the card live again, and a stream that ends without one leaves it read-only with its Activity entry closed. Try again follows switch_failed.retryable or the refusal's retry mode. Choices are keyed on choice_id, with the axis only for an id missing from the earlier plan, and a stale refusal keeps the turn's first plan as the baseline. Map.groupBy is replaced for older Safari. Conversation search matches message text only. The approved plan shows its caveats. * fix(studio): end a gone pause read-only, show caveats before approval and quiet a stopped switch A refusal for a pause that no longer exists ends the card read-only. A choice falls back to its axis only when its id was replaced. 401 and 429 never offer an immediate Try again, and a busy refusal offers it after its retry delay. Moved choices with no row, each choice's reason and the plan's caveats show on the card, and a stopped switch is a quiet note. Switch settling moves out of stream() into an onEnd callback, notices share one builder, and the card and full plan share one approve bar. Conversation search also matches a user message's card title, with a database test. * fix(studio): keep a switch's card live when the harness was unreachable and match choices by id only A switch whose request got no response keeps the card live with Try again; only a server-confirmed end makes it read-only. Every failed switch clears the steps-updated mark. Choices match on choice_id alone. The fallback answer step is the last answering step in plan order, the headline takes the harness's title when it has one, caveats key by index, and the three switch notices share one builder. * fix(studio): give a stopped switch time to be re-offered and time retries from the notice A switch's stop grace (15 s) outlasts the harness's 10 s re-offer, and repeated Stop clicks send one cancel. A 429 with a retry delay offers Try again after it, timed from when the notice arrived. The switch-ended path reuses endTurn, stream() handles a caller-owned end in one place, and the live card and approved view share the answer-step rule and the caveats list. * fix(studio): end a switch read-only unless its refusal left the pause alone Only a busy, option-unavailable or rate-limited refusal keeps the card live; every other refusal ends it read-only with a reader sentence, and a proxy's raw text goes to the console. Try again appears only when the harness asks for a retry after a delay. done and error frames end the turn when they arrive, a stopped switch keeps its Stopping line, a Stop after a fresh plan sends no cancel, and a switch stream's Activity entry closes by how the harness ended it. * fix(studio): keep the plan live when the site proxy refused a switch A switch the proxy refused before the harness answered (its 502 with no upstream status, or its 429) left a live pause behind a read-only card or a row without Try again. The proxy's 502 now names the upstream status when the harness had answered, the card stays live otherwise, and a proxy 429 offers Try again after Retry-After. A live row shows detail only from a harness ToolError, and a stopped switch's notice is quiet. * fix(studio): let Stop end a switch still waiting to be sent A switch picked while the paused stream was still open waited with no halt, so Stop did nothing. The halt is published when the stream is queued, and a Stop before the request starts skips it and keeps the card live with a quiet note. A Stop followed by a refusal ends as stopped, and the approved view guards stored steps and choices. * fix(studio): end a stopped switch as stopped whatever answers it A refusal after Stop ends the switch with the quiet stopped note, a Stop on an unsent switch clears its halt so a later switch can be stopped, and only a switch publishes its halt while queued. A proxy 429 offers Try again only with a positive Retry-After and reads "Studio is busy." * fix(studio): end a stopped switch as stopped when its fetch fails A switch the user stopped whose fetch then rejected came back as a live card reading "Studio could not be reached." It now ends as stopped, as a refused one does. * fix(studio): announce a switch's outcome and say steps changed only when they did The card keeps one polite live region that states what a switch changed, as the moved-choices note did, and "Steps updated to match." shows only when the step labels differ from the previous plan's. * docs(studio): tidy the plan card and switch comments --- db/test/conversation-search.test.ts | 53 + .../organisms/Studio/hooks/useChat.ts | 435 ++++-- .../workspace/ApprovedPlanPill.module.scss | 93 ++ .../Studio/workspace/ApprovedPlanPill.tsx | 45 + .../Studio/workspace/ChatMessage.tsx | 10 +- .../organisms/Studio/workspace/ChatPanel.tsx | 9 +- .../workspace/PlanApproveBar.module.scss | 26 + .../Studio/workspace/PlanApproveBar.tsx | 34 + .../Studio/workspace/PlanCard.module.scss | 180 +-- .../organisms/Studio/workspace/PlanCard.tsx | 111 +- .../Studio/workspace/PlanLedger.module.scss | 319 +++++ .../organisms/Studio/workspace/PlanLedger.tsx | 333 +++++ .../Studio/workspace/PlanModal.module.scss | 243 ++-- .../organisms/Studio/workspace/PlanModal.tsx | 311 ++--- src/lib/chat/conversationSearch.ts | 107 ++ src/lib/chat/conversationStore.ts | 78 +- src/lib/chat/historyClient.ts | 2 +- src/lib/chat/test/conversationSearch.test.ts | 33 + src/lib/proxy/forward.ts | 34 +- src/lib/proxy/problem.ts | 23 +- src/lib/proxy/test/problem.test.ts | 9 + src/lib/studio/chips.ts | 20 +- src/lib/studio/plan.ts | 795 ++++++++++- src/lib/studio/studioClient.ts | 63 +- src/lib/studio/test/plan.test.ts | 1239 ++++++++++++++++- src/lib/studio/test/studioClient.test.ts | 110 ++ src/lib/studio/wire.ts | 62 +- 27 files changed, 3957 insertions(+), 820 deletions(-) create mode 100644 db/test/conversation-search.test.ts create mode 100644 src/components/organisms/Studio/workspace/ApprovedPlanPill.module.scss create mode 100644 src/components/organisms/Studio/workspace/ApprovedPlanPill.tsx create mode 100644 src/components/organisms/Studio/workspace/PlanApproveBar.module.scss create mode 100644 src/components/organisms/Studio/workspace/PlanApproveBar.tsx create mode 100644 src/components/organisms/Studio/workspace/PlanLedger.module.scss create mode 100644 src/components/organisms/Studio/workspace/PlanLedger.tsx create mode 100644 src/lib/chat/conversationSearch.ts create mode 100644 src/lib/chat/test/conversationSearch.test.ts diff --git a/db/test/conversation-search.test.ts b/db/test/conversation-search.test.ts new file mode 100644 index 0000000..ecee960 --- /dev/null +++ b/db/test/conversation-search.test.ts @@ -0,0 +1,53 @@ +import path from 'node:path'; +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { drizzle } from 'drizzle-orm/node-postgres'; +import { searchConversations } from '../../src/lib/chat/conversationSearch.ts'; +import { applyMigrations } from '../apply-migrations.ts'; +import { conversation, user } from '../schema.ts'; +import { createTestDatabase } from './harness.ts'; + +const MIGRATIONS_FOLDER = path.resolve(import.meta.dirname, '../migrations'); + +/** + * Conversation search on Postgres reads each saved message's text and the + * title of the card a user message names, and leaves out the approved plan + * stored beside a reply. + */ +test('conversation search matches message text and card titles, not stored plans', async (t) => { + const database = await createTestDatabase(); + t.after(() => database.teardown()); + + await applyMigrations(database.migratorPool(), { + migrationsFolder: MIGRATIONS_FOLDER, + runtimeDbUser: database.runtimeUser, + migratorDbUser: database.migratorUser, + }); + + const db = drizzle(database.runtimePool()); + + await db.insert(user).values([{ id: 'owner', name: 'Owner', email: 'owner@example.org' }]); + await db.insert(conversation).values({ + id: 'conv-1', + userId: 'owner', + title: 'Aid question', + data: { + messages: [ + { role: 'user', text: 'How much aid did Ghana get?' }, + { + role: 'bot', + text: 'About 1 billion dollars.', + plan: { title: 'Net disbursements to Zanzibar', steps: [], choices: [] }, + }, + { role: 'user', text: 'Make it a bar chart', card: { title: 'Health aid trend' } }, + ], + }, + }); + + const hits = async (q: string) => + (await searchConversations(db, 'owner', q)).map((hit) => [hit.id, hit.snippet]); + + assert.deepEqual(await hits('ghana'), [['conv-1', 'How much aid did Ghana get?']]); + assert.deepEqual(await hits('health aid'), [['conv-1', 'Health aid trend']]); + assert.deepEqual(await hits('zanzibar'), []); +}); diff --git a/src/components/organisms/Studio/hooks/useChat.ts b/src/components/organisms/Studio/hooks/useChat.ts index c800b6f..cd9da3a 100644 --- a/src/components/organisms/Studio/hooks/useChat.ts +++ b/src/components/organisms/Studio/hooks/useChat.ts @@ -38,12 +38,19 @@ import { restoreCanvas, takeManifest, type CanvasBaseline } from '@/lib/studio/c import { plainTextFromNote } from '@/lib/studio/note'; import { approveReply, + approvedFromFrame, planFromFrame, refusedSwitch, stalePlan, - settledPlan, startSwitch, + stoppedQueuedSwitch, + switchEnded, + endedSwitch, switchReply, + unreachableSwitch, + optionLabel, + withApprovedPlan, + type ApprovedPlan, type PlanState, } from '@/lib/studio/plan'; import { withAnswer } from '@/lib/studio/transcript'; @@ -55,6 +62,7 @@ import { runInterrupt, runTurn, STOP_GRACE_MS, + SWITCH_STOP_GRACE_MS, type StreamHandlers, type StreamRefusal, } from '@/lib/studio/studioClient'; @@ -66,6 +74,7 @@ import type { MutationOperation, Seed, StudioFrame, + ToolError, } from '@/lib/studio/wire'; import { uuid } from '@/lib/uuid'; import type { CardRef, Widget } from '@/lib/chat/widgets'; @@ -75,10 +84,18 @@ import type { CardRef, Widget } from '@/lib/chat/widgets'; // user's answer to a paused turn (a clarify choice or text, a plan // amendment): shown like any user message, but part of the turn it answers. // A bot turn's `followups` are prompts a click sends as the next message, -// and its `chips` switch a scope choice of the answer's readings. +// and its `chips` switch a scope choice of the answer's readings. `plan` is +// the plan the user approved for this answer, saved with the transcript so +// a reopened conversation can show it. export type ChatMessage = | { role: 'user'; text: string; card?: { title: string }; answer?: true } - | { role: 'bot'; text: string; followups?: string[]; chips?: ApplyChoiceSuggestion[] }; + | { + role: 'bot'; + text: string; + followups?: string[]; + chips?: ApplyChoiceSuggestion[]; + plan?: ApprovedPlan; + }; // A widget stamped with the send() turn it arrived in — the canvas keeps // only the latest turn's widgets plus whatever the user pinned. @@ -133,7 +150,8 @@ export type ChatState = { }; // A saved conversation's chat half — what hydrate() restores and what a -// shared/forked copy starts from. +// shared/forked copy starts from. Approved plans ride on their answers in +// `messages`. export type ChatSnapshot = { sessionId: string | null; componentRefs?: CardRef[]; @@ -161,14 +179,24 @@ const withLine = (prev: ChatState, status: string, statusDetail: string | null = statusSince: status === prev.status ? prev.statusSince : Date.now(), }); -// A turn's end on screen, however it ended: its Activity entry closes, the -// chat line clears, and a plan card whose switch got no fresh plan leaves. +// How a stream ended: its error frame, whether `done` arrived, whether the +// user stopped it, whether it broke, and whether its request started (a Stop +// while it waited in the queue skips the request). +type StreamEnding = { + error: ToolError | null; + done: boolean; + stopped: boolean; + failed: boolean; + sent: boolean; +}; + +// A turn's end on screen, however it ended: its Activity entry closes and +// the chat line clears. const endTurn = (prev: ChatState, turn: number, open?: 'skipped' | 'failed'): ChatState => ({ ...prev, activity: endTurnActivity(prev.activity, turn, open), ...NO_LINE, thinking: false, - plan: settledPlan(prev.plan), }); // A conversation restored WITHOUT its server-side session (a fork of a @@ -230,8 +258,9 @@ export const useChat = (initial?: ChatSnapshot) => { // shares reopen the same server-side conversation. const sessionRef = useRef(initial?.sessionId ?? null); const abortRef = useRef(null); - // Stops the running stream's turn (set while a request runs). - const haltRef = useRef<(() => void) | null>(null); + // Stops the running stream's turn (set while a request runs); true for + // the stream's first Stop. + const haltRef = useRef<(() => boolean) | null>(null); // The queue itself; `queued` in state mirrors it for rendering. const queueRef = useRef([]); // Requests run one after another on this chain (the harness serves one @@ -257,6 +286,10 @@ export const useChat = (initial?: ChatSnapshot) => { clarify?: Clarify; plan?: PlanState; } | null>(null); + // The latest plan this turn offered, or is waiting on a switch of. A + // fresh plan.proposed carries its baseline (what the turn's first plan + // held) and learns which choice its switch moved. A new turn clears it. + const turnPlanRef = useRef(null); // The card an in-flight `edit` turn is changing. The harness answers an // edit with a REPLACEMENT component under a new id (and drops the old one // from its composition) rather than a component.revised, so the canvas has @@ -358,6 +391,7 @@ export const useChat = (initial?: ChatSnapshot) => { // from a refusal that answered a stale card with the current one. const offerPlan = useCallback((plan: PlanState) => { pendingInterruptRef.current = { interruptId: plan.interruptId, plan }; + turnPlanRef.current = plan; setState((prev) => ({ ...prev, ...NO_LINE, thinking: false, plan })); }, []); // Swap one widget in place, wherever it sits in the list. @@ -368,24 +402,47 @@ export const useChat = (initial?: ChatSnapshot) => { })); }, []); - // `onRefusal` answers an HTTP refusal of the request (see StreamHandlers) - // and returns true when it has dealt with it; any other refusal ends the - // turn as an error. + // `onRefusal` answers an HTTP refusal of the request, and `onUnreachable` + // a request that got no response (see StreamHandlers); each returns true + // when it has dealt with it, and anything else ends the turn as an error. + // Once the stream ends, at its `done` or `error` frame or else when it + // closes, `onEnd` gets how it ended (see StreamEnding) and ends the turn + // on screen. A caller without one gets `endOwn`. A caller with one (a + // switch, whose harness may re-offer the plan after a Stop) also keeps + // the frames after Stop, for up to SWITCH_STOP_GRACE_MS, with the chat + // line on "Stopping". const stream = useCallback( ( turn: number, start: (handlers: StreamHandlers, signal: AbortSignal) => Promise, - onRefusal?: (refusal: StreamRefusal) => boolean, + { + onRefusal, + onUnreachable, + onEnd, + }: { + onRefusal?: (refusal: StreamRefusal) => boolean; + onUnreachable?: () => boolean; + onEnd?: (ending: StreamEnding) => void; + } = {}, ) => { const controller = new AbortController(); const generation = generationRef.current; - // Set by Stop. The stream is read to its end and its frames are - // ignored. + // The user stopped the stream (Stop). let stopped = false; + // Set by Stop without `onEnd`: the stream is read to its end and + // its frames are ignored. + let muted = false; + let harnessError: ToolError | null = null; + let done = false; + let failed = false; + // The request has started. + let sent = false; + // The turn's end on screen has run (finish). + let finished = false; let stopGrace: ReturnType | null = null; // Runs from the request's start until the stream ends or pauses. const stall = createStallWatch((stalled) => { - if (generationRef.current === generation && !stopped) { + if (generationRef.current === generation && !muted) { setState((prev) => ({ ...prev, stalled })); } }); @@ -398,6 +455,17 @@ export const useChat = (initial?: ChatSnapshot) => { } }); + // A step's chat line. After Stop the line stays on "Stopping". + const showLine = (label: string, detail: string | null = null) => { + if (!stopped) { + setState((prev) => + prev.status === label && prev.statusDetail === detail + ? prev + : withLine(prev, label, detail), + ); + } + }; + const patchActivity = (fn: (entry: ActivityEntry) => ActivityEntry) => setState((prev) => ({ ...prev, @@ -431,7 +499,7 @@ export const useChat = (initial?: ChatSnapshot) => { const handle = (frame: StudioFrame) => { // A stream that reset/hydrate walked away from, or that the // user stopped, must not write into the conversation. - if (generationRef.current !== generation || stopped) { + if (generationRef.current !== generation || muted) { return; } @@ -445,7 +513,7 @@ export const useChat = (initial?: ChatSnapshot) => { if (label) { grace.cancel(); - setState((prev) => withLine(prev, label)); + showLine(label); } break; @@ -453,11 +521,7 @@ export const useChat = (initial?: ChatSnapshot) => { // Deltas after the steps are the answer. grace.cancel(); - setState((prev) => - prev.status === 'Writing the answer' - ? prev - : withLine(prev, 'Writing the answer'), - ); + showLine('Writing the answer'); patchBot((last) => ({ ...last, text: last.text + frame.text })); break; case 'message.complete': @@ -480,7 +544,7 @@ export const useChat = (initial?: ChatSnapshot) => { grace.idle(); } else if (line) { grace.cancel(); - setState((prev) => withLine(prev, line.label, line.detail)); + showLine(line.label, line.detail); } // One Activity line per step_id. A later frame for the @@ -596,13 +660,33 @@ export const useChat = (initial?: ChatSnapshot) => { break; } - case 'plan.proposed': - // Also the answer to a switch, whose fresh plan, with - // what moved, replaces the card it kept. + case 'plan.proposed': { + // Also the answer to a switch or an amendment, whose + // fresh plan replaces the card and marks what changed + // since the turn's first plan. stall.stop(); grace.cancel(); - offerPlan(planFromFrame(frame)); + offerPlan(planFromFrame(frame, turnPlanRef.current ?? undefined)); + + // The turn waits on the user again, so a Stop has + // nothing to cancel. + if (haltRef.current === halt) { + haltRef.current = null; + } + + break; + } + case 'plan.approved': { + // Comes first on the approve's stream, so the reply it + // writes into is the transcript's last message. + const approved = approvedFromFrame(frame); + + setState((prev) => ({ + ...prev, + messages: withApprovedPlan(prev.messages, approved), + })); break; + } case 'plan.versioned': // Transient bookkeeping — the fresh plan.proposed that // follows re-renders the card. @@ -645,29 +729,19 @@ export const useChat = (initial?: ChatSnapshot) => { case 'error': stall.stop(); grace.cancel(); - setState((prev) => { - const messages = prev.messages.slice(); - const last = messages[messages.length - 1]; - - if (last?.role === 'bot' && !last.text) { - messages[messages.length - 1] = { - ...last, - text: `_${frame.error.detail}_`, - }; - } - - return endTurn({ ...prev, messages }, turn, 'failed'); - }); + harnessError = frame.error; + finish(); break; case 'done': stall.stop(); grace.cancel(); + done = true; if (frame.composition) { applyManifest(frame.composition); } - setState((prev) => endTurn(prev, turn)); + finish(); break; } }; @@ -676,15 +750,47 @@ export const useChat = (initial?: ChatSnapshot) => { // empty answer takes the preamble, else a note). The stream is // read until the harness ends it, so the request counts as in // flight, and queued sends wait, until the conversation is free. - const halt = () => { + // A caller that ends the turn itself keeps the frames (a switch's + // stream may re-offer the plan). A Stop before the request + // started ends it at once, and the request is skipped. Returns + // whether to send the cancel, which is true on the stream's + // first Stop once its request has started. + const halt = (): boolean => { if (stopped || generationRef.current !== generation) { - return; + return false; } stopped = true; - stall.stop(); grace.cancel(); - stopGrace = setTimeout(() => controller.abort(), STOP_GRACE_MS); + + if (sent) { + stopGrace = setTimeout( + () => controller.abort(), + onEnd ? SWITCH_STOP_GRACE_MS : STOP_GRACE_MS, + ); + } + + if (onEnd) { + if (!sent) { + // Stop is free for the next stream queued behind this + // one, which the chain has not reached yet. + if (haltRef.current === halt) { + haltRef.current = null; + } + + finish(); + + return false; + } + + setState((prev) => withLine(prev, 'Stopping')); + + return true; + } + + muted = true; + stall.stop(); + setState((prev) => { const messages = prev.messages.slice(); const last = messages[messages.length - 1]; @@ -695,39 +801,98 @@ export const useChat = (initial?: ChatSnapshot) => { return endTurn({ ...prev, messages }, turn, 'skipped'); }); + + return sent; + }; + + // The end of a turn whose caller leaves it to the stream: `done` + // ends it, an error frame ends it failed with its detail as an + // empty reply's text, and a broken stream ends it failed. A + // stream that paused leaves the turn open, and one the user + // stopped ended on screen at the Stop. + const endOwn = (ending: StreamEnding) => { + if (ending.stopped) { + return; + } + + const detail = ending.error?.detail; + + if (detail !== undefined) { + setState((prev) => { + const messages = prev.messages.slice(); + const last = messages[messages.length - 1]; + + if (last?.role === 'bot' && !last.text) { + messages[messages.length - 1] = { ...last, text: `_${detail}_` }; + } + + return endTurn({ ...prev, messages }, turn, 'failed'); + }); + } else if (ending.failed || ending.done) { + setState((prev) => endTurn(prev, turn, ending.failed ? 'failed' : undefined)); + } + }; + + // The turn's end on screen, once: at the `done` or `error` frame, + // or else when the stream closes. + const finish = () => { + if (!finished) { + finished = true; + (onEnd ?? endOwn)({ error: harnessError, done, stopped, failed, sent }); + } }; - // A refusal of a stream that reset/hydrate walked away from, or - // that the user stopped, is dropped, as handle() drops frames. + // A refusal or a failed fetch of a stream that reset/hydrate + // walked away from, or that the user stopped, is dropped, as + // handle() drops frames. A switch the user stopped then ends as + // stopped (its `onEnd`), not on a live card whose buttons the + // cancel has made useless. const refuse = (refusal: StreamRefusal): boolean => - generationRef.current !== generation || stopped || (onRefusal?.(refusal) ?? false); + generationRef.current !== generation || + muted || + stopped || + (onRefusal?.(refusal) ?? false); + const unreachable = (): boolean => + generationRef.current !== generation || + muted || + stopped || + (onUnreachable?.() ?? false); // Queue behind whatever is in flight. The controller is published // when the request actually starts, so reset aborts the fetch - // that is running, not one still waiting here. - let failed = false; + // that is running, not one still waiting here. The halt is + // published at once for a switch when no stream holds Stop (the + // one ahead paused on a plan but has not closed yet), so a Stop + // while it waits skips its request. A resume has no such halt: + // it has already cleared its card, so skipping its request would + // lose the approval while the pause waits. + if (onEnd && haltRef.current === null) { + haltRef.current = halt; + } inFlightRef.current += 1; chainRef.current = chainRef.current .then(() => { - if (generationRef.current !== generation) { + if (generationRef.current !== generation || stopped) { return; } + sent = true; abortRef.current = controller; haltRef.current = halt; stall.beat(); return start( - { onFrame: handle, onKeepAlive: stall.beat, onRefusal: refuse }, + { + onFrame: handle, + onKeepAlive: stall.beat, + onRefusal: refuse, + onUnreachable: unreachable, + }, controller.signal, ).catch((error: Error) => { if (error.name !== 'AbortError' && generationRef.current === generation) { failed = true; - - if (!stopped) { - setState((prev) => endTurn(prev, turn, 'failed')); - } } }); }) @@ -747,6 +912,8 @@ export const useChat = (initial?: ChatSnapshot) => { return; } + finish(); + inFlightRef.current -= 1; // A stream that broke mid-way says nothing about whether @@ -792,6 +959,7 @@ export const useChat = (initial?: ChatSnapshot) => { const turn = turnCount.current; pendingInterruptRef.current = null; + turnPlanRef.current = null; editingRef.current = null; setState((prev) => ({ ...withLine(prev, line), @@ -893,14 +1061,16 @@ export const useChat = (initial?: ChatSnapshot) => { turnCount.current, (handlers, signal) => runInterrupt(conversation, pending.interruptId, reply, handlers, signal), - (refusal) => { - const current = stalePlan(refusal); + { + onRefusal: (refusal) => { + const current = stalePlan(refusal, pending?.plan); - if (current) { - offerPlan(current); - } + if (current) { + offerPlan(current); + } - return current !== null; + return current !== null; + }, }, ); }, @@ -945,9 +1115,14 @@ export const useChat = (initial?: ChatSnapshot) => { }, [resume]); // The plan card's three buttons. An amendment carries the user's change - // request; the stream then proposes a fresh plan version. + // request; the stream then proposes a fresh plan version. A read-only + // card has no pause to answer. const respondPlan = useCallback( (decision: 'approve' | 'amend' | 'reject', payload?: string) => { + if (!pendingInterruptRef.current?.plan) { + return; + } + const note = decision === 'approve' ? '_Plan approved._' @@ -968,10 +1143,15 @@ export const useChat = (initial?: ChatSnapshot) => { // A scope decision's option, picked on the plan card. The card stays, // its controls waiting, until the harness re-offers the plan with what - // moved. A refused switch keeps the pause and the card says why. A - // stale one (the plan moved on elsewhere) is answered with the current - // card. Any other refusal fails the turn, and the card leaves with the - // pause it answered. + // changed, or with `switch_failed` saying why it did not apply. A + // refusal that leaves the pause alone keeps the card live and says why on + // the choice's row (refusedSwitch); a stale one (the plan moved on + // elsewhere) is answered with the current card, and any other ends the + // card read-only. So does a stream that ends without a plan (it closed, + // broke, sent `done` or an error, or was stopped), once, closing the + // turn's Activity entry (endedSwitch) and leaving the reply alone. A + // request that never reached the harness keeps the card live, with "Try + // again". const switchChoice = useCallback( (choiceId: string, optionId: string) => { const conversation = sessionRef.current; @@ -983,29 +1163,82 @@ export const useChat = (initial?: ChatSnapshot) => { return; } - const label = plan.decisions - .find((decision) => decision.choice_id === choiceId) - ?.options?.find((option) => option.option_id === optionId)?.label; + const label = optionLabel(plan.decisions, choiceId, optionId); + const turn = turnCount.current; + // The read-only card a refusal that may have used up the pause + // ends on. + let refused: PlanState | null = null; - pendingInterruptRef.current = null; + // The pause stays pending with the waiting plan. switchReply + // refuses a second pick while it waits, and the plan the harness + // re-offers is compared against it. + const waiting = startSwitch(plan, { choiceId, optionId }); + + pendingInterruptRef.current = { interruptId: pending.interruptId, plan: waiting }; + turnPlanRef.current = waiting; setState((prev) => ({ ...withLine(prev, label ? `Switching to ${label}` : 'Switching'), thinking: true, - plan: prev.plan && startSwitch(prev.plan, { choiceId, optionId }), + plan: waiting, })); stream( - turnCount.current, + turn, (handlers, signal) => runInterrupt(conversation, pending.interruptId, reply, handlers, signal), - (refusal) => { - const refused = refusedSwitch(plan, refusal); + { + onRefusal: (refusal) => { + const card = refusedSwitch(waiting, refusal); - if (refused) { - offerPlan(refused); - } + if (card.live) { + offerPlan(card); + } else { + refused = card; + } + + // The card shows only a harness ToolError's detail. + if (!refusal.error && refusal.detail) { + console.warn('Studio: a switch was refused', refusal); + } + + return true; + }, + // The request never reached the harness, so the pause + // waits. + onUnreachable: () => { + offerPlan(unreachableSwitch(waiting)); + + return true; + }, + onEnd: ({ error, stopped, sent }) => { + // A fresh plan or a refusal replaced the waiting card. + if (pendingInterruptRef.current?.plan !== waiting) { + return; + } + + // Stopped while it waited behind the stream that + // offered the plan. Nothing reached the harness, so + // the pause waits. + if (!sent) { + offerPlan(stoppedQueuedSwitch(waiting)); + + return; + } + + const { plan: ended, activity } = endedSwitch(waiting, { + error, + stopped, + refused, + }); + + pendingInterruptRef.current = null; + turnPlanRef.current = null; + setState((prev) => { + const after = switchEnded(prev, waiting, ended); - return refused !== null; + return after === prev ? prev : endTurn(after, turn, activity); + }); + }, }, ); }, @@ -1031,22 +1264,24 @@ export const useChat = (initial?: ChatSnapshot) => { stream( turn, (handlers, signal) => runApplyChoice(conversation, chip, handlers, signal), - (refusal) => { - const detail = chipRefusalDetail(refusal); + { + onRefusal: (refusal) => { + const detail = chipRefusalDetail(refusal); - if (detail === null) { - return false; - } + if (detail === null) { + return false; + } - setState((prev) => - endTurn( - { ...prev, messages: withRefusedChip(prev.messages, chip, detail) }, - turn, - 'failed', - ), - ); + setState((prev) => + endTurn( + { ...prev, messages: withRefusedChip(prev.messages, chip, detail) }, + turn, + 'failed', + ), + ); - return true; + return true; + }, }, ); }, @@ -1080,21 +1315,21 @@ export const useChat = (initial?: ChatSnapshot) => { }; }, [applyChoice, send]); - // The Stop control: ask the harness to cancel and end the running turn - // (see `halt` in stream()). Requests queued behind it also wait for the - // cancel request to answer. + // The Stop control: end the running turn (see `halt` in stream()) and + // ask the harness to cancel, once per stream whose request started. On a + // switch, a second cancel would end the stream before the harness + // re-offers the plan. Requests queued behind it also wait for the cancel + // request to answer. const stop = useCallback(() => { const conversation = sessionRef.current; - const halt = haltRef.current; - if (!conversation || !halt) { + if (!conversation || !haltRef.current?.()) { return; } const cancelled = cancelTurn(conversation); chainRef.current = chainRef.current.then(() => cancelled); - halt(); }, []); const reset = useCallback(() => { @@ -1118,6 +1353,7 @@ export const useChat = (initial?: ChatSnapshot) => { canvasEditsRef.current?.reset(); turnCount.current = 0; pendingInterruptRef.current = null; + turnPlanRef.current = null; recapRef.current = null; setState({ messages: [], @@ -1144,6 +1380,7 @@ export const useChat = (initial?: ChatSnapshot) => { inFlightRef.current = 0; queueRef.current = []; pendingInterruptRef.current = null; + turnPlanRef.current = null; sessionRef.current = saved.sessionId; revisionsRef.current = restoreCardRevisions(saved); canvasRef.current = restoreCanvas(saved); diff --git a/src/components/organisms/Studio/workspace/ApprovedPlanPill.module.scss b/src/components/organisms/Studio/workspace/ApprovedPlanPill.module.scss new file mode 100644 index 0000000..d7e3bd0 --- /dev/null +++ b/src/components/organisms/Studio/workspace/ApprovedPlanPill.module.scss @@ -0,0 +1,93 @@ +@use "variables" as *; +@use "mixins" as *; + +// The WidgetChips chip: paper fill, sage line, the state pill flush right. +.pill { + @include flex(row, flex-start, flex-start); + + gap: rem(8); + width: 100%; + min-width: 0; + padding: rem(9) rem(10); + text-align: left; + cursor: pointer; + background-color: var(--color-grid-bg-light); + border: 1px solid var(--color-gray-1600); + border-radius: rem(12); + transition: border-color 0.25s ease; + + &:hover { + border-color: var(--color-charcoal); + } +} + +// Three ruled lines: a plan. +.icon { + @include flex(column, center, flex-start); + + flex-shrink: 0; + gap: rem(2.5); + width: rem(14); + height: rem(14); + + i { + display: block; + height: 1.5px; + background-color: var(--color-charcoal); + + &:nth-child(1) { + width: rem(13); + } + + &:nth-child(2) { + width: rem(9); + } + + &:nth-child(3) { + width: rem(11); + } + } +} + +.body { + flex: 1; + min-width: 0; +} + +.title { + @include font-size(12px, 1.1); + + display: block; + font-family: var(--font-outfit); + font-weight: 500; + color: var(--color-charcoal); + letter-spacing: -0.01em; + overflow-wrap: anywhere; +} + +.sub { + @include font-size(11px, 1.3); + + display: block; + margin-top: rem(4); + font-family: var(--font-outfit); + font-weight: 300; + color: var(--color-stone); + letter-spacing: -0.01em; + overflow-wrap: anywhere; +} + +.state { + @include flex(row, center, center); + @include font-size(11px, 1.09); + + flex-shrink: 0; + height: rem(20); + margin-top: rem(-3.4); + padding: 0 rem(5); + font-family: var(--font-outfit); + color: var(--color-mint-dark); + letter-spacing: -0.031em; + background-color: var(--color-mint); + border-radius: rem(7); +} diff --git a/src/components/organisms/Studio/workspace/ApprovedPlanPill.tsx b/src/components/organisms/Studio/workspace/ApprovedPlanPill.tsx new file mode 100644 index 0000000..3aee7ae --- /dev/null +++ b/src/components/organisms/Studio/workspace/ApprovedPlanPill.tsx @@ -0,0 +1,45 @@ +'use client'; + +import { useState } from 'react'; + +import styles from './ApprovedPlanPill.module.scss'; +import PlanModal from './PlanModal'; + +import { approvedView, stepCount, type ApprovedPlan } from '@/lib/studio/plan'; + +/** The approved plan under the answer it produced, in the WidgetChips chip + * shape: its title, the step count and what it counted. A click opens the + * plan read-only. */ +const ApprovedPlanPill = ({ plan }: { plan: ApprovedPlan }) => { + const [open, setOpen] = useState(false); + const view = approvedView(plan); + const counted = view.choices.map((choice) => choice.value).join('; '); + + return ( + <> + + + + ); +}; + +export default ApprovedPlanPill; diff --git a/src/components/organisms/Studio/workspace/ChatMessage.tsx b/src/components/organisms/Studio/workspace/ChatMessage.tsx index 761a187..28914a9 100644 --- a/src/components/organisms/Studio/workspace/ChatMessage.tsx +++ b/src/components/organisms/Studio/workspace/ChatMessage.tsx @@ -4,6 +4,7 @@ import classNames from 'classnames'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; +import ApprovedPlanPill from './ApprovedPlanPill'; import styles from './ChatPanel.module.scss'; import MessageFeedback from './MessageFeedback'; import WidgetChips from './WidgetChips'; @@ -34,8 +35,9 @@ type ChatMessageProps = { /** * One transcript entry: a user bubble, or a bot turn — markdown, the cards - * it put on the whiteboard as chips, and (on the latest turn) the suggested - * follow-ups and the chips that switch a scope choice of the answer. + * it put on the whiteboard as chips, the plan the user approved for it, and + * (on the latest turn) the suggested follow-ups and the chips that switch a + * scope choice of the answer. */ const ChatMessage = ({ message, @@ -69,7 +71,7 @@ const ChatMessage = ({ const followups = message.followups ?? []; const chips = message.chips ?? []; - if (!message.text && followups.length === 0 && chips.length === 0) { + if (!message.text && followups.length === 0 && chips.length === 0 && !message.plan) { return null; } @@ -95,6 +97,8 @@ const ChatMessage = ({ onRemove={onRemove} readOnly={readOnly} /> + {/* The plan the user approved for this answer, to read again. */} + {message.plan && } {/* Follow-ups belong to the CURRENT turn only — once a newer turn exists they're stale, so drop them. */} {isLast && !readOnly && (followups.length > 0 || chips.length > 0) && ( diff --git a/src/components/organisms/Studio/workspace/ChatPanel.tsx b/src/components/organisms/Studio/workspace/ChatPanel.tsx index e90bffa..35da69b 100644 --- a/src/components/organisms/Studio/workspace/ChatPanel.tsx +++ b/src/components/organisms/Studio/workspace/ChatPanel.tsx @@ -93,8 +93,9 @@ const ChatPanel = ({ } // While a plan awaits review, the input is the "Propose changes" - // channel — the text travels as an amendment, not a new turn. - if (chat.plan) { + // channel, and the text travels as an amendment, not a new turn. A + // read-only card awaits nothing, so the text asks again. + if (chat.plan?.live) { chat.respondPlan('amend', follow.trim()); } else if (editTarget) { chat.send(follow, { @@ -249,14 +250,14 @@ const ChatPanel = ({ onChange={setFollow} onSubmit={sendFollow} placeholder={ - chat.plan + chat.plan?.live ? 'Propose changes or ask a follow up...' : editTarget ? 'Ask about this card...' : 'Ask a follow up...' } label={ - chat.plan + chat.plan?.live ? 'Propose changes' : editTarget ? 'Ask about this card' diff --git a/src/components/organisms/Studio/workspace/PlanApproveBar.module.scss b/src/components/organisms/Studio/workspace/PlanApproveBar.module.scss new file mode 100644 index 0000000..782d561 --- /dev/null +++ b/src/components/organisms/Studio/workspace/PlanApproveBar.module.scss @@ -0,0 +1,26 @@ +@use "variables" as *; +@use "mixins" as *; + +.bar { + @include flex(row, space-between, center); + + gap: rem(10); +} + +.label { + @include font-size(12px, 1.1); + + font-family: var(--font-outfit); + font-weight: 500; + color: var(--color-charcoal); + letter-spacing: -0.01em; +} + +// Approve is the one dark button. Cancel is plain. +.actions { + @include flex(row, flex-end, center); + + gap: rem(10); + + --plain-color: var(--color-charcoal); +} diff --git a/src/components/organisms/Studio/workspace/PlanApproveBar.tsx b/src/components/organisms/Studio/workspace/PlanApproveBar.tsx new file mode 100644 index 0000000..eb7dc21 --- /dev/null +++ b/src/components/organisms/Studio/workspace/PlanApproveBar.tsx @@ -0,0 +1,34 @@ +'use client'; + +import classNames from 'classnames'; + +import styles from './PlanApproveBar.module.scss'; + +import Button from '@/components/atoms/Button/Button'; + +/** "Start building?" with Cancel and Approve, under the plan card and in the + * full plan's footer. While a switch is in flight it says the plan is + * updating and both buttons wait. */ +export const PlanApproveBar = ({ + busy, + onApprove, + onCancel, + className, +}: { + busy: boolean; + onApprove?: () => void; + onCancel?: () => void; + className?: string; +}) => ( +
    +

    {busy ? 'Updating the plan…' : 'Start building?'}

    +
    + + +
    +
    +); diff --git a/src/components/organisms/Studio/workspace/PlanCard.module.scss b/src/components/organisms/Studio/workspace/PlanCard.module.scss index 94e644c..97f6d6e 100644 --- a/src/components/organisms/Studio/workspace/PlanCard.module.scss +++ b/src/components/organisms/Studio/workspace/PlanCard.module.scss @@ -1,7 +1,7 @@ @use "variables" as *; @use "mixins" as *; -// The plan preview + the "Start building?" bar stack with a small seam. +// The plan card + the "Start building?" bar stack with a small seam. .planWrap { @include flex(column, flex-start, stretch); @@ -18,128 +18,115 @@ border-radius: rem(10); } -.head { - @include flex(row, space-between, center); +// --- Head: meta line, title, summary ---------------------------------------- - gap: rem(10); - padding: rem(14) rem(16) rem(10); +.head { + padding: rem(12) rem(14) rem(10); } .title { - @include font-size(14px, 1.1); + @include font-size(14px, 1.2); + margin-top: rem(5); font-family: var(--font-outfit); font-weight: 500; color: var(--color-charcoal); letter-spacing: -0.01em; + overflow-wrap: anywhere; } -.version, -.count { - font-weight: 400; +.summary { + @include font-size(11px, 1.35); + + margin-top: rem(4); + font-family: var(--font-outfit); + font-weight: 300; color: var(--color-stone); + letter-spacing: -0.031em; } -// "View Plan" — the full plan, in a modal. -.view { - @include font-size(12px, 1.34); +// --- The step that gives the answer ----------------------------------------- - flex-shrink: 0; - padding: 0; - font-family: var(--font-outfit); - font-weight: 400; - color: var(--color-charcoal); - text-decoration: underline; - text-underline-offset: 0.15em; - letter-spacing: -0.031em; - cursor: pointer; - background: none; - border: 0; +.stepsLine { + padding: rem(9) rem(14) rem(12); + border-top: 1px solid var(--color-net-light); transition: opacity 0.25s ease; +} - &:hover { - opacity: 0.6; - } +// While a switch is in flight, these steps are about to be rewritten. +.dim { + opacity: 0.4; } -// --- The step timeline (the first few) ------------------------------------- +.stepsTop { + @include font-size(10px, 1.3); -.steps { - @include flex(column, flex-start, stretch); + font-family: var(--font-dm-mono); + color: var(--color-stone); + letter-spacing: -0.02em; +} - gap: rem(4); - margin: 0; - padding: 0 rem(16) rem(14); - list-style: none; -} - -// More steps behind "View Plan": the last shown one fades out, as a cue -// that the list goes on. -.stepsMore .step:last-child { - opacity: 0.35; -} - -// Dot rail: each step draws its dot; the connecting line spans between -// dots (skipped after the last one). -.step { - position: relative; - padding: rem(6) 0 rem(6) rem(24); - - // The dot rides the label's own midline (the row's padding is even, so - // that is the row's centre); the line runs from under it to the next. - &::before { - content: ""; - position: absolute; - top: 50%; - left: 0; - width: rem(10); - height: rem(10); - translate: 0 -50%; - background-color: var(--color-charcoal); - border-radius: 50%; - } - - &:not(:last-child)::after { - content: ""; - position: absolute; - top: calc(50% + #{rem(5)}); - bottom: calc(-50% + #{rem(5)}); - left: rem(4.5); - width: 1px; - background-color: var(--color-charcoal); - } -} - -.stepLabel { +.answer { + @include flex(row, flex-start, flex-start); @include font-size(12px, 1.34); + gap: rem(8); + margin-top: rem(6); font-family: var(--font-outfit); - font-weight: 400; color: var(--color-charcoal); letter-spacing: -0.031em; } -// What the switch behind this plan changed, and why the harness refused the -// last answer to it, under the steps. -.moved, -.notice { - margin: 0; - padding: 0 rem(16) rem(14); +.answerLabel { + min-width: 0; + overflow-wrap: anywhere; +} - &:empty { - padding: 0; - } +.dot { + flex-shrink: 0; + width: rem(10); + height: rem(10); + margin-top: rem(2); + background-color: var(--color-charcoal); + border-radius: 50%; } -// --- "Start building?" ------------------------------------------------------ +.updated { + @include font-size(10px, 1.35); -// Grows OUT of the input below: rounded on top only, its bottom edge -// borrowed from the input's own top border (no gap, no double line). -.proceed { - @include flex(row, space-between, center); + margin-top: rem(5); + font-family: var(--font-outfit); + color: var(--color-mint-dark); + letter-spacing: -0.01em; +} + +// The switch outcome's live region, mounted empty and read by screen readers +// only. +.outcome { + position: absolute; + overflow: hidden; + width: 1px; + height: 1px; + white-space: nowrap; + clip-path: inset(50%); +} + +.viewPlan { + margin-top: rem(12); +} - gap: rem(10); +// --- What to keep in mind --------------------------------------------------- +.caveats { + margin-top: rem(10); +} + +// --- "Start building?" ------------------------------------------------------ + +// The approve bar's chrome. It grows out of the input below, rounded on top +// only, its bottom edge borrowed from the input's own top border (no gap, no +// double line). +.proceed { // The extra bottom padding + the negative margin tuck the bar's lower // band UNDER the input that follows (the input paints over it). padding: rem(10) rem(10) rem(26) rem(16); @@ -149,18 +136,3 @@ border-bottom: 0; border-radius: rem(10) rem(10) 0 0; } - -.proceedLabel { - @include font-size(12px, 1.1); - - font-family: var(--font-outfit); - font-weight: 500; - color: var(--color-charcoal); - letter-spacing: -0.01em; -} - -.proceedActions { - @include flex(row, flex-end, center); - - gap: rem(6); -} diff --git a/src/components/organisms/Studio/workspace/PlanCard.tsx b/src/components/organisms/Studio/workspace/PlanCard.tsx index 8d1fab9..fbd0a24 100644 --- a/src/components/organisms/Studio/workspace/PlanCard.tsx +++ b/src/components/organisms/Studio/workspace/PlanCard.tsx @@ -3,14 +3,14 @@ import classNames from 'classnames'; import { useState } from 'react'; +import { PlanApproveBar } from './PlanApproveBar'; import styles from './PlanCard.module.scss'; -import PlanModal, { PlanMovedNote, PlanNotice } from './PlanModal'; +import { PlanCaveats, PlanLedger, PlanMeta } from './PlanLedger'; +import PlanModal from './PlanModal'; import type { ChatState } from '../hooks/useChat'; import Button from '@/components/atoms/Button/Button'; - -// How many steps the preview shows before "View Plan" takes over. -const PREVIEW_STEPS = 3; +import { STEPS_UPDATED, answerStepOf, planView, stepCount, switchOutcome } from '@/lib/studio/plan'; type PlanCardProps = { plan: NonNullable; @@ -20,59 +20,80 @@ type PlanCardProps = { }; /** - * The research-plan review in the chat (design: "Proposed Research Plan"): - * a preview — the title with the step count, the first steps on the dot - * rail (the last one fading out), "View Plan" to the full plan in a modal — - * then the "Start building?" bar with Cancel and Approve. Changes still - * travel through the chat input below (ChatPanel repurposes it as "Propose - * changes or ask a follow up…" while the plan is pending, wired to the - * amend decision). + * The research plan awaiting review, in the chat foot: the title and its + * summary, the scope choices ("Counted as", each switchable in place), the + * step that gives the answer, what to keep in mind about the answer, and + * "View full plan" to every step in a modal. Then the "Start building?" bar + * with Cancel and Approve. Changes travel through the chat input below + * (ChatPanel repurposes it as "Propose changes or ask a follow up…" while + * the plan is pending, wired to the amend decision). + * + * What a switch changed (the rows marked Changed, the choices moved without + * a row, and the steps) is also written out in one polite live region, empty + * between switches and read by screen readers only, so it is announced when + * the fresh plan arrives. + * + * The card's height follows the number of scope choices. While a switch is + * in flight the steps line dims and the bar waits. A card whose pause has + * ended (a switch ended without a fresh plan) is read-only, with no bar, and + * its rows say to ask again. */ const PlanCard = ({ plan, onApprove, onCancel, onSwitch }: PlanCardProps) => { const [open, setOpen] = useState(false); - const preview = plan.steps.slice(0, PREVIEW_STEPS); - const more = plan.steps.length > PREVIEW_STEPS; + const view = planView(plan); + const answer = answerStepOf(view); + const busy = !!plan.switching; + const steps = stepCount(view.steps.length); return (
    -

    - {plan.title} - {plan.version > 1 && ( - v{plan.version} - )} - - {' – '} - {plan.steps.length} {plan.steps.length === 1 ? 'Step' : 'Steps'} - -

    - + +

    {view.title}

    + {view.summary &&

    {view.summary}

    }
    -
      - {preview.map((step) => ( -
    1. - {step.label} -
    2. - ))} -
    - - -
    -
    -

    Start building?

    -
    - -
    -
    +

    + {switchOutcome(view, plan).join(' ')} +

    + + {plan.live && ( + + )} void; + +/** "Research plan v2 / 5 steps", with the Approved tag on an approved plan. */ +export const PlanMeta = ({ view, approved }: { view: PlanView; approved?: boolean }) => ( +

    + Research plan{view.version > 1 ? ` v${view.version}` : ''} + / + {stepCount(view.steps.length)} + {approved && Approved} +

    +); + +/** + * "Keep in mind": what qualifies the answer, one sentence a line, on the card + * and in the full plan, and nothing without caveats. `className` places it in + * its host, and `headClassName` draws its head as the host's other section + * heads. + */ +export const PlanCaveats = ({ + caveats, + className, + headClassName, +}: { + caveats: string[]; + className?: string; + headClassName?: string; +}) => + caveats.length > 0 && ( +
    +

    Keep in mind

    +
      + {caveats.map((caveat, index) => ( + // Caveats are sentences with no id, and two may read alike. +
    • {caveat}
    • + ))} +
    +
    + ); + +/** Whether `notice`'s `retryAt` has passed, true at once without one. */ +const useWaited = (notice: PlanNotice | null): boolean => { + const retryAt = notice?.retryAt ?? null; + const [waited, setWaited] = useState(null); + + useEffect(() => { + if (retryAt === null) { + return; + } + + const timer = setTimeout(() => setWaited(notice), Math.max(0, retryAt - Date.now())); + + return () => clearTimeout(timer); + }, [notice, retryAt]); + + return retryAt === null || waited === notice; +}; + +/** + * Why the last switch did not apply, with "Try again" when picking the + * option again could apply. The live region stays mounted, empty until a + * notice arrives, so a screen reader announces the notice when it does. + */ +const NoticeRegion = ({ notice, onRetry }: { notice: PlanNotice | null; onRetry?: () => void }) => ( +
    + {notice && ( +
    +

    + {notice.lead && {notice.lead}} + {notice.text} +

    + {onRetry && ( + + )} +
    + )} +
    +); + +/** + * One scope choice: its name, the value the plan counts by, "Changed" and + * the value it held when a switch moved it, and "Change", which opens the + * options in place, drawn as a radio list: buttons in a group, the active + * one pressed, each picking its option at once. A partial option names the + * years it covers and an unavailable one says why. While a switch is in + * flight every option ignores clicks but keeps focus (`aria-disabled`), and + * the picked one says the plan is updating. When the plan this row's switch + * produced arrives and the option that held focus is gone, focus moves to + * "Change". + */ +const ChoiceRow = ({ + choice, + plan, + open, + reasons, + refocus, + onToggle, + onSwitch, +}: { + choice: PlanChoiceView; + plan: PlanState | null; + open: boolean; + reasons: boolean; + // Focus was inside the ledger when it last moved (see PlanLedger). + refocus: RefObject; + onToggle: () => void; + onSwitch?: Switch; +}) => { + const changeRef = useRef(null); + const decision = choice.decision; + const choiceId = decision?.choice_id; + const switchable = !!(plan && onSwitch && decision && choiceId && canSwitch(plan)); + const busy = !!plan?.switching; + const pending = plan?.switching?.choiceId === choiceId ? plan?.switching?.optionId : null; + const notice = plan?.notice?.choiceId && plan.notice.choiceId === choiceId ? plan.notice : null; + const failedOption = notice?.optionId; + // "Try again" waits out the delay the harness asked for. + const waited = useWaited(notice); + const retry = + notice?.retryable && + waited && + !busy && + choiceId && + failedOption && + plan && + onSwitch && + switchReply(plan, choiceId, failedOption) + ? () => onSwitch(choiceId, failedOption) + : undefined; + + useEffect(() => { + const active = document.activeElement; + + if ( + refocus.current && + plan?.switched === choiceId && + (!active || active === document.body) + ) { + changeRef.current?.focus(); + } + }, [refocus, plan, choiceId]); + + return ( +
    + {choice.name} + + {choice.value} + {choice.changedFrom && Changed} + + {switchable && ( + + )} + {choice.changedFrom && ( + + Changed from {choice.changedFrom}. + {choice.changedReason && ` ${choice.changedReason}`} + + )} + {reasons && choice.reason && {choice.reason}} + {switchable && open && decision?.options && ( +
    + {decision.options.map((option) => { + const active = isActiveOption(decision, option); + const isPending = option.option_id === pending; + const inert = busy || !isSelectable(decision, option); + const sub = + option.status === 'unavailable' + ? option.reason + : option.status === 'partial' && option.years + ? `Covers ${yearsText(option.years)}` + : null; + + return ( + + ); + })} +
    + )} + +
    + ); +}; + +/** + * "Counted as": the plan's scope choices as a ruled ledger, one row each, + * then a line for each choice the switch moved that has no row. `plan` and + * `onSwitch` make the rows switchable. An approved plan passes neither. + * `reasons` adds why the plan holds each choice. One row opens at a time: + * the one being switched, else the one whose switch failed, until the user + * opens or closes another. + */ +export const PlanLedger = ({ + view, + plan = null, + onSwitch, + reasons = false, + className, +}: { + view: PlanView; + plan?: PlanState | null; + onSwitch?: Switch; + reasons?: boolean; + className?: string; +}) => { + // The row the user opened or closed (null) for this plan. Undefined + // follows the plan. + const [picked, setPicked] = useState<{ plan: PlanState | null; open?: string | null }>({ + plan, + }); + // Focus was inside this ledger when it last moved. An option that held + // it unmounts without a blur, so it stays set across a re-offer. + const hadFocusRef = useRef(false); + + if (picked.plan !== plan) { + setPicked({ plan }); + } + + const open = + picked.plan === plan && picked.open !== undefined + ? picked.open + : (plan?.switching?.choiceId ?? plan?.notice?.choiceId ?? null); + const rowIds = new Set(view.choices.map((choice) => choice.decision?.choice_id)); + // A notice about the whole plan, or about a choice the plan does not + // show, goes under the rows. + const general = + plan?.notice && !(plan.notice.choiceId && rowIds.has(plan.notice.choiceId)) + ? plan.notice + : null; + + const ruled = view.choices.length > 0 || view.moved.length > 0; + + // An approved plan without choices has nothing to show. A live plan keeps + // its notice region mounted. + if (!ruled && !plan) { + return null; + } + + return ( +
    { + hadFocusRef.current = true; + }} + onBlur={(event) => { + if (!event.currentTarget.contains(event.relatedTarget)) { + hadFocusRef.current = false; + } + }} + > + {ruled &&

    Counted as

    } + {view.choices.map((choice) => ( + + setPicked({ plan, open: open === choice.key ? null : choice.key }) + } + /> + ))} + {view.moved.map((move) => ( +

    + + Changed from {move.from} to {move.to}.{move.reason && ` ${move.reason}`} + +

    + ))} +
    + +
    +
    + ); +}; diff --git a/src/components/organisms/Studio/workspace/PlanModal.module.scss b/src/components/organisms/Studio/workspace/PlanModal.module.scss index 0b16266..b65800a 100644 --- a/src/components/organisms/Studio/workspace/PlanModal.module.scss +++ b/src/components/organisms/Studio/workspace/PlanModal.module.scss @@ -1,29 +1,59 @@ @use "variables" as *; @use "mixins" as *; -// A touch wider than the house centred dialog: a plan's descriptions are -// paragraphs. +// A touch wider than the house centred dialog: a plan's summary is a +// paragraph. .modal { width: rem(560); } .body { - padding-bottom: rem(8); + padding: 0 rem(6) rem(8); } -// --- The step timeline (all of it) ----------------------------------------- +.summary { + @include font-size(11px, 1.4); + + margin: rem(8) 0 rem(14); + font-family: var(--font-outfit); + font-weight: 300; + color: var(--color-stone); + letter-spacing: -0.031em; +} + +.ledger { + --ledger-inset: 0; + + margin-top: rem(8); +} + +.section { + margin-top: rem(4); + border-top: 1px solid var(--color-net-light); +} + +.sectionHead { + @include font-size(10px, 1.3); -.steps { + padding: rem(10) 0 rem(6); + font-family: var(--font-dm-mono); + color: var(--color-stone); + letter-spacing: -0.02em; +} + +// --- The steps: a dot rail, folded to labels -------------------------------- + +.rail { @include flex(column, flex-start, stretch); - gap: rem(4); + gap: rem(2); margin: 0; - padding: 0 rem(6); + padding: 0; list-style: none; } -// Dot rail: each step draws its dot; the connecting line spans between -// dots (skipped after the last one). +// Each step draws a hollow dot, and a line runs from under it to the next. +// The answer step's dot is filled. .step { position: relative; padding-left: rem(24); @@ -33,9 +63,11 @@ position: absolute; top: rem(9); left: 0; + box-sizing: border-box; width: rem(10); height: rem(10); - background-color: var(--color-charcoal); + background-color: var(--color-white); + border: 1px solid var(--color-charcoal); border-radius: 50%; } @@ -43,13 +75,17 @@ content: ""; position: absolute; top: rem(19); - bottom: rem(-13); + bottom: rem(-11); left: rem(4.5); width: 1px; background-color: var(--color-charcoal); } } +.stepAnswer::before { + background-color: var(--color-charcoal); +} + .stepRow { @include flex(row, space-between, center); @@ -57,25 +93,40 @@ width: 100%; padding: rem(6) 0; text-align: left; - cursor: pointer; background: none; border: 0; -} -.stepRowStatic { - cursor: default; + &:is(button) { + cursor: pointer; + } } .stepLabel { @include font-size(13px, 1.34); + min-width: 0; font-family: var(--font-outfit); - font-weight: 500; + font-weight: 400; color: var(--color-charcoal); letter-spacing: -0.031em; + overflow-wrap: anywhere; + + .stepAnswer & { + font-weight: 500; + } } -.stepChevron { +.kind { + @include font-size(10px, 1.3); + + display: block; + font-family: var(--font-dm-mono); + font-weight: 400; + color: var(--color-stone); + letter-spacing: -0.02em; +} + +.chevron { flex-shrink: 0; width: rem(12); height: rem(12); @@ -83,182 +134,54 @@ transition: rotate 0.25s $ease-out; } -.stepChevronOpen { +.chevronOpen { rotate: 180deg; } -// The house accordion: grid rows 0fr → 1fr animate the fold with no -// height measuring; the inner clips. -.fold { - display: grid; - grid-template-rows: 0fr; - transition: grid-template-rows 0.3s $ease-out; -} - -.foldOpen { - grid-template-rows: 1fr; -} - -.foldInner { - min-height: 0; - overflow: hidden; -} - .stepDetail { @include font-size(11px, 1.4); - padding: 0 rem(20) rem(10) 0; + padding: 0 rem(20) rem(8) 0; font-family: var(--font-outfit); font-weight: 300; color: var(--color-stone); letter-spacing: -0.031em; } -// The agent's own choices, quiet under the timeline. -.decisions { - @include flex(column, flex-start, stretch); - - gap: rem(8); - margin: rem(12) rem(6) 0; - padding: rem(12) 0 0; - list-style: none; - border-top: 1px solid var(--color-sage-light); -} +// --- The planner's other decisions ------------------------------------------ -.decision { +.others { + @include flex(column, flex-start, stretch); @include font-size(11px, 1.4); + gap: rem(4); + margin: 0 0 rem(6); + padding: 0; font-family: var(--font-outfit); font-weight: 300; color: var(--color-stone); letter-spacing: -0.031em; + list-style: none; - strong { + b { font-weight: 500; color: var(--color-charcoal); } } -// A scope decision's options, the clarify picker's option buttons at the -// size of the decision text. -.options { - @include flex(row, flex-start, stretch); - - flex-wrap: wrap; - gap: rem(4); - margin-top: rem(6); -} - -.option { - @include flex(column, flex-start, flex-start); - @include font-size(11px, 1.34); - - gap: rem(2); - max-width: 100%; - padding: rem(6) rem(8); - font-family: var(--font-outfit); - font-weight: 400; - color: var(--color-charcoal); - text-align: left; - letter-spacing: -0.031em; - cursor: pointer; - background: none; - border: 1px solid var(--color-sage-light); - border-radius: rem(8); - transition: - border-color 0.25s ease, - background-color 0.25s ease, - opacity 0.25s ease; - - &:hover:not(:disabled, [aria-disabled="true"]) { - background-color: var(--color-net-mid); - } - - &:disabled, - &[aria-disabled="true"] { - cursor: default; - } - - // Unavailable, or waiting on a switch in flight. - &:is(:disabled, [aria-disabled="true"]):not(.optionActive, .optionPending) { - opacity: 0.55; - } -} - -.optionActive { - background-color: var(--color-net-light); - border-color: var(--color-charcoal); -} - -.optionPending { - border-style: dashed; - border-color: var(--color-charcoal); -} +// --- Footer ----------------------------------------------------------------- -.optionLabel { - font-weight: 500; -} - -.optionNote { - color: var(--color-stone); -} - -// What a switch changed, and why a switch was refused. -.moved, -.notice { - @include font-size(11px, 1.4); - - margin: rem(10) rem(6) 0; - font-family: var(--font-outfit); - font-weight: 400; - color: var(--color-charcoal); - letter-spacing: -0.031em; -} - -.moved { - @include flex(column, flex-start, stretch); - - gap: rem(4); - padding: 0; - list-style: none; -} - -// Both stay mounted as live regions and take no room when empty. -.moved:empty, -.notice:empty { - margin: 0; - padding: 0; -} - -// --- "Start building?" ------------------------------------------------------ - -// Pinned to the dialog's bottom edge, under the scrolling steps; the -// negative side margins take it to the dialog's own edges. .proceed { @include flex(row, space-between, center); - position: sticky; - bottom: rem(-20); - z-index: 1; gap: rem(10); - margin: rem(16) rem(-10) rem(-20); - padding: rem(10) rem(10) rem(10) rem(16); - background-color: var(--color-grid-bg-light); - border-top: 1px solid var(--color-sage-light); - border-radius: 0 0 rem(10) rem(10); } -.proceedLabel { - @include font-size(12px, 1.1); +.note { + @include font-size(12px, 1.3); font-family: var(--font-outfit); - font-weight: 500; - color: var(--color-charcoal); + font-weight: 400; + color: var(--color-stone); letter-spacing: -0.01em; } - -.proceedActions { - @include flex(row, flex-end, center); - - gap: rem(6); -} diff --git a/src/components/organisms/Studio/workspace/PlanModal.tsx b/src/components/organisms/Studio/workspace/PlanModal.tsx index dfb80c9..ba411d4 100644 --- a/src/components/organisms/Studio/workspace/PlanModal.tsx +++ b/src/components/organisms/Studio/workspace/PlanModal.tsx @@ -3,222 +3,171 @@ import classNames from 'classnames'; import { useState } from 'react'; +import { PlanApproveBar } from './PlanApproveBar'; +import { PlanCaveats, PlanLedger, PlanMeta } from './PlanLedger'; import styles from './PlanModal.module.scss'; -import type { ChatState } from '../hooks/useChat'; import Button from '@/components/atoms/Button/Button'; import Icon from '@/components/atoms/Icon/Icon'; import Modal from '@/components/molecules/Modal/Modal'; -import { - canSwitch, - isActiveOption, - isSelectable, - movedSentence, - switchOptions, - yearsText, - type PlanState, -} from '@/lib/studio/plan'; -import type { PlanDecision } from '@/lib/studio/wire'; +import { answerStepOf, type PlanState, type PlanView } from '@/lib/studio/plan'; type PlanModalProps = { - plan: NonNullable; + view: PlanView; open: boolean; onOpenChange: (open: boolean) => void; - onApprove: () => void; - onCancel: () => void; - // Switches a scope decision to another option. Left out where the - // viewer cannot change the plan (a guest, a read-only view). + // The plan awaiting review, with its answers. An approved plan + // (read-only) passes none of these. + plan?: PlanState; + onApprove?: () => void; + onCancel?: () => void; + // Switches a scope choice to another option. Left out where the viewer + // cannot change the plan (a guest). onSwitch?: (choiceId: string, optionId: string) => void; }; -/** What the switch that produced this plan changed, said once on the plan - * it re-offered. The live region stays mounted, empty between switches, - * so a screen reader announces the sentences when they arrive. */ -export const PlanMovedNote = ({ plan, className }: { plan: PlanState; className?: string }) => ( -
      - {plan.moved.map((moved) => ( -
    • {movedSentence(moved)}
    • - ))} -
    -); +/** Every step on the dot rail, folded to its label. The step that gives the + * answer starts open and carries a filled dot. */ +const Steps = ({ view }: { view: PlanView }) => { + const [openKeys, setOpenKeys] = useState( + () => new Set(view.steps.filter((step) => step.givesAnswer).map((step) => step.key)), + ); -/** Why the harness refused the last answer to this plan, for the user to - * answer it again. A live region, mounted while empty, as above. */ -export const PlanNotice = ({ plan, className }: { plan: PlanState; className?: string }) => ( -

    - {plan.notice} -

    -); + const toggle = (key: string) => + setOpenKeys((prev) => { + const next = new Set(prev); -/** - * One decision: the question, the chosen answer and why. A scope decision - * the plan lets the user switch also lists its options, the active one - * marked; a partial option names the years it covers, an unavailable one - * says why and cannot be picked. While a switch is in flight every option - * ignores clicks but keeps focus (`aria-disabled`). Only unavailable ones - * are natively disabled. - */ -const DecisionRow = ({ - decision, - plan, - onSwitch, -}: { - decision: PlanDecision; - plan: PlanState; - onSwitch?: (choiceId: string, optionId: string) => void; -}) => { - const options = onSwitch && canSwitch(plan) ? switchOptions(decision) : null; - const choiceId = decision.choice_id; + if (!next.delete(key)) { + next.add(key); + } - return ( -
  • - {decision.question} — {decision.chosen} - {decision.reason ? ` (${decision.reason})` : ''} - {options && choiceId && ( -
    - {options.map((option) => { - const active = isActiveOption(decision, option); - const pending = - plan.switching?.choiceId === choiceId && - plan.switching.optionId === option.option_id; - const inert = !isSelectable(decision, option) || !!plan.switching; - const note = - option.status === 'unavailable' - ? option.reason - : option.status === 'partial' && option.years - ? `Covers ${yearsText(option.years)}` - : null; + return next; + }); - return ( + return ( +
      + {view.steps.map((step) => { + const isOpen = openKeys.has(step.key); + const label = ( + + {step.givesAnswer && Gives the answer} + {step.label} + + ); + + return ( +
    1. + {step.description ? ( - ); - })} -
    - )} -
  • + ) : ( + {label} + )} + {step.description && isOpen && ( +

    {step.description}

    + )} + + ); + })} + ); }; /** - * The full plan (design: "Proposed Research Plan – N Steps" modal): every - * step on the dot rail with its description unfolded (a chevron folds it), - * the agent's decisions under them (a scope decision with its options to - * switch between), and the same "Start building?" bar with Cancel and - * Approve pinned to the bottom. + * The full plan: what it counts, the scope choices (switchable on a live + * plan) with why the plan holds each, what to keep in mind, the steps folded + * to labels with the answer step open, then the planner's other decisions. + * A live plan's footer asks to approve. An approved plan opens read-only + * from the pill under its answer. */ -const PlanModal = ({ plan, open, onOpenChange, onApprove, onCancel, onSwitch }: PlanModalProps) => { - // Steps start unfolded — the modal exists to read them. - const [folded, setFolded] = useState>(new Set()); - - const toggleStep = (id: string) => - setFolded((prev) => { - const next = new Set(prev); - - if (next.has(id)) { - next.delete(id); - } else { - next.add(id); - } - - return next; - }); +const PlanModal = ({ + view, + open, + onOpenChange, + plan, + onApprove, + onCancel, + onSwitch, +}: PlanModalProps) => { + const readOnly = !plan; + const ended = plan && !plan.live; + const busy = !!plan?.switching; + const answerKey = answerStepOf(view)?.key; return ( +

    + {readOnly + ? 'Approved. Ask a follow up to change the scope.' + : 'Ask again to change the plan.'} +

    + +
    + ) : ( + + ) + } >
    -
      - {plan.steps.map((step) => { - const isOpen = !folded.has(step.id); - - return ( -
    1. - {step.description ? ( - - ) : ( - - {step.label} - - )} - {step.description && ( -
      -
      -

      {step.description}

      -
      -
      - )} -
    2. - ); - })} -
    - {plan.decisions.length > 0 && ( -
      - {plan.decisions.map((decision) => ( - - ))} -
    + + {view.summary &&

    {view.summary}

    } + + +
    +

    Steps

    + {/* A switch can move the answer step, and the open set + starts again from the new one. */} + +
    + {view.others.length > 0 && ( +
    +

    Other choices the plan made

    +
      + {view.others.map((other) => ( +
    • + {other.question}: {other.chosen} + {other.reason ? `. ${other.reason}` : ''} +
    • + ))} +
    +
    )} - - -
    -
    -

    Start building?

    -
    - - -
    ); diff --git a/src/lib/chat/conversationSearch.ts b/src/lib/chat/conversationSearch.ts new file mode 100644 index 0000000..df919dd --- /dev/null +++ b/src/lib/chat/conversationSearch.ts @@ -0,0 +1,107 @@ +import { and, desc, eq, ilike, or, sql } from 'drizzle-orm'; +import type { NodePgDatabase } from 'drizzle-orm/node-postgres'; + +import { conversation } from '../../../db/schema.ts'; + +/** + * Conversation search over the `conversation` table. It reads the title and, + * of each saved message, its text and the title of the card a user message + * names. The plans and chips stored beside them are left out. Each query + * takes the database handle as its first argument; `conversationStore.ts` + * binds it to the server's pool. With no path-alias dependency, it runs under + * `node --test` against a migrated test database. + */ + +export type ConversationMeta = { + id: string; + title: string; + updatedAt: number; + turnCount: number; + // Search only: the first transcript line that matched, trimmed around it. + snippet?: string; +}; + +export type SearchDb = Pick; + +export const LIST_LIMIT = 100; + +/** The searchable texts of one stored message, in order. */ +const searchedTexts = (message: unknown): string[] => { + if (message === null || typeof message !== 'object') { + return []; + } + + const { text, card } = message as { text?: unknown; card?: { title?: unknown } | null }; + const title = card && typeof card === 'object' ? card.title : undefined; + + return [text, title].filter((value): value is string => typeof value === 'string'); +}; + +/** A search hit's context: the first message text or card title containing + * `q` (case-insensitive), cut to a window around the match. */ +export const snippetOf = (messages: unknown, q: string): string | undefined => { + if (!Array.isArray(messages)) { + return undefined; + } + + const needle = q.toLowerCase(); + + for (const raw of messages.flatMap(searchedTexts)) { + const text = raw.replace(/\s+/g, ' '); + const at = text.toLowerCase().indexOf(needle); + + if (at >= 0) { + const start = Math.max(0, at - 50); + const end = Math.min(text.length, at + needle.length + 70); + + return `${start > 0 ? '…' : ''}${text.slice(start, end)}${end < text.length ? '…' : ''}`; + } + } + + return undefined; +}; + +/** Title + transcript search (case-insensitive substring), newest first. */ +export const searchConversations = async ( + db: SearchDb, + userId: string, + q: string, +): Promise => { + const pattern = `%${q.replace(/[%_\\]/g, (m) => `\\${m}`)}%`; + const rows = await db + .select({ + id: conversation.id, + title: conversation.title, + updatedAt: conversation.updatedAt, + turnCount: conversation.turnCount, + messages: sql`${conversation.data} -> 'messages'`, + }) + .from(conversation) + .where( + and( + eq(conversation.userId, userId), + or( + ilike(conversation.title, pattern), + sql`exists ( + select 1 + from jsonb_array_elements( + case jsonb_typeof(${conversation.data} -> 'messages') + when 'array' then ${conversation.data} -> 'messages' + else '[]'::jsonb + end + ) as message + where message ->> 'text' ilike ${pattern} + or message -> 'card' ->> 'title' ilike ${pattern} + )`, + ), + ), + ) + .orderBy(desc(conversation.updatedAt)) + .limit(LIST_LIMIT); + + return rows.map(({ messages, ...row }) => ({ + ...row, + updatedAt: row.updatedAt.getTime(), + snippet: snippetOf(messages, q), + })); +}; diff --git a/src/lib/chat/conversationStore.ts b/src/lib/chat/conversationStore.ts index 49fcde4..43d11a9 100644 --- a/src/lib/chat/conversationStore.ts +++ b/src/lib/chat/conversationStore.ts @@ -1,5 +1,10 @@ -import { and, desc, eq, ilike, or, sql } from 'drizzle-orm'; +import { and, desc, eq } from 'drizzle-orm'; +import { + LIST_LIMIT, + searchConversations as searchIn, + type ConversationMeta, +} from './conversationSearch'; import type { SavedConversation } from './history'; import { db } from '@/lib/db'; @@ -12,17 +17,6 @@ import { conversation } from '../../../db/schema'; * reopen a canvas as it was left. Every query is scoped to the owner. */ -export type ConversationMeta = { - id: string; - title: string; - updatedAt: number; - turnCount: number; - // Search only: the first transcript line that matched, trimmed around it. - snippet?: string; -}; - -const LIST_LIMIT = 100; - export const listConversations = async (userId: string): Promise => { const rows = await db .select({ @@ -39,63 +33,9 @@ export const listConversations = async (userId: string): Promise ({ ...row, updatedAt: row.updatedAt.getTime() })); }; -// A search hit's context: the first message containing `q` (case-insensitive), -// cut to a window around the match. -const snippetOf = (messages: unknown, q: string): string | undefined => { - if (!Array.isArray(messages)) { - return undefined; - } - - const needle = q.toLowerCase(); - - for (const message of messages as { text?: unknown }[]) { - const text = typeof message?.text === 'string' ? message.text.replace(/\s+/g, ' ') : ''; - const at = text.toLowerCase().indexOf(needle); - - if (at >= 0) { - const start = Math.max(0, at - 50); - const end = Math.min(text.length, at + needle.length + 70); - - return `${start > 0 ? '…' : ''}${text.slice(start, end)}${end < text.length ? '…' : ''}`; - } - } - - return undefined; -}; - -/** Title + transcript search (case-insensitive substring), newest first. */ -export const searchConversations = async ( - userId: string, - q: string, -): Promise => { - const pattern = `%${q.replace(/[%_\\]/g, (m) => `\\${m}`)}%`; - const rows = await db - .select({ - id: conversation.id, - title: conversation.title, - updatedAt: conversation.updatedAt, - turnCount: conversation.turnCount, - messages: sql`${conversation.data} -> 'messages'`, - }) - .from(conversation) - .where( - and( - eq(conversation.userId, userId), - or( - ilike(conversation.title, pattern), - sql`(${conversation.data} -> 'messages')::text ILIKE ${pattern}`, - ), - ), - ) - .orderBy(desc(conversation.updatedAt)) - .limit(LIST_LIMIT); - - return rows.map(({ messages, ...row }) => ({ - ...row, - updatedAt: row.updatedAt.getTime(), - snippet: snippetOf(messages, q), - })); -}; +/** Title + transcript search (see conversationSearch.ts), newest first. */ +export const searchConversations = (userId: string, q: string): Promise => + searchIn(db, userId, q); export const getConversation = async ( userId: string, diff --git a/src/lib/chat/historyClient.ts b/src/lib/chat/historyClient.ts index e625166..755a94a 100644 --- a/src/lib/chat/historyClient.ts +++ b/src/lib/chat/historyClient.ts @@ -1,6 +1,6 @@ import type { SavedConversation } from './history'; -import type { ConversationMeta } from '@/lib/chat/conversationStore'; +import type { ConversationMeta } from '@/lib/chat/conversationSearch'; /** * Browser side of the account history: the /api/conversations calls the diff --git a/src/lib/chat/test/conversationSearch.test.ts b/src/lib/chat/test/conversationSearch.test.ts new file mode 100644 index 0000000..b6bfc2c --- /dev/null +++ b/src/lib/chat/test/conversationSearch.test.ts @@ -0,0 +1,33 @@ +/** + * The snippet in `conversationSearch.ts` reads message texts and the title of + * the card a user message names. Other stored fields do not match. + */ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { snippetOf } from '../conversationSearch.ts'; + +test('a card title a user message names is a search hit', () => { + const messages = [ + { role: 'user', text: 'Make it a bar chart', card: { title: 'Health aid to Ghana' } }, + { role: 'bot', text: 'Done.' }, + ]; + + assert.equal(snippetOf(messages, 'ghana'), 'Health aid to Ghana'); +}); + +test('message text matches before a later card title, and stored plans never match', () => { + const messages = [ + { role: 'user', text: 'How much aid did Ghana get?' }, + { + role: 'bot', + text: 'About 1 billion dollars.', + plan: { title: 'Net disbursements to Kenya' }, + }, + { role: 'user', text: 'Edit', card: { title: 'Ghana chart' } }, + ]; + + assert.equal(snippetOf(messages, 'ghana'), 'How much aid did Ghana get?'); + assert.equal(snippetOf(messages, 'kenya'), undefined); + assert.equal(snippetOf('not a list', 'ghana'), undefined); +}); diff --git a/src/lib/proxy/forward.ts b/src/lib/proxy/forward.ts index 07ef469..78fd7d1 100644 --- a/src/lib/proxy/forward.ts +++ b/src/lib/proxy/forward.ts @@ -107,18 +107,6 @@ export const forward = async (req: NextRequest, options: ForwardOptions): Promis setCaller(headers, options.caller); - const earlyFailure = (error: unknown): Response => { - // A client that left aborted the read itself, so no one is answered. - if (!req.signal.aborted) { - console.error( - `relay failed before the first byte: ${new URL(options.target).pathname}`, - error, - ); - } - - return unreachableProblem(options.unreachable, limitHeaders); - }; - const hasBody = req.method !== 'GET' && req.method !== 'HEAD'; let upstream: Response; @@ -136,14 +124,32 @@ export const forward = async (req: NextRequest, options: ForwardOptions): Promis signal: req.signal, } as RequestInit); } catch { + // No response, so the upstream is taken not to have received the + // call: a connection reset after the body went out reads the same. return unreachableProblem(options.unreachable, limitHeaders); } + // The upstream answered `status`, so it received the call, and the + // problem document says so (upstream_status). + const earlyFailure = (error: unknown, status: number): Response => { + // A client that left aborted the read itself, so no one is answered. + if (!req.signal.aborted) { + console.error( + `relay failed before the first byte: ${new URL(options.target).pathname}`, + error, + ); + } + + return unreachableProblem(options.unreachable, limitHeaders, status); + }; + if (options.rewrite) { + const { status } = upstream; + try { upstream = await options.rewrite(upstream); } catch (error) { - return earlyFailure(error); + return earlyFailure(error, status); } } @@ -157,7 +163,7 @@ export const forward = async (req: NextRequest, options: ForwardOptions): Promis upstream, headers: out, compress: options.compress, - onEarlyFailure: earlyFailure, + onEarlyFailure: (error) => earlyFailure(error, upstream.status), }); }; diff --git a/src/lib/proxy/problem.ts b/src/lib/proxy/problem.ts index ae9dd67..5167159 100644 --- a/src/lib/proxy/problem.ts +++ b/src/lib/proxy/problem.ts @@ -127,6 +127,23 @@ export const rateLimitedProblem = ( headers: { ...headers, 'Retry-After': String(reset) }, }); -/** A 502 `transient_unavailable` problem document: the upstream did not answer. */ -export const unreachableProblem = (detail: string, headers: Record): Response => - problem({ status: 502, code: 'transient_unavailable', detail, retryable: true, headers }); +/** + * A 502 `transient_unavailable` problem document: the upstream did not + * answer. `upstreamStatus` is the status of an upstream that did answer and + * whose body then failed before the first byte was relayed; the document + * carries it as `upstream_status`, so a client can tell that the upstream + * received the call. + */ +export const unreachableProblem = ( + detail: string, + headers: Record, + upstreamStatus?: number, +): Response => + problem({ + status: 502, + code: 'transient_unavailable', + detail, + retryable: true, + headers, + extra: upstreamStatus === undefined ? undefined : { upstream_status: upstreamStatus }, + }); diff --git a/src/lib/proxy/test/problem.test.ts b/src/lib/proxy/test/problem.test.ts index 45987e4..e7ce404 100644 --- a/src/lib/proxy/test/problem.test.ts +++ b/src/lib/proxy/test/problem.test.ts @@ -62,6 +62,15 @@ test('unreachableProblem: a retryable 502 transient_unavailable problem with the assert.equal(body.retryable, true); }); +test('unreachableProblem: names the status of an upstream that answered before its body failed', async () => { + const before = await unreachableProblem('The studio agent is unreachable.', {}).json(); + const after = await unreachableProblem('The studio agent is unreachable.', {}, 422).json(); + + assert.equal('upstream_status' in before, false); + assert.equal(after.upstream_status, 422); + assert.equal(after.code, 'transient_unavailable'); +}); + test('problem: titles the statuses the site refuses with and spreads the extra members', async () => { for (const [status, title] of [ [400, 'Bad Request'], diff --git a/src/lib/studio/chips.ts b/src/lib/studio/chips.ts index 89def2b..9585cc9 100644 --- a/src/lib/studio/chips.ts +++ b/src/lib/studio/chips.ts @@ -45,15 +45,17 @@ const sameChip = (a: ApplyChoiceSuggestion, b: ApplyChoiceSuggestion): boolean = * such as a fork of a share: its chips go, since the harness holds none of * the answers that offered them. */ export const withoutChips = (messages: ChatMessage[]): ChatMessage[] => - messages.map((message) => - message.role === 'bot' && message.chips - ? { - role: 'bot', - text: message.text, - ...(message.followups && { followups: message.followups }), - } - : message, - ); + messages.map((message) => { + if (message.role !== 'bot' || !message.chips) { + return message; + } + + const rest = { ...message }; + + delete rest.chips; + + return rest; + }); /** The `detail` of the harness's refusal of a chip (422 with pointer * `/apply_choice`), or null for any other refusal. */ diff --git a/src/lib/studio/plan.ts b/src/lib/studio/plan.ts index 71c9c4b..7a06207 100644 --- a/src/lib/studio/plan.ts +++ b/src/lib/studio/plan.ts @@ -1,41 +1,123 @@ /** * The plan card's state and its `switch`: how a scope decision's options * read, which of them the user can pick, the interrupt reply a pick sends, - * and what the card says when the harness refuses one. + * what the card says when a switch does not apply, and the approved plan the + * answer keeps. */ import type { StreamRefusal } from './studioClient'; import type { InterruptResponse, PlanAction, + PlanApproved, PlanDecision, PlanDecisionOption, PlanMoved, PlanProposed, PlanStep, + PlanSwitchFailed, + ToolError, } from './wire'; /** A switch the user asked for and the harness has not answered yet. */ export type PlanSwitch = { choiceId: string; optionId: string }; +/** Why the last answer to the plan did not apply. A notice about a switch + * names the choice and option it was for, so the card says it on that + * choice's row and can try the option again. A notice about the whole plan + * (a stale card) names neither. */ +export type PlanNotice = { + // A short first line naming the option ("Could not switch to net + // disbursements."), null when `text` says it all. + lead: string | null; + text: string; + // Whether picking the option again could apply. False where the refusal + // would repeat (an option Search cannot serve, signed out, rate limited) + // and on a card that is not live. + retryable: boolean; + // When "Try again" shows (ms epoch): the delay the harness asked for, + // counted from the refusal's arrival. Null to offer it at once. + retryAt: number | null; + // An outcome the user chose (a switch they stopped), said without error + // styling. + quiet: boolean; + choiceId: string | null; + optionId: string | null; +}; + +/** A notice: `text`, and by default no lead, no "Try again", and no + * choice. */ +export const planNotice = ({ + text, + lead = null, + retryable = false, + retryAt = null, + quiet = false, + choiceId = null, + optionId = null, +}: Partial & { text: string }): PlanNotice => ({ + lead, + text, + retryable, + retryAt, + quiet, + choiceId, + optionId, +}); + +/** A scope choice whose option differs from the first plan of the turn: + * the label it held there, and why it moved when the harness said. */ +export type PlanChange = { choiceId: string; from: string; reason: string | null }; + +/** The option each scope choice held on the first plan of the turn that + * showed it, by `choice_id`. */ +export type PlanBaseline = Record; + /** A plan awaiting the user's approve, amend, reject or switch (the * harness's `plan.proposed` pause). */ export type PlanState = { + // What the card leads with: the harness's title, else the first question + // the user asked. title: string; + // One sentence saying what the plan counts, null from a harness that + // predates it. + summary: string | null; steps: PlanStep[]; + // The step whose result answers the question. + answerStepId: string | null; decisions: PlanDecision[]; + // What qualifies the answer, one reader sentence each; empty from a + // harness that predates them. + caveats: string[]; + // What the switch moved on a choice the card shows no row for (one + // without options, or one the plan does not list). + movedElsewhere: PlanMoved[]; version: number; actions: PlanAction[]; interruptId: string; + // The pause the harness sent is waiting on the user, so the card's + // controls answer it. False once a switch ended without the harness + // offering a plan again. The turn has ended and the card is read-only. + live: boolean; // The approval key a switch echoes, null from a harness that predates it. revision: string | null; - // What the switch that produced this plan changed, empty otherwise. - moved: PlanMoved[]; + // What the scope choices held on the first plan of the turn, and the + // ones that hold another option on this plan. + baseline: PlanBaseline; + changed: PlanChange[]; + // The choice whose switch applied and produced this plan, null + // otherwise and once another switch starts. + switched: string | null; + // The switch that produced this plan changed the step labels. False + // wherever `switched` is null. + stepsUpdated: boolean; switching: PlanSwitch | null; - // Why the harness refused the last switch, for the card to say. - notice: string | null; + notice: PlanNotice | null; }; +/** The plan the user approved, as the answer it produced keeps it. */ +export type ApprovedPlan = Omit; + export type SwitchReply = Required< Pick >; @@ -49,23 +131,236 @@ export const PLAN_REVISION_STALE = 'plan_revision_stale'; export const STALE_NOTICE = 'This plan changed after this page received it, so it can no longer be switched here. Reload the conversation to see the current plan.'; -export const planFromFrame = (frame: PlanProposed): PlanState => ({ - title: frame.title, - steps: frame.steps, - decisions: frame.decisions ?? [], - version: frame.version, - actions: frame.actions, - interruptId: frame.interrupt_id, - revision: frame.revision ?? null, - moved: frame.moved ?? [], - switching: null, - notice: null, -}); +/** The index of the step whose result answers the question, on a live + * plan and an approved one alike: the one the harness flags, else the last + * step in plan order that `answers` (one an ask names), else the last + * step. -1 without steps. */ +const answerIndex = ( + steps: readonly TStep[], + answers: (step: TStep) => boolean = () => false, +): number => { + const flagged = steps.findIndex((step) => step.gives_answer); + const named = steps.findLastIndex(answers); + + return flagged >= 0 ? flagged : named >= 0 ? named : steps.length - 1; +}; + +/** The id of the step that answers the question (answerIndex). */ +const answerStep = (frame: PlanProposed): string | null => { + const answering = new Set((frame.asks ?? []).flatMap((ask) => ask.answered_by ?? [])); + + return frame.steps[answerIndex(frame.steps, (step) => answering.has(step.id))]?.id ?? null; +}; + +/** The label of the option a scope decision counts by. */ +export const activeLabel = (decision: PlanDecision): string => + decision.options?.find((option) => isActiveOption(decision, option))?.label ?? decision.chosen; + +/** The label of one option of a scope choice, or null when the plan has no + * such option. */ +export const optionLabel = ( + decisions: PlanDecision[], + choiceId: string, + optionId: string, +): string | null => + decisions + .find((decision) => decision.choice_id === choiceId) + ?.options?.find((option) => option.option_id === optionId)?.label ?? null; + +/** The `switch_failed.code` of a switch the user stopped through `/cancel`, + * re-offering the unchanged plan. */ +export const SWITCH_CANCELLED = 'switch_cancelled'; + +/** The notice of a switch that did not reach the harness. The pause waits, + * so picking the option again can apply. */ +export const UNREACHABLE_NOTICE = 'Studio could not be reached.'; + +const ASK_AGAIN = 'Ask again to change the plan.'; + +/** "Could not switch to net disbursements." */ +const couldNotSwitch = (label: string): string => `Could not switch to ${label}.`; + +/** The switchable scope choices of `decisions`, by `choice_id`. A choice + * keeps its id across a conversation's switches. */ +const switchable = (decisions: PlanDecision[]): Map => + new Map( + decisions.flatMap((decision) => + decision.choice_id && switchOptions(decision) ? [[decision.choice_id, decision]] : [], + ), + ); -/** The plan offers switching: it lists the action and carries the revision - * a switch must echo. */ +const activeOptionId = (decision: PlanDecision): string | null => + decision.options?.find((option) => isActiveOption(decision, option))?.option_id ?? null; + +/** + * The baseline after a plan showing `choices`: `previous` as it was, plus + * what each choice it does not hold yet holds now. + */ +const baselineWith = ( + choices: Map, + previous: PlanBaseline = {}, +): PlanBaseline => { + const added = [...choices].flatMap(([choiceId, decision]) => + choiceId in previous + ? [] + : [[choiceId, { optionId: activeOptionId(decision), label: activeLabel(decision) }]], + ); + + return { ...previous, ...Object.fromEntries(added) }; +}; + +/** + * The scope choices that hold another option than on the first plan of the + * turn that showed them, with the label they held there and the harness's + * reason for the ones it says moved. A choice switched back to its first + * option is unchanged. + */ +const changesSince = ( + baseline: PlanBaseline, + choices: Map, + moved: PlanMoved[], +): PlanChange[] => + [...choices].flatMap(([choiceId, decision]) => { + const first = baseline[choiceId]; + const optionId = activeOptionId(decision); + const same = + first?.optionId && optionId + ? first.optionId === optionId + : first?.label === activeLabel(decision); + + if (!first || same) { + return []; + } + + const said = moved.find((entry) => entry.choice_id === choiceId); + + return [{ choiceId, from: first.label, reason: said?.reason || null }]; + }); + +/** Whether two plans' steps read differently, by label and order. */ +const stepsDiffer = (before: readonly PlanStep[], after: readonly PlanStep[]): boolean => + before.length !== after.length || before.some((step, i) => step.label !== after[i]?.label); + +/** The choice of `choices` that `previous` was switching, for focus to + * return to; the first row when the switch took it off the plan. */ +const switchedChoice = (previous: PlanState, choices: Map): string | null => { + const pick = previous.switching; + + if (!pick) { + return null; + } + + return choices.has(pick.choiceId) ? pick.choiceId : (choices.keys().next().value ?? null); +}; + +/** + * The notice on the row of `pick`, a switch that did not apply. With the + * harness's `detail`, a lead naming the option and the detail under it, + * unless the notice is `quiet` (the user stopped the switch), when the detail + * stands alone. Without one, `own(picked)`, the card's words. `askAgain` + * closes the text on a card that is no longer live. + */ +const switchNotice = ( + decisions: PlanDecision[], + pick: PlanSwitch | null, + { + detail, + own, + askAgain = false, + ...rest + }: Pick, 'retryable' | 'retryAt' | 'quiet'> & { + detail: string | null; + own: (picked: string) => string; + askAgain?: boolean; + }, +): PlanNotice => { + const picked = (pick && optionLabel(decisions, pick.choiceId, pick.optionId)) ?? 'that option'; + const text = detail || own(picked); + + return planNotice({ + ...rest, + lead: detail && !rest.quiet ? couldNotSwitch(picked) : null, + text: askAgain ? `${text} ${ASK_AGAIN}` : text, + choiceId: pick?.choiceId ?? null, + optionId: pick?.optionId ?? null, + }); +}; + +/** "Studio could not switch to net disbursements." */ +const studioCouldNotSwitch = (picked: string): string => `Studio could not switch to ${picked}.`; + +/** + * The harness's word on a switch that did not apply: a lead naming the + * option, then its reason. A switch the user stopped (`switch_cancelled`) + * did what they asked, so its reason stands alone, quietly. + */ +const switchFailedNotice = (decisions: PlanDecision[], failed: PlanSwitchFailed): PlanNotice => + switchNotice( + decisions, + { choiceId: failed.choice_id, optionId: failed.option_id }, + { + detail: failed.reason, + own: studioCouldNotSwitch, + // A harness that predates `retryable` leaves it out. + retryable: failed.retryable ?? true, + quiet: failed.code === SWITCH_CANCELLED, + }, + ); + +/** The card's headline: the harness's title, else the first question the + * user asked with its first letter raised. */ +const headline = (frame: PlanProposed): string => { + const ask = frame.asks?.[0]?.ask.trim(); + + if (frame.title.trim() || !ask) { + return frame.title; + } + + return ask.charAt(0).toUpperCase() + ask.slice(1); +}; + +/** + * The card for a `plan.proposed` frame. `previous` is the plan the turn + * offered before it (the waiting card of a switch, a plan amended, or the + * card a stale answer was sent from): its baseline carries over, so the card + * marks every choice that holds another option than on the turn's first + * plan. A frame carrying `switch_failed` says why on the row of the choice + * it names. + */ +export const planFromFrame = (frame: PlanProposed, previous?: PlanState): PlanState => { + const decisions = frame.decisions ?? []; + const failed = frame.switch_failed; + const rows = switchable(decisions); + const baseline = baselineWith(rows, previous?.baseline); + const moved = frame.moved ?? []; + const switched = previous && !failed ? switchedChoice(previous, rows) : null; + + return { + title: headline(frame), + summary: frame.summary || null, + steps: frame.steps, + answerStepId: answerStep(frame), + decisions, + caveats: frame.caveats ?? [], + movedElsewhere: moved.filter((entry) => !rows.has(entry.choice_id)), + version: frame.version, + actions: frame.actions, + interruptId: frame.interrupt_id, + live: true, + revision: frame.revision ?? null, + baseline, + changed: changesSince(baseline, rows, moved), + switched, + stepsUpdated: !!previous && switched !== null && stepsDiffer(previous.steps, frame.steps), + switching: null, + notice: failed ? switchFailedNotice(decisions, failed) : null, + }; +}; + +/** The plan offers switching: it is live, lists the action and carries the + * revision a switch must echo. */ export const canSwitch = (plan: PlanState): boolean => - plan.actions.includes('switch') && plan.revision !== null; + plan.live && plan.actions.includes('switch') && plan.revision !== null; /** A decision's switchable options, or null for an ordinary decision. */ export const switchOptions = (decision: PlanDecision): PlanDecisionOption[] | null => @@ -87,9 +382,6 @@ export const isSelectable = (decision: PlanDecision, option: PlanDecisionOption) export const yearsText = (years: readonly (readonly [number, number])[]): string => years.map(([first, last]) => (first === last ? `${first}` : `${first}–${last}`)).join(', '); -export const movedSentence = (moved: PlanMoved): string => - `${moved.from_label} changed to ${moved.to_label}. ${moved.reason}`.trim(); - /** * The interrupt reply that switches `choiceId` to `optionId`, or null when * the plan offers no such switch: switching is off, another switch is in @@ -120,10 +412,13 @@ export const switchReply = ( }; /** The plan while its switch is in flight: the card stays, its controls - * wait, and an earlier refusal's notice goes. */ + * wait, and an earlier refusal's notice and the mark of the switch before it + * go, so every card that comes of this switch failing has neither. */ export const startSwitch = (plan: PlanState, pick: PlanSwitch): PlanState => ({ ...plan, switching: pick, + switched: null, + stepsUpdated: false, notice: null, }); @@ -135,44 +430,191 @@ const isStale = (refusal: StreamRefusal): boolean => export const SWITCH_OPTION_UNAVAILABLE = 'switch_option_unavailable'; /** The card a stale switch or approve is answered with, the harness's - * current plan, saying why it replaced the one the user answered. Null for - * any other refusal, or a stale one without a current plan. */ -export const stalePlan = (refusal: StreamRefusal): PlanState | null => { + * current plan, saying why it replaced `previous`, the one the user + * answered, whose baseline it keeps. Null for any other refusal, or a stale + * one without a current plan. */ +export const stalePlan = (refusal: StreamRefusal, previous?: PlanState): PlanState | null => { const current = isStale(refusal) ? refusal.error?.current_plan : null; - return current ? { ...planFromFrame(current), notice: refusal.detail } : null; + return current + ? { + ...planFromFrame(current, previous && { ...previous, switching: null }), + notice: planNotice({ text: refusal.detail ?? STALE_NOTICE }), + } + : null; +}; + +/** + * Why a switch did not apply. With the harness's `detail`, a lead naming the + * option and the detail under it; without one, the card's own words (what + * the user picked, and what the plan still counts). + */ +const unappliedNotice = ( + plan: PlanState, + pick: PlanSwitch, + detail: string | null, + { retryable, retryAt }: Pick, +): PlanNotice => + switchNotice(plan.decisions, pick, { + detail, + own: (picked) => `${studioCouldNotSwitch(picked)}${stillCounts(plan, pick)}`, + retryable, + retryAt, + }); + +/** The sentence " The plan still counts gross disbursements." for the option + * the pick's choice holds, or nothing when the plan lacks the choice. */ +const stillCounts = (plan: PlanState, pick: PlanSwitch): string => { + const decision = plan.decisions.find((candidate) => candidate.choice_id === pick.choiceId); + + return decision ? ` The plan still counts ${activeLabel(decision)}.` : ''; }; /** - * The card after the harness refused its switch, saying why, or null when - * the refusal is not one the harness gives a switch. Those are a 409 - * `plan_revision_stale` and a 422 `switch_option_unavailable`; any other - * status (a 404 for an interrupt that is gone, 401, 429, 5xx) is a failed - * request, and the pause it answered is over. + * Picking the option again could apply: the harness's ToolError says to + * retry after a delay. A rate limit (429) also names the delay, since an + * immediate retry would meet it again. + */ +const retryableRefusal = ({ status, error }: StreamRefusal): boolean => + error?.retry === 'retry_after_delay' && (status !== 429 || !!error.retry_after_seconds); + +/** The site proxy's rate limit. The button's appearance carries the wait. */ +const BUSY_TEXT = 'Studio is busy.'; + +/** The site proxy answered for a harness it could not reach, without + * forwarding the switch (no `upstream_status`). */ +const proxyUnreached = ({ proxy }: StreamRefusal): boolean => + proxy?.code === 'transient_unavailable' && proxy.upstreamStatus === null; + +/** + * The harness refused the switch before touching the pause: another request + * holds the conversation (409 `transient_unavailable`), the plan does not + * offer the option (422 `switch_option_unavailable`), or the caller is rate + * limited (429, the harness's or the site proxy's, which answers without + * forwarding the switch). After any other refusal the pause may be gone or in use. + */ +const leavesPause = ({ status, error }: StreamRefusal): boolean => + status === 429 || + (status === 409 && error?.error === 'transient_unavailable') || + (status === 422 && error?.constraint_id === SWITCH_OPTION_UNAVAILABLE); + +/** + * The card after the harness refused its switch. The card always stays. * - * A stale revision brings the current plan, and switching works again on - * it. Without one the card keeps its revision, so an approve from it is - * refused as stale, and drops the switch action. + * A 409 `plan_revision_stale` brings the current plan, and switching works + * again on it. Without one the card keeps its revision, so an approve from it + * is refused as stale, and drops the switch action. A refusal that leaves + * the pause alone (leavesPause) says on the choice's row that the switch did + * not apply, with the harness's detail when a ToolError carries it and the + * card's own words otherwise, since a proxy's text is written for + * developers. It offers "Try again" only where a second try could apply + * (retryableRefusal), and once the ToolError's `retry_after_seconds`, when + * it sets them, have run from `now`, when the refusal arrived. The site + * proxy's rate limit says the studio is busy (BUSY_TEXT) and offers "Try + * again" once its Retry-After has run, or not at all when it names no wait. + * A switch the proxy could not forward is a request that did not reach the + * harness (unreachableSwitch). Any other refusal (a 404, a 401, a 5xx, a 422 + * `binding_invalid`, the proxy's 502 after the harness answered) ends the + * card read-only (endedPlan), with the harness's detail when a ToolError + * carries it and the card's own words otherwise. */ -export const refusedSwitch = (plan: PlanState, refusal: StreamRefusal): PlanState | null => { - const constraint = refusal.error?.constraint_id; +export const refusedSwitch = ( + plan: PlanState, + refusal: StreamRefusal, + now: number = Date.now(), +): PlanState => { + const pick = plan.switching; + const settled = { ...plan, switching: null }; - if (refusal.status === 409 && constraint === PLAN_REVISION_STALE) { + if (refusal.status === 409 && refusal.error?.constraint_id === PLAN_REVISION_STALE) { return ( - stalePlan(refusal) ?? { - ...plan, + stalePlan(refusal, plan) ?? { + ...settled, actions: plan.actions.filter((action) => action !== 'switch'), - switching: null, - notice: STALE_NOTICE, + notice: planNotice({ text: STALE_NOTICE }), } ); } - if (refusal.status === 422 && constraint === SWITCH_OPTION_UNAVAILABLE) { - return { ...plan, switching: null, notice: refusal.detail }; + if (proxyUnreached(refusal)) { + return unreachableSwitch(plan); + } + + const detail = refusal.error ? refusal.detail : null; + + if (!leavesPause(refusal)) { + return endedPlan(plan, { detail }); + } + + if (!pick) { + return settled; } - return null; + if (refusal.proxy?.code === 'rate_limited') { + const wait = refusal.proxy.retryAfterSeconds; + + return { + ...settled, + notice: unappliedNotice(plan, pick, BUSY_TEXT, { + retryable: !!wait, + retryAt: wait ? now + wait * 1000 : null, + }), + }; + } + + const retryable = retryableRefusal(refusal); + const seconds = refusal.error?.retry_after_seconds; + + return { + ...settled, + notice: unappliedNotice(plan, pick, detail, { + retryable, + retryAt: retryable && seconds ? now + seconds * 1000 : null, + }), + }; +}; + +/** + * The card after its switch request never reached the harness (the request + * failed before any response). The pause waits, so the card stays live + * and its row offers "Try again". + */ +export const unreachableSwitch = (plan: PlanState): PlanState => { + const pick = plan.switching; + const settled = { ...plan, switching: null }; + + return pick + ? { + ...settled, + notice: unappliedNotice(plan, pick, UNREACHABLE_NOTICE, { + retryable: true, + retryAt: null, + }), + } + : settled; +}; + +/** The card once the user stopped a switch before its request started (it + * waited behind the stream that offered the plan). Nothing reached the + * harness, so the pause waits and the card stays live, saying + * quietly on the choice's row that the plan is unchanged, with Try again. */ +export const stoppedQueuedSwitch = (plan: PlanState): PlanState => { + const pick = plan.switching; + const settled = { ...plan, switching: null }; + + return pick + ? { + ...settled, + notice: switchNotice(plan.decisions, pick, { + detail: null, + own: (picked) => + `Stopped before switching to ${picked}.${stillCounts(plan, pick)}`, + retryable: true, + retryAt: null, + quiet: true, + }), + } + : settled; }; /** An `approve` of `plan`, carrying its revision so the harness can tell an @@ -184,7 +626,260 @@ export const approveReply = ( ? { decision: 'approve', plan_revision: plan.revision } : { decision: 'approve' }; -/** A plan whose switch ended without a fresh plan (an error, a stop, a turn - * that finished) leaves the card. Any other plan stays. */ -export const settledPlan = (plan: PlanState | null): PlanState | null => - plan?.switching ? null : plan; +/** How a switch's stream ended without a plan: the error's `detail` when it + * sent one, and whether the user stopped it. */ +export type SwitchEnding = { detail?: string | null; stopped?: boolean }; + +/** + * The card once a switch's stream ended without the harness offering a plan + * again (it broke, sent an error, closed, or was stopped and the harness did + * not re-offer), or the harness refused it in a way that may have used up + * the pause (refusedSwitch). The turn has ended, so the card stays + * read-only, saying in one line on the choice's row that the switch did not + * apply and to ask again. + */ +export const endedPlan = (plan: PlanState, { detail, stopped }: SwitchEnding = {}): PlanState => ({ + ...plan, + live: false, + switching: null, + notice: switchNotice(plan.decisions, plan.switching, { + detail: stopped ? null : (detail ?? null), + own: stopped ? (picked) => `Stopped before switching to ${picked}.` : studioCouldNotSwitch, + askAgain: true, + // The user asked for the stop. + quiet: !!stopped, + }), +}); + +/** How a switch's stream ended without a fresh plan: the harness's error + * frame, whether the user stopped it, and the read-only card a refusal + * ended it on. */ +export type SwitchStreamEnd = { + error: ToolError | null; + stopped: boolean; + refused: PlanState | null; +}; + +/** + * The read-only card a switch's stream ended on without a fresh plan, and + * how its Activity entry closes. A stop the harness answered by closing the + * stream, cancelling the turn or refusing the switch is skipped. The Stop's + * cancel can reach the harness before the switch, which then finds no pause + * (a 404 or 409). Every other end failed and the switch did not apply. That + * covers a refusal (its card), a harness error (its reason, a stop before it + * included), and a stream that closed, broke or sent `done`. + */ +export const endedSwitch = ( + waiting: PlanState, + { error, stopped, refused }: SwitchStreamEnd, +): { plan: PlanState; activity: 'skipped' | 'failed' } => { + if (stopped && (!error || error.error === 'cancelled')) { + return { plan: endedPlan(waiting, { stopped }), activity: 'skipped' }; + } + + if (refused) { + return { plan: refused, activity: 'failed' }; + } + + return { plan: endedPlan(waiting, { detail: error?.detail }), activity: 'failed' }; +}; + +/** + * The conversation once a switch's stream ended without a fresh plan: the + * waiting card gives way to `ended`, its read-only form (endedPlan). A state + * whose card is not the waiting one (a fresh plan or a refusal replaced it) + * is returned as is. The caller ends the turn on screen. + */ +export const switchEnded = ( + state: TState, + waiting: PlanState, + ended: PlanState, +): TState => (state.plan === waiting ? { ...state, plan: ended } : state); + +/** The approved plan an answer keeps, from its `plan.approved` frame. */ +export const approvedFromFrame = (frame: PlanApproved): ApprovedPlan => ({ + plan_version_id: frame.plan_version_id, + version: frame.version, + revision: frame.revision ?? null, + title: frame.title, + summary: frame.summary ?? null, + // A stored frame may lack either list. + steps: frame.steps ?? [], + choices: frame.choices ?? [], + caveats: frame.caveats ?? [], +}); + +/** The transcript with `plan` kept on the reply the approve resumed: the + * last bot message, which the resumed stream writes into. */ +export const withApprovedPlan = ( + messages: TMessage[], + plan: ApprovedPlan, +): TMessage[] => { + const last = messages.at(-1); + + return last?.role === 'bot' ? [...messages.slice(0, -1), { ...last, plan }] : messages; +}; + +// --- What the card and the modal read -------------------------------------- + +export type PlanStepView = { + key: string; + label: string; + description: string | null; + givesAnswer: boolean; +}; + +/** One scope choice as a row: its name, the value it holds, what it held + * before a switch, why the plan holds it, and the decision behind it when + * the row can switch. */ +export type PlanChoiceView = { + key: string; + name: string; + value: string; + changedFrom: string | null; + changedReason: string | null; + reason: string | null; + decision: PlanDecision | null; +}; + +/** A choice a switch moved that has no row: what it held, what it holds + * now, and why. */ +export type PlanMoveView = { key: string; from: string; to: string; reason: string | null }; + +export type PlanOtherView = { key: string; question: string; chosen: string; reason: string }; + +/** A plan as the card and the modal show it, live or approved. */ +export type PlanView = { + title: string; + version: number; + summary: string | null; + steps: PlanStepView[]; + choices: PlanChoiceView[]; + moved: PlanMoveView[]; + others: PlanOtherView[]; + // What qualifies the answer, one reader sentence each. + caveats: string[]; +}; + +export const planView = (plan: PlanState): PlanView => { + const choices: PlanChoiceView[] = []; + const others: PlanOtherView[] = []; + + for (const decision of plan.decisions) { + const choiceId = switchOptions(decision) && decision.choice_id; + + if (choiceId) { + const change = plan.changed.find((entry) => entry.choiceId === choiceId); + + choices.push({ + key: choiceId, + name: decision.question, + value: activeLabel(decision), + changedFrom: change?.from ?? null, + changedReason: change?.reason ?? null, + reason: decision.reason || null, + decision, + }); + } else { + others.push({ + key: decision.id, + question: decision.question, + chosen: decision.chosen, + reason: decision.reason, + }); + } + } + + return { + title: plan.title, + version: plan.version, + summary: plan.summary, + steps: plan.steps.map((step) => ({ + key: step.id, + label: step.label, + description: step.description || null, + givesAnswer: step.id === plan.answerStepId, + })), + choices, + moved: plan.movedElsewhere.map((entry) => ({ + key: entry.choice_id, + from: entry.from_label, + to: entry.to_label, + reason: entry.reason || null, + })), + others, + caveats: plan.caveats, + }; +}; + +export const approvedView = (plan: ApprovedPlan): PlanView => { + // A stored message may lack either list, as approvedFromFrame allows. + const steps = plan.steps ?? []; + const answer = answerIndex(steps); + + return { + title: plan.title, + version: plan.version, + summary: plan.summary || null, + steps: steps.map((step, index) => ({ + key: String(index), + label: step.label, + description: null, + givesAnswer: index === answer, + })), + choices: (plan.choices ?? []).map((choice, index) => ({ + key: String(index), + name: choice.question, + value: choice.chosen, + changedFrom: choice.changed_from || null, + changedReason: null, + reason: null, + decision: null, + })), + moved: [], + others: [], + // A plan saved before the harness sent caveats has none. + caveats: plan.caveats ?? [], + }; +}; + +export const STEPS_UPDATED = 'Steps updated to match.'; + +/** + * What the switch that produced `plan` changed, a sentence each, for the + * card's one live region: the rows that hold another option than on the + * turn's first plan, the choices moved without a row, and whether the steps + * changed. Empty on a plan no switch produced. A switch that changed none of + * these (back to the first option, the same steps) still says the plan + * updated. + */ +export const switchOutcome = (view: PlanView, plan: PlanState): string[] => { + if (plan.switched === null) { + return []; + } + + const lines = [ + ...view.choices.flatMap((choice) => + choice.changedFrom + ? [ + `${choice.name} is now ${choice.value}, changed from ${choice.changedFrom}.${ + choice.changedReason ? ` ${choice.changedReason}` : '' + }`, + ] + : [], + ), + ...view.moved.map( + (move) => + `Changed from ${move.from} to ${move.to}.${move.reason ? ` ${move.reason}` : ''}`, + ), + ...(plan.stepsUpdated ? [STEPS_UPDATED] : []), + ]; + + return lines.length > 0 ? lines : ['Plan updated.']; +}; + +/** The step whose result answers the question. */ +export const answerStepOf = (view: PlanView): PlanStepView | undefined => + view.steps.find((step) => step.givesAnswer); + +export const stepCount = (count: number): string => `${count} ${count === 1 ? 'step' : 'steps'}`; diff --git a/src/lib/studio/studioClient.ts b/src/lib/studio/studioClient.ts index 549d43d..b87dfc0 100644 --- a/src/lib/studio/studioClient.ts +++ b/src/lib/studio/studioClient.ts @@ -29,6 +29,14 @@ import type { */ export const STOP_GRACE_MS = 10_000; +/** + * How long the page waits on a stop of a plan-card switch. The harness reads + * a stopped switch's stream on for up to its `SWITCH_REOFFER_SECONDS` (10 s) + * to re-offer the plan, so this grace is that window plus 5 s for the + * re-offer to arrive. A shorter one would close the stream on a live pause. + */ +export const SWITCH_STOP_GRACE_MS = 15_000; + const transportError = (detail: string): StudioFrame => ({ event: 'error', seq: -1, @@ -39,22 +47,36 @@ const transportError = (detail: string): StudioFrame => ({ } as ToolError, }); +/** A problem document the site proxy answered with itself + * (src/lib/proxy/problem). It carries the `code`, the status of a harness + * that answered before the relay failed (`upstream_status`, null when the + * harness was not reached), and the Retry-After seconds. */ +export type ProxyProblem = { + code: string; + upstreamStatus: number | null; + retryAfterSeconds: number | null; +}; + /** An HTTP refusal of a stream request: its status, the body's `detail` - * when it has one, and the harness's ToolError when the body is one - * (with `current_plan` on a stale plan answer). */ + * when it has one, the harness's ToolError when the body is one (with + * `current_plan` on a stale plan answer), and `proxy` when the site proxy + * refused the request itself. */ export type StreamRefusal = { status: number; detail: string | null; error: PlanRevisionStaleError | null; + proxy?: ProxyProblem; }; -/** What a stream reports to its caller. `onRefusal` sees an HTTP refusal - * before the stream would report it as an `error` frame, and returns true - * when it has dealt with it. */ +/** What a stream reports to its caller. `onRefusal` sees an HTTP refusal, + * and `onUnreachable` a request that got no response at all (the fetch + * rejected), before the stream would report either as an `error` frame. + * Each returns true when it has dealt with it. */ export type StreamHandlers = { onFrame: (frame: StudioFrame) => void; onKeepAlive?: () => void; onRefusal?: (refusal: StreamRefusal) => boolean; + onUnreachable?: () => boolean; }; const asToolError = (body: unknown): PlanRevisionStaleError | null => @@ -65,10 +87,28 @@ const asToolError = (body: unknown): PlanRevisionStaleError | null => ? (body as ToolError) : null; +// The proxy's problem documents carry a `code` and no harness `error`. +const asProxyProblem = (body: unknown, headers: Headers): ProxyProblem | null => { + if (body === null || typeof body !== 'object' || 'error' in body) { + return null; + } + + const { code, upstream_status: upstreamStatus } = body as Record; + const retryAfter = Number.parseInt(headers.get('retry-after') ?? '', 10); + + return typeof code === 'string' + ? { + code, + upstreamStatus: typeof upstreamStatus === 'number' ? upstreamStatus : null, + retryAfterSeconds: Number.isFinite(retryAfter) ? retryAfter : null, + } + : null; +}; + const streamStudio = async ( endpoint: 'turn' | 'interrupt', body: TurnRequest | InterruptResponse, - { onFrame, onKeepAlive, onRefusal }: StreamHandlers, + { onFrame, onKeepAlive, onRefusal, onUnreachable }: StreamHandlers, signal?: AbortSignal, ): Promise => { let res: Response; @@ -85,7 +125,9 @@ const streamStudio = async ( throw error; } - onFrame(transportError((error as Error).message)); + if (!onUnreachable?.()) { + onFrame(transportError((error as Error).message)); + } return; } @@ -93,11 +135,13 @@ const streamStudio = async ( if (!res.ok || !res.body) { let detail: string | null = null; let error: PlanRevisionStaleError | null = null; + let proxy: ProxyProblem | null = null; try { const parsed = (await res.json()) as { detail?: unknown }; error = asToolError(parsed); + proxy = asProxyProblem(parsed, res.headers); if (typeof parsed.detail === 'string') { detail = parsed.detail; @@ -106,7 +150,10 @@ const streamStudio = async ( // Non-JSON body — the status line stands. } - if (!res.ok && onRefusal?.({ status: res.status, detail, error })) { + if ( + !res.ok && + onRefusal?.({ status: res.status, detail, error, ...(proxy && { proxy }) }) + ) { return; } diff --git a/src/lib/studio/test/plan.test.ts b/src/lib/studio/test/plan.test.ts index 49e4e14..5fd6c1b 100644 --- a/src/lib/studio/test/plan.test.ts +++ b/src/lib/studio/test/plan.test.ts @@ -8,22 +8,37 @@ import test from 'node:test'; import { PLAN_REVISION_STALE, approveReply, + approvedFromFrame, + approvedView, + answerStepOf, canSwitch, STALE_NOTICE, + STEPS_UPDATED, SWITCH_OPTION_UNAVAILABLE, isActiveOption, isSelectable, - movedSentence, planFromFrame, + planView, refusedSwitch, - settledPlan, + endedPlan, + endedSwitch, + SWITCH_CANCELLED, stalePlan, startSwitch, + stoppedQueuedSwitch, + switchEnded, + switchOutcome, switchReply, + UNREACHABLE_NOTICE, + unreachableSwitch, + withApprovedPlan, yearsText, + type PlanState, } from '../plan.ts'; +import { withoutChips } from '../chips.ts'; import type { StreamRefusal } from '../studioClient.ts'; -import type { PlanDecision, PlanProposed } from '../wire.ts'; +import type { PlanApproved, PlanDecision, PlanProposed, RetryMode } from '../wire.ts'; +import type { ChatMessage } from '../../../components/organisms/Studio/hooks/useChat.ts'; const countDecision: PlanDecision = { id: 'd1', @@ -75,28 +90,489 @@ const frame = (overrides: Partial = {}): PlanProposed => ({ ...overrides, }); -test('planFromFrame keeps the revision and what moved', () => { - const moved = [ - { - choice_id: 'spec_1:measure_basis', - from_label: 'Gross disbursements', - to_label: 'Net disbursements', - reason: 'Repayments are now subtracted.', - }, - ]; - const plan = planFromFrame(frame({ revision: 'rev-b', moved })); +const PICK = { choiceId: 'spec_1:measure_basis', optionId: 'measure_basis:net' }; + +/** The fresh plan a switch to net disbursements re-offers. */ +const switchedFrame = (overrides: Partial = {}): PlanProposed => + frame({ + interrupt_id: 'int-2', + version: 2, + revision: 'rev-b', + decisions: [ + { + ...countDecision, + chosen: 'Net disbursements', + active_option_id: 'measure_basis:net', + }, + ], + ...overrides, + }); + +test('planFromFrame keeps the revision and marks nothing changed without a switch', () => { + const plan = planFromFrame(frame({ revision: 'rev-b' })); assert.equal(plan.revision, 'rev-b'); - assert.deepEqual(plan.moved, moved); + assert.deepEqual(plan.changed, []); assert.equal(plan.switching, null); assert.equal(plan.notice, null); }); -test('planFromFrame reads a harness without revision or moved as none', () => { - const plan = planFromFrame(frame({ revision: undefined, moved: undefined })); +test('a harness without summary, gives_answer or revision still gives a whole card', () => { + const plan = planFromFrame( + frame({ + revision: undefined, + moved: undefined, + steps: [ + { id: 's1', label: 'Retrieve health aid' }, + { id: 's2', label: 'Sum across recipients' }, + ], + }), + ); + const view = planView(plan); assert.equal(plan.revision, null); - assert.deepEqual(plan.moved, []); + assert.equal(view.title, 'Health aid to Ghana'); + assert.equal(view.summary, null); + assert.deepEqual( + view.steps.map((step) => [step.label, step.givesAnswer]), + [ + ['Retrieve health aid', false], + ['Sum across recipients', true], + ], + ); + assert.deepEqual( + view.choices.map((choice) => [choice.key, choice.value, choice.changedFrom]), + [['spec_1:measure_basis', 'Gross disbursements', null]], + ); + assert.deepEqual( + view.others.map((other) => other.key), + ['d2'], + ); +}); + +test('a plan shows its caveats and why it holds each choice; one without caveats shows none', () => { + const caveat = 'The total counts every provider, the health figure bilateral aid only.'; + const view = planView(planFromFrame(frame({ caveats: [caveat] }))); + + assert.deepEqual(view.caveats, [caveat]); + assert.deepEqual( + view.choices.map((choice) => choice.reason), + ['the source publishes this basis'], + ); + assert.deepEqual(planView(planFromFrame(frame({ caveats: undefined }))).caveats, []); + assert.deepEqual(planView(planFromFrame(frame({ caveats: null }))).caveats, []); +}); + +test('the answer step is the flagged one, else the last step an ask names', async (t) => { + const steps = [ + { id: 's1', label: 'Fetch' }, + { id: 's2', label: 'Divide' }, + { id: 's3', label: 'Chart' }, + ]; + const cases: [string, Partial, string][] = [ + [ + 'flagged', + { steps: steps.map((step) => ({ ...step, gives_answer: step.id === 's2' })) }, + 'Divide', + ], + ['an ask', { steps, asks: [{ ask: 'share', answered_by: ['s2', 'missing'] }] }, 'Divide'], + [ + 'asks naming steps out of plan order', + { + steps, + asks: [ + { ask: 'chart', answered_by: ['s3'] }, + { ask: 'share', answered_by: ['s1'] }, + ], + }, + 'Chart', + ], + ['neither', { steps }, 'Chart'], + ]; + + for (const [name, overrides, label] of cases) { + await t.test(name, () => { + assert.equal(answerStepOf(planView(planFromFrame(frame(overrides))))?.label, label); + }); + } +}); + +test('a plan re-offered after a switch marks the switched choice and what it held', () => { + const fresh = planFromFrame(switchedFrame(), startSwitch(planFromFrame(frame()), PICK)); + const [choice] = planView(fresh).choices; + + assert.deepEqual(fresh.changed, [ + { choiceId: 'spec_1:measure_basis', from: 'Gross disbursements', reason: null }, + ]); + assert.equal(choice?.value, 'Net disbursements'); + assert.equal(choice?.changedFrom, 'Gross disbursements'); +}); + +test('a switch announces "Steps updated to match." only when the step labels differ', async (t) => { + const waiting = startSwitch(planFromFrame(frame()), PICK); + const cases: [string, Partial, boolean][] = [ + ['the same steps', {}, false], + [ + 'the same labels under new ids', + { steps: [{ id: 's9', label: 'Retrieve health aid' }] }, + false, + ], + ['another label', { steps: [{ id: 's1', label: 'Retrieve net health aid' }] }, true], + [ + 'another number of steps', + { + steps: [ + { id: 's1', label: 'Retrieve health aid' }, + { id: 's2', label: 'Sum across recipients' }, + ], + }, + true, + ], + ]; + + for (const [name, overrides, updated] of cases) { + await t.test(name, () => { + const fresh = planFromFrame(switchedFrame(overrides), waiting); + + assert.equal(switchOutcome(planView(fresh), fresh).includes(STEPS_UPDATED), updated); + }); + } + + // A plan no switch produced, and one whose next switch has started, announce nothing. + const unswitched = planFromFrame(frame({ steps: [{ id: 's1', label: 'Other' }] })); + const fresh = planFromFrame(switchedFrame({ steps: [{ id: 's1', label: 'Other' }] }), waiting); + + assert.deepEqual(switchOutcome(planView(unswitched), unswitched), []); + assert.deepEqual( + switchOutcome(planView(startSwitch(fresh, PICK)), startSwitch(fresh, PICK)), + [], + ); +}); + +test('a switch outcome names each changed row and each move, for one live region', () => { + const fresh = planFromFrame( + switchedFrame({ + decisions: [basisDecision('measure_basis:net'), sectorDecision('sector:120')], + moved: [ + { + choice_id: 'spec_2:gone', + from_label: 'Health and population', + to_label: 'Health', + reason: 'Net figures cover health alone.', + }, + ], + }), + startSwitch(planFromFrame(frame()), PICK), + ); + + assert.deepEqual(switchOutcome(planView(fresh), fresh), [ + 'How is aid counted? is now Net disbursements, changed from Gross disbursements.', + 'Changed from Health and population to Health. Net figures cover health alone.', + ]); +}); + +test('a switch back to the first option still announces that the plan updated', () => { + const v2 = planFromFrame(switchedFrame(), startSwitch(planFromFrame(frame()), PICK)); + const v3 = planFromFrame( + switchedFrame({ version: 3, decisions: [basisDecision('measure_basis:gross')] }), + startSwitch(v2, { ...PICK, optionId: 'measure_basis:gross' }), + ); + + assert.deepEqual(switchOutcome(planView(v3), v3), ['Plan updated.']); +}); + +/** A sector choice on spec `spec`, holding `active`. */ +const sectorDecision = ( + active: 'sector:120' | 'sector:120+130', + spec = 'spec_1', +): PlanDecision => ({ + ...countDecision, + id: `sector_${spec}`, + question: 'Sector', + chosen: active === 'sector:120' ? 'Health' : 'Health and population', + choice_id: `${spec}:sector`, + active_option_id: active, + options: [ + { + option_id: 'sector:120+130', + label: 'Health and population', + status: 'available', + reason: null, + years: null, + }, + { + option_id: 'sector:120', + label: 'Health', + status: 'available', + reason: null, + years: null, + }, + ], +}); + +const basisDecision = (active: string, spec = 'spec_1'): PlanDecision => ({ + ...countDecision, + choice_id: `${spec}:measure_basis`, + active_option_id: active, +}); + +test('moved names a choice the switch moved besides the picked one, with why', () => { + const fresh = planFromFrame( + switchedFrame({ + decisions: [basisDecision('measure_basis:net'), sectorDecision('sector:120')], + moved: [ + { + choice_id: 'spec_1:sector', + from_label: 'Health and population', + to_label: 'Health', + reason: 'Net figures cover health alone.', + }, + ], + }), + startSwitch( + planFromFrame(frame({ decisions: [countDecision, sectorDecision('sector:120+130')] })), + PICK, + ), + ); + + assert.deepEqual(fresh.changed, [ + { choiceId: 'spec_1:measure_basis', from: 'Gross disbursements', reason: null }, + { + choiceId: 'spec_1:sector', + from: 'Health and population', + reason: 'Net figures cover health alone.', + }, + ]); +}); + +test('after two switches both rows are Changed from the first plan', () => { + const v1 = planFromFrame( + frame({ + decisions: [basisDecision('measure_basis:gross'), sectorDecision('sector:120+130')], + }), + ); + const v2 = planFromFrame( + switchedFrame({ + decisions: [basisDecision('measure_basis:net'), sectorDecision('sector:120+130')], + }), + startSwitch(v1, PICK), + ); + const v3 = planFromFrame( + switchedFrame({ + interrupt_id: 'int-3', + version: 3, + decisions: [basisDecision('measure_basis:net'), sectorDecision('sector:120')], + }), + startSwitch(v2, { choiceId: 'spec_1:sector', optionId: 'sector:120' }), + ); + + assert.deepEqual(v3.changed, [ + { choiceId: 'spec_1:measure_basis', from: 'Gross disbursements', reason: null }, + { choiceId: 'spec_1:sector', from: 'Health and population', reason: null }, + ]); + assert.equal(v3.switched, 'spec_1:sector'); + assert.deepEqual( + planView(v3).choices.map((choice) => [choice.name, choice.changedFrom]), + [ + ['How is aid counted?', 'Gross disbursements'], + ['Sector', 'Health and population'], + ], + ); +}); + +test('a plan that drops one of two choices keeps Changed and the focus target', async (t) => { + const numerator = (active: string) => ({ ...basisDecision(active, 'r1'), id: 'd-num' }); + const denominator = (active: string) => ({ ...basisDecision(active, 'r2'), id: 'd-den' }); + const sector = sectorDecision('sector:120+130', 'r1'); + const gross = 'measure_basis:gross'; + const net = 'measure_basis:net'; + // [name, v1 decisions, the choice switched, v2 decisions, Changed, focus] + const cases: [string, PlanDecision[], string, PlanDecision[], string[], string][] = [ + [ + 'the switched choice survives', + [numerator(gross), denominator(gross)], + 'r2:measure_basis', + [denominator(net)], + ['r2:measure_basis'], + 'r2:measure_basis', + ], + [ + // A merged row keeps its first member's id in plan order, so a + // switch of the second member finds no row of its own and focus + // goes to the first row, which is the merged one. + 'a merge of the switched choice keeps the first member id, the first row', + [numerator(gross), denominator(gross)], + 'r2:measure_basis', + [numerator(net)], + ['r1:measure_basis'], + 'r1:measure_basis', + ], + [ + 'the switched choice left the plan', + [sector, numerator(gross)], + 'r1:measure_basis', + [sector], + [], + 'r1:sector', + ], + ]; + + for (const [name, first, choiceId, second, changed, focus] of cases) { + await t.test(name, () => { + const v1 = planFromFrame(frame({ decisions: first })); + const v2 = planFromFrame( + switchedFrame({ decisions: second }), + startSwitch(v1, { choiceId, optionId: net }), + ); + + assert.deepEqual( + v2.changed.map((change) => [change.choiceId, change.from]), + changed.map((id) => [id, 'Gross disbursements']), + ); + assert.equal(v2.switched, focus); + }); + } +}); + +test('a choice first shown on a later plan is Changed from what it held there', () => { + const v1 = planFromFrame(frame({ decisions: [basisDecision('measure_basis:gross', 'r1')] })); + const v2 = planFromFrame( + switchedFrame({ + decisions: [ + basisDecision('measure_basis:net', 'r1'), + sectorDecision('sector:120+130', 'r1'), + ], + }), + startSwitch(v1, { choiceId: 'r1:measure_basis', optionId: 'measure_basis:net' }), + ); + const v3 = planFromFrame( + switchedFrame({ + version: 3, + decisions: [ + basisDecision('measure_basis:net', 'r1'), + sectorDecision('sector:120', 'r1'), + ], + }), + startSwitch(v2, { choiceId: 'r1:sector', optionId: 'sector:120' }), + ); + + assert.deepEqual( + v3.changed.map((change) => [change.choiceId, change.from]), + [ + ['r1:measure_basis', 'Gross disbursements'], + ['r1:sector', 'Health and population'], + ], + ); +}); + +test('a choice switched back to its first option is not Changed', () => { + const v1 = planFromFrame(frame({ decisions: [basisDecision('measure_basis:gross')] })); + const v2 = planFromFrame( + switchedFrame({ decisions: [basisDecision('measure_basis:net')] }), + startSwitch(v1, PICK), + ); + const v3 = planFromFrame( + switchedFrame({ version: 3, decisions: [basisDecision('measure_basis:gross')] }), + startSwitch(v2, { choiceId: PICK.choiceId, optionId: 'measure_basis:gross' }), + ); + + assert.deepEqual( + v2.changed.map((change) => change.choiceId), + [PICK.choiceId], + ); + assert.deepEqual(v3.changed, []); + assert.equal(v3.switched, PICK.choiceId); +}); + +test('a choice added on an axis another choice holds is Changed only against itself', () => { + const v1 = planFromFrame(frame({ decisions: [basisDecision('measure_basis:gross', 'r1')] })); + const v2 = planFromFrame( + switchedFrame({ + decisions: [ + basisDecision('measure_basis:net', 'r1'), + { ...basisDecision('measure_basis:net', 'r2'), id: 'd-new' }, + ], + }), + startSwitch(v1, { choiceId: 'r1:measure_basis', optionId: 'measure_basis:net' }), + ); + + assert.deepEqual( + v2.changed.map((change) => change.choiceId), + ['r1:measure_basis'], + ); + assert.equal(v2.baseline['r2:measure_basis']?.optionId, 'measure_basis:net'); + assert.equal(v2.switched, 'r1:measure_basis'); +}); + +test('a moved choice without a row of its own still shows what moved', () => { + const fresh = planFromFrame( + switchedFrame({ + decisions: [ + basisDecision('measure_basis:net'), + { ...sectorDecision('sector:120'), options: [] }, + ], + moved: [ + { + choice_id: 'spec_1:sector', + from_label: 'Health and population', + to_label: 'Health', + reason: 'Net figures cover health alone.', + }, + { + choice_id: 'spec_9:provider', + from_label: 'All donors', + to_label: 'Bilateral donors', + reason: '', + }, + ], + }), + startSwitch(planFromFrame(frame()), PICK), + ); + const view = planView(fresh); + + assert.deepEqual( + view.choices.map((choice) => choice.key), + ['spec_1:measure_basis'], + ); + assert.deepEqual(view.moved, [ + { + key: 'spec_1:sector', + from: 'Health and population', + to: 'Health', + reason: 'Net figures cover health alone.', + }, + { key: 'spec_9:provider', from: 'All donors', to: 'Bilateral donors', reason: null }, + ]); +}); + +test('switch_failed puts its reason on the row of the choice it names', () => { + const reason = 'Net figures for 2023 are not published. The plan still counts gross.'; + const failed = { + choice_id: 'spec_1:measure_basis', + option_id: 'measure_basis:net', + code: 'operation_unsupported', + reason, + }; + const plan = planFromFrame(frame({ revision: 'rev-c', switch_failed: failed })); + + assert.deepEqual(plan.notice, { + lead: 'Could not switch to Net disbursements.', + text: reason, + retryable: true, + retryAt: null, + quiet: false, + ...PICK, + }); + assert.equal( + planFromFrame(frame({ switch_failed: { ...failed, retryable: false } })).notice?.retryable, + false, + ); + assert.deepEqual(switchReply(plan, PICK.choiceId, PICK.optionId), { + decision: 'switch', + choice_id: PICK.choiceId, + option_id: PICK.optionId, + plan_revision: 'rev-c', + }); }); test('switchReply sends the choice, the option and the revision the user saw', () => { @@ -196,7 +672,18 @@ test('a stale switch is answered with the current card, which switches again', ( }); const replaced = refusedSwitch(waiting, staleRefusal(currentFrame)); - assert.deepEqual(replaced, { ...planFromFrame(currentFrame), notice: STALE_DETAIL }); + assert.deepEqual(replaced, { + ...planFromFrame(currentFrame), + notice: { + lead: null, + text: STALE_DETAIL, + retryable: false, + retryAt: null, + quiet: false, + choiceId: null, + optionId: null, + }, + }); assert.deepEqual(switchReply(replaced, 'spec_1:measure_basis', 'measure_basis:net'), { decision: 'switch', choice_id: 'spec_1:measure_basis', @@ -205,10 +692,37 @@ test('a stale switch is answered with the current card, which switches again', ( }); }); +test('a stale refusal keeps the turn baseline, so a choice moved elsewhere is Changed', () => { + const v1 = planFromFrame(frame()); + const v2 = planFromFrame(switchedFrame(), startSwitch(v1, PICK)); + const waiting = startSwitch(v2, { + choiceId: PICK.choiceId, + optionId: 'measure_basis:gross', + }); + const replaced = refusedSwitch( + waiting, + staleRefusal(switchedFrame({ interrupt_id: 'int-3', version: 3, revision: 'rev-c' })), + ); + + assert.deepEqual(replaced.changed, [ + { choiceId: PICK.choiceId, from: 'Gross disbursements', reason: null }, + ]); + assert.equal(replaced.switched, null); + assert.equal(replaced.live, true); +}); + test('a stale approve is answered with the current card; other refusals are not', () => { assert.deepEqual(stalePlan(staleRefusal(currentFrame)), { ...planFromFrame(currentFrame), - notice: STALE_DETAIL, + notice: { + lead: null, + text: STALE_DETAIL, + retryable: false, + retryAt: null, + quiet: false, + choiceId: null, + optionId: null, + }, }); assert.equal(stalePlan(staleRefusal()), null); assert.equal( @@ -238,9 +752,16 @@ test('a stale switch without a current card keeps its revision, so an approve is }); const refused = refusedSwitch(plan, staleRefusal()); - assert.ok(refused); assert.equal(refused.switching, null); - assert.equal(refused.notice, STALE_NOTICE); + assert.deepEqual(refused.notice, { + lead: null, + text: STALE_NOTICE, + retryable: false, + retryAt: null, + quiet: false, + choiceId: null, + optionId: null, + }); assert.equal(canSwitch(refused), false); assert.equal(switchReply(refused, 'spec_1:measure_basis', 'measure_basis:net'), null); assert.deepEqual(approveReply(refused), { decision: 'approve', plan_revision: 'rev-a' }); @@ -262,95 +783,675 @@ test('an unavailable option keeps the card and shows the harness reason', () => }, }); - assert.ok(refused); assert.equal(refused.switching, null); - assert.equal(refused.notice, 'Net disbursements cannot be counted for this plan.'); + // The same pick would be refused again, so the row offers no Try again. + assert.deepEqual(refused.notice, { + lead: 'Could not switch to Net disbursements.', + text: 'Net disbursements cannot be counted for this plan.', + retryable: false, + retryAt: null, + quiet: false, + ...PICK, + }); assert.equal(refused.revision, 'rev-a'); assert.equal(canSwitch(refused), true); }); -test('a refusal the harness does not give a switch is a failed request, with no card', async (t) => { - const plan = startSwitch(planFromFrame(frame()), { - choiceId: 'spec_1:measure_basis', - optionId: 'measure_basis:net', +test('a refusal that leaves the pause alone keeps the card and says on the row why the switch did not apply', async (t) => { + const plan = startSwitch(planFromFrame(frame()), PICK); + const toolError = (error: string, retry: RetryMode = 'do_not_retry') => ({ + error: error as 'not_found', + retry, + detail: error, + }); + const generic = + 'Studio could not switch to Net disbursements. The plan still counts Gross disbursements.'; + const lead = 'Could not switch to Net disbursements.'; + // [name, refusal, lead, text, retryable] + const cases: [string, StreamRefusal, string | null, string, boolean][] = [ + // A detail without a ToolError is written for developers. + ['a 429', { status: 429, detail: 'Too many requests.', error: null }, null, generic, false], + ['a 429 without detail', { status: 429, detail: null, error: null }, null, generic, false], + [ + 'a 429 whose ToolError says to retry after a delay', + { + status: 429, + detail: 'Too many requests.', + error: toolError('rate_limited', 'retry_after_delay'), + }, + lead, + 'Too many requests.', + false, + ], + [ + 'a 409 transient_unavailable', + { + status: 409, + detail: 'busy', + error: toolError('transient_unavailable', 'retry_after_delay'), + }, + lead, + 'busy', + true, + ], + ]; + + for (const [name, refusal, wantLead, text, retryable] of cases) { + await t.test(name, () => { + const refused = refusedSwitch(plan, refusal); + + assert.equal(refused.live, true); + assert.equal(refused.switching, null); + assert.deepEqual(refused.notice, { + lead: wantLead, + text, + retryable, + retryAt: null, + quiet: false, + ...PICK, + }); + assert.equal( + switchReply(refused, PICK.choiceId, PICK.optionId)?.plan_revision, + 'rev-a', + ); + }); + } +}); + +test('a refusal asking to wait offers Try again once its delay has run from its arrival', () => { + const arrived = 1_000_000; + const refused = refusedSwitch( + startSwitch(planFromFrame(frame()), PICK), + { + status: 409, + detail: 'Another turn is running.', + error: { + error: 'transient_unavailable', + retry: 'retry_after_delay', + detail: 'Another turn is running.', + retry_after_seconds: 5, + }, + }, + arrived, + ); + + assert.equal(refused.notice?.retryable, true); + assert.equal(refused.notice?.retryAt, arrived + 5_000); +}); + +test('a 429 offers Try again only when its ToolError names a delay to wait out', async (t) => { + const plan = startSwitch(planFromFrame(frame()), PICK); + const arrived = 1_000_000; + const rateLimited = (retry: RetryMode, seconds?: number): StreamRefusal => ({ + status: 429, + detail: 'Too many requests.', + error: { + error: 'rate_limited', + retry, + detail: 'Too many requests.', + ...(seconds !== undefined && { retry_after_seconds: seconds }), + }, }); - const toolError = (error: string, constraint_id?: string) => ({ + // [name, refusal, retryable, retryAt] + const cases: [string, StreamRefusal, boolean, number | null][] = [ + ['a delay', rateLimited('retry_after_delay', 30), true, arrived + 30_000], + ['retry_after_delay without seconds', rateLimited('retry_after_delay'), false, null], + ['seconds without retry_after_delay', rateLimited('do_not_retry', 30), false, null], + ['no ToolError', { status: 429, detail: 'Too many requests.', error: null }, false, null], + ]; + + for (const [name, refusal, retryable, retryAt] of cases) { + await t.test(name, () => { + const refused = refusedSwitch(plan, refusal, arrived); + + assert.equal(refused.notice?.retryable, retryable); + assert.equal(refused.notice?.retryAt, retryAt); + }); + } +}); + +test('a refusal the proxy answered itself keeps the card live only where the harness never saw the switch', async (t) => { + const plan = startSwitch(planFromFrame(frame()), PICK); + const arrived = 1_000_000; + const lead = 'Could not switch to Net disbursements.'; + const proxied = ( + status: number, + proxy: NonNullable, + ): StreamRefusal => ({ status, detail: 'proxy text for developers', error: null, proxy }); + // [name, refusal, live, text, retryable, retryAt] + const cases: [string, StreamRefusal, boolean, string, boolean, number | null][] = [ + [ + 'a rate limit waits out its Retry-After', + proxied(429, { code: 'rate_limited', upstreamStatus: null, retryAfterSeconds: 12 }), + true, + 'Studio is busy.', + true, + arrived + 12_000, + ], + [ + 'a rate limit with no Retry-After offers no Try again', + proxied(429, { code: 'rate_limited', upstreamStatus: null, retryAfterSeconds: null }), + true, + 'Studio is busy.', + false, + null, + ], + [ + 'a rate limit with a Retry-After of 0 offers no Try again', + proxied(429, { code: 'rate_limited', upstreamStatus: null, retryAfterSeconds: 0 }), + true, + 'Studio is busy.', + false, + null, + ], + [ + 'an unreachable harness', + proxied(502, { + code: 'transient_unavailable', + upstreamStatus: null, + retryAfterSeconds: null, + }), + true, + UNREACHABLE_NOTICE, + true, + null, + ], + [ + 'a harness that answered before the relay failed', + proxied(502, { + code: 'transient_unavailable', + upstreamStatus: 422, + retryAfterSeconds: null, + }), + false, + 'Studio could not switch to Net disbursements. Ask again to change the plan.', + false, + null, + ], + ]; + + for (const [name, refusal, live, text, retryable, retryAt] of cases) { + await t.test(name, () => { + const refused = refusedSwitch(plan, refusal, arrived); + + assert.equal(refused.live, live); + assert.deepEqual(refused.notice, { + lead: live ? lead : null, + text, + retryable, + retryAt, + quiet: false, + ...PICK, + }); + }); + } +}); + +test('a refusal that may have used up the pause ends the card read-only, without Try again', async (t) => { + const plan = startSwitch(planFromFrame(frame()), PICK); + const toolError = ( + error: string, + retry: RetryMode = 'do_not_retry', + constraint_id?: string, + ) => ({ error: error as 'not_found', - retry: 'do_not_retry' as const, + retry, detail: error, ...(constraint_id && { constraint_id }), }); + const generic = 'Studio could not switch to Net disbursements. Ask again to change the plan.'; const cases: [string, StreamRefusal][] = [ + ['a 404', { status: 404, detail: 'The plan is gone.', error: null }], [ - 'a 404 for an interrupt that is gone', - { status: 404, detail: 'gone', error: toolError('not_found') }, + 'a not_found ToolError', + { + status: 410, + detail: 'The plan is gone.', + error: toolError('not_found'), + }, ], + ['a 400', { status: 400, detail: 'Bad request.', error: null }], ['a 401', { status: 401, detail: 'Sign in again.', error: null }], - ['a 429', { status: 429, detail: null, error: null }], - ['a 502', { status: 502, detail: null, error: null }], [ - 'a 409 transient_unavailable', - { status: 409, detail: 'busy', error: toolError('transient_unavailable') }, + 'a 401 whose ToolError says to retry after a delay', + { + status: 401, + detail: 'Sign in again.', + error: toolError('transient_unavailable', 'retry_after_delay'), + }, ], + ['a 403', { status: 403, detail: 'Forbidden.', error: null }], + ['a 413', { status: 413, detail: 'Too large.', error: null }], [ 'a 422 binding_invalid', - { status: 422, detail: 'bad', error: toolError('binding_invalid') }, + { + status: 422, + detail: 'a plan interrupt takes no answers', + error: toolError('binding_invalid', 'correct_and_retry'), + }, ], [ 'a stale constraint on a 422', - { status: 422, detail: 'x', error: toolError('edit_conflict', PLAN_REVISION_STALE) }, + { + status: 422, + detail: 'x', + error: toolError('edit_conflict', 'refresh_then_retry', PLAN_REVISION_STALE), + }, + ], + [ + 'a 409 resumed with another decision', + { + status: 409, + detail: 'already resumed', + error: toolError('edit_conflict', 'refresh_then_retry'), + }, + ], + ['a 500 with no body', { status: 500, detail: null, error: null }], + ['a 502 with no body', { status: 502, detail: null, error: null }], + [ + 'a proxy 502 whose text is not a ToolError', + { status: 502, detail: 'The studio agent is unreachable.', error: null }, + ], + [ + 'a 503 whose ToolError says to retry after a delay', + { + status: 503, + detail: 'Not saved.', + error: { + ...toolError('transient_unavailable', 'retry_after_delay'), + retry_after_seconds: 30, + }, + }, ], ]; for (const [name, refusal] of cases) { await t.test(name, () => { - assert.equal(refusedSwitch(plan, refusal), null); + const refused = refusedSwitch(plan, refusal); + // Only a harness ToolError's detail is written for readers. + const detail = refusal.error && refusal.detail; + + assert.equal(refused.live, false); + assert.equal(refused.switching, null); + assert.deepEqual(refused.notice, { + lead: detail ? 'Could not switch to Net disbursements.' : null, + text: detail ? `${detail} Ask again to change the plan.` : generic, + retryable: false, + retryAt: null, + quiet: false, + ...PICK, + }); + assert.equal(switchReply(refused, PICK.choiceId, PICK.optionId), null); }); } }); -test('a fresh plan after a switch replaces the waiting card with what moved', () => { - const waiting = startSwitch(planFromFrame(frame()), { - choiceId: 'spec_1:measure_basis', - optionId: 'measure_basis:net', +test('a switch that ends without a fresh plan leaves the card read-only, saying to ask again', async (t) => { + const waiting = startSwitch(planFromFrame(frame()), PICK); + // [name, ending, lead, text, quiet] + const cases: [string, Parameters[1], string | null, string, boolean][] = [ + [ + 'closed', + {}, + null, + 'Studio could not switch to Net disbursements. Ask again to change the plan.', + false, + ], + [ + 'an error', + { detail: 'The harness stopped.' }, + 'Could not switch to Net disbursements.', + 'The harness stopped. Ask again to change the plan.', + false, + ], + [ + 'stopped', + { stopped: true, detail: 'Cancelled.' }, + null, + 'Stopped before switching to Net disbursements. Ask again to change the plan.', + true, + ], + ]; + + for (const [name, ending, lead, text, quiet] of cases) { + await t.test(name, () => { + const ended = endedPlan(waiting, ending); + + assert.equal(ended.live, false); + assert.equal(ended.switching, null); + assert.deepEqual(ended.notice, { + lead, + text, + retryable: false, + retryAt: null, + quiet, + ...PICK, + }); + assert.equal(canSwitch(ended), false); + assert.equal(switchReply(ended, PICK.choiceId, PICK.optionId), null); + }); + } +}); + +test('a switch stream that ends without a fresh plan closes its Activity entry by how the harness ended it', async (t) => { + const waiting = startSwitch(planFromFrame(frame()), PICK); + const harnessError = (error: 'cancelled' | 'transient_unavailable', detail: string) => ({ + error, + retry: 'do_not_retry' as const, + detail, }); - const fresh = planFromFrame( + const gone = refusedSwitch(waiting, { + status: 404, + detail: 'The plan is gone.', + error: { error: 'not_found', retry: 'do_not_retry', detail: 'The plan is gone.' }, + }); + const generic = 'Studio could not switch to Net disbursements. Ask again to change the plan.'; + const stoppedText = + 'Stopped before switching to Net disbursements. Ask again to change the plan.'; + // [name, ending, Activity state, notice text] + const cases: [string, Parameters[1], 'skipped' | 'failed', string][] = [ + ['done with no plan', { error: null, stopped: false, refused: null }, 'failed', generic], + [ + 'a harness error', + { + error: harnessError('transient_unavailable', 'Lost.'), + stopped: false, + refused: null, + }, + 'failed', + 'Lost. Ask again to change the plan.', + ], + [ + 'a refusal', + { error: null, stopped: false, refused: gone }, + 'failed', + 'The plan is gone. Ask again to change the plan.', + ], + [ + 'stopped, then closed', + { error: null, stopped: true, refused: null }, + 'skipped', + stoppedText, + ], + [ + 'stopped, then cancelled by the harness', + { error: harnessError('cancelled', 'Stopped.'), stopped: true, refused: null }, + 'skipped', + stoppedText, + ], + [ + 'stopped, then a harness error', + { error: harnessError('transient_unavailable', 'Lost.'), stopped: true, refused: null }, + 'failed', + 'Lost. Ask again to change the plan.', + ], + [ + // The Stop's cancel reached the harness first, so the switch + // found no pause. + 'stopped, then a 404', + { error: null, stopped: true, refused: gone }, + 'skipped', + stoppedText, + ], + ]; + + for (const [name, ending, activity, text] of cases) { + await t.test(name, () => { + const ended = endedSwitch(waiting, ending); + + assert.equal(ended.activity, activity); + assert.equal(ended.plan.live, false); + assert.equal(ended.plan.notice?.text, text); + }); + } +}); + +test('a switch stopped and re-offered by the harness is live again, with Try again', () => { + const waiting = startSwitch(planFromFrame(frame()), PICK); + const reoffered = planFromFrame( frame({ interrupt_id: 'int-2', - version: 2, - revision: 'rev-b', - moved: [ - { - choice_id: 'spec_1:measure_basis', - from_label: 'Gross disbursements', - to_label: 'Net disbursements', - reason: 'Repayments are now subtracted.', - }, - ], + switch_failed: { + choice_id: PICK.choiceId, + option_id: PICK.optionId, + code: SWITCH_CANCELLED, + reason: 'The switch was stopped. The plan still counts gross.', + retryable: true, + }, }), + waiting, ); - assert.equal(waiting.switching?.optionId, 'measure_basis:net'); - assert.equal(fresh.switching, null); - assert.deepEqual(fresh.moved.map(movedSentence), [ - 'Gross disbursements changed to Net disbursements. Repayments are now subtracted.', - ]); - assert.equal( - switchReply(fresh, 'spec_1:measure_basis', 'measure_basis:net')?.plan_revision, - 'rev-b', + assert.equal(reoffered.live, true); + assert.equal(reoffered.interruptId, 'int-2'); + // The user stopped it, so the notice is the reason alone, said quietly, + // with a null lead. + assert.deepEqual(reoffered.notice, { + lead: null, + text: 'The switch was stopped. The plan still counts gross.', + retryable: true, + retryAt: null, + quiet: true, + ...PICK, + }); + assert.deepEqual(reoffered.changed, []); + assert.ok(switchReply(reoffered, PICK.choiceId, PICK.optionId)); +}); + +/** The conversation fields switchEnded reads, around a paused turn. */ +const pausedTurn = (plan: PlanState | null) => ({ + messages: [ + { role: 'user', text: 'What share of US aid went to health in 2023?' }, + { role: 'bot', text: '' }, + ], + activity: [{ turn: 1, prompt: 'q', startedAt: 0, tools: [] }], + plan, + thinking: true, + status: 'Switching to Net disbursements', + statusDetail: null, + statusSince: 1, + stalled: false, +}); + +test('a switch stream that ends with no plan, done or error leaves the card read-only, its row saying so without Try again', () => { + const waiting = startSwitch(planFromFrame(frame()), PICK); + const after = switchEnded(pausedTurn(waiting), waiting, endedPlan(waiting)); + + assert.equal(after.plan?.switching, null); + assert.equal(after.plan?.live, false); + assert.equal(after.plan?.notice?.choiceId, PICK.choiceId); + assert.equal(after.plan?.notice?.retryable, false); +}); + +test('a failed switch writes nothing into the paused reply or its Activity entry', () => { + const waiting = startSwitch(planFromFrame(frame()), PICK); + const before = pausedTurn(waiting); + const after = switchEnded( + before, + waiting, + endedPlan(waiting, { detail: 'The harness stopped.' }), ); + + assert.equal(after.messages, before.messages); + assert.equal(after.activity, before.activity); + assert.equal(after.plan?.notice?.text, 'The harness stopped. Ask again to change the plan.'); }); -test('a switch that ends without a fresh plan drops the card; other plans stay', () => { - const plan = planFromFrame(frame()); - const waiting = startSwitch(plan, { - choiceId: 'spec_1:measure_basis', - optionId: 'measure_basis:net', +test('a switch that ended after a fresh plan or a refusal leaves the state alone', () => { + const waiting = startSwitch(planFromFrame(frame()), PICK); + const replaced = pausedTurn(planFromFrame(switchedFrame(), waiting)); + + assert.equal(switchEnded(replaced, waiting, endedPlan(waiting)), replaced); +}); + +test('the card leads with the title, and with the first ask only when there is no title', async (t) => { + const asks = [{ ask: 'health share of US ODA in 2023', answered_by: ['s1'] }]; + const cases: [string, Partial, string][] = [ + [ + 'a summary', + { title: 'Health share of US aid, 2023', summary: 'Gross.', asks }, + 'Health share of US aid, 2023', + ], + ['a title and an ask', { title: 'Research plan', asks }, 'Research plan'], + ['no title, an ask', { title: '', asks }, 'Health share of US ODA in 2023'], + ['a title alone', { title: 'Research plan' }, 'Research plan'], + ]; + + for (const [name, overrides, title] of cases) { + await t.test(name, () => { + assert.equal(planFromFrame(frame(overrides)).title, title); + }); + } +}); + +test('a switch that does not apply clears the mark of the switch before it', async (t) => { + const applied = planFromFrame(switchedFrame(), startSwitch(planFromFrame(frame()), PICK)); + const waiting = startSwitch(applied, { + choiceId: PICK.choiceId, + optionId: 'measure_basis:gross', + }); + const cases: [string, PlanState][] = [ + ['a refusal', refusedSwitch(waiting, { status: 422, detail: 'No.', error: null })], + ['a gone pause', refusedSwitch(waiting, { status: 404, detail: null, error: null })], + ['a stream end without a plan', endedPlan(waiting, { detail: 'The harness stopped.' })], + ['an unreachable harness', unreachableSwitch(waiting)], + ['a switch stopped before it was sent', stoppedQueuedSwitch(waiting)], + ]; + + assert.equal(applied.switched, PICK.choiceId); + + for (const [name, plan] of cases) { + await t.test(name, () => { + assert.equal(plan.switched, null); + }); + } +}); + +test('a switch that never reached the harness keeps the card live, with Try again', () => { + const unreached = unreachableSwitch(startSwitch(planFromFrame(frame()), PICK)); + + assert.equal(unreached.live, true); + assert.equal(unreached.switching, null); + assert.deepEqual(unreached.notice, { + lead: 'Could not switch to Net disbursements.', + text: UNREACHABLE_NOTICE, + retryable: true, + retryAt: null, + quiet: false, + ...PICK, + }); + assert.ok(switchReply(unreached, PICK.choiceId, PICK.optionId)); +}); + +test('a switch stopped before its request started keeps the card live, quietly', () => { + const stopped = stoppedQueuedSwitch(startSwitch(planFromFrame(frame()), PICK)); + + assert.equal(stopped.live, true); + assert.equal(stopped.switching, null); + assert.deepEqual(stopped.notice, { + lead: null, + text: 'Stopped before switching to Net disbursements. The plan still counts Gross disbursements.', + retryable: true, + retryAt: null, + quiet: true, + ...PICK, }); + assert.ok(switchReply(stopped, PICK.choiceId, PICK.optionId)); +}); + +test('a plan re-offered after a switch names the switched choice, for focus to return to', () => { + const fresh = planFromFrame(switchedFrame(), startSwitch(planFromFrame(frame()), PICK)); + + assert.equal(fresh.switched, PICK.choiceId); + assert.equal(planFromFrame(frame()).switched, null); +}); + +const approvedFrame: PlanApproved = { + event: 'plan.approved', + seq: 1, + plan_version_id: 'pv-2', + version: 2, + revision: 'rev-b', + title: 'Health share of US aid, 2023', + summary: 'Gross disbursements of US aid, current US dollars.', + steps: [ + { label: 'Fetch health aid' }, + { label: 'Divide', gives_answer: true }, + { label: 'Chart' }, + ], + choices: [ + { question: 'Basis', chosen: 'net disbursements', changed_from: 'gross disbursements' }, + ], +}; + +test('plan.approved rides on the reply the approve resumed and survives a save and a fork', () => { + const messages: ChatMessage[] = [ + { role: 'user', text: 'What share of US aid went to health in 2023?' }, + { role: 'bot', text: '' }, + ]; + const approved = withApprovedPlan(messages, approvedFromFrame(approvedFrame)); + const answered: ChatMessage[] = approved.map((message) => + message.role === 'bot' + ? { ...message, text: 'In 2023, 13%.', chips: [] as never[] } + : message, + ); + const saved = JSON.parse(JSON.stringify({ messages: answered })) as { messages: ChatMessage[] }; + const reply = withoutChips(saved.messages).at(-1); + + assert.equal(messages.at(-1)?.role, 'bot'); + assert.ok(reply?.role === 'bot' && reply.plan); + assert.equal(reply.text, 'In 2023, 13%.'); + + const view = approvedView(reply.plan); + + assert.equal(view.title, 'Health share of US aid, 2023'); + assert.equal(answerStepOf(view)?.label, 'Divide'); + assert.deepEqual( + view.choices.map((choice) => [choice.name, choice.value, choice.changedFrom]), + [['Basis', 'net disbursements', 'gross disbursements']], + ); +}); + +test('an approved plan shows its caveats, and one saved before caveats shows none', () => { + const caveat = 'The total counts every provider, the health figure bilateral aid only.'; + + assert.deepEqual( + approvedView(approvedFromFrame({ ...approvedFrame, caveats: [caveat] })).caveats, + [caveat], + ); + assert.deepEqual(approvedView(approvedFromFrame(approvedFrame)).caveats, []); + assert.deepEqual( + approvedView({ ...approvedFromFrame(approvedFrame), caveats: undefined }).caveats, + [], + ); +}); + +test('a stored approved plan without steps or choices still gives a view', () => { + const stored = JSON.parse( + JSON.stringify({ ...approvedFrame, steps: undefined, choices: undefined }), + ) as PlanApproved; + + for (const plan of [approvedFromFrame(stored), stored]) { + const view = approvedView(plan); + + assert.deepEqual(view.steps, []); + assert.deepEqual(view.choices, []); + assert.equal(answerStepOf(view), undefined); + } +}); + +test('an approved plan with no step flagged answers with its last step', () => { + const view = approvedView( + approvedFromFrame({ ...approvedFrame, steps: [{ label: 'Fetch' }, { label: 'Sum' }] }), + ); + + assert.equal(answerStepOf(view)?.label, 'Sum'); +}); + +test('withApprovedPlan leaves a transcript that does not end on a reply', () => { + const messages: ChatMessage[] = [{ role: 'user', text: 'hi' }]; - assert.equal(settledPlan(waiting), null); - assert.equal(settledPlan(plan), plan); - assert.equal(settledPlan(null), null); + assert.equal(withApprovedPlan(messages, approvedFromFrame(approvedFrame)), messages); }); test('yearsText joins ranges and prints a one-year range once', () => { diff --git a/src/lib/studio/test/studioClient.test.ts b/src/lib/studio/test/studioClient.test.ts index 3ac6d67..132186f 100644 --- a/src/lib/studio/test/studioClient.test.ts +++ b/src/lib/studio/test/studioClient.test.ts @@ -12,6 +12,7 @@ import { runApplyChoice, runInterrupt, runTurn, + type StreamRefusal, } from '../studioClient.ts'; import type { ApplyChoiceSuggestion, StudioFrame } from '../wire.ts'; @@ -155,6 +156,115 @@ test('runInterrupt: a refusal onRefusal leaves is an error frame with its detail assert.equal(frames[0]?.event === 'error' && frames[0].error.detail, 'proxy failed'); }); +test("runInterrupt: a problem document the proxy answered with itself reaches onRefusal as the proxy's", async (t) => { + const proxyProblem = (status: number, body: Record, headers = {}) => + Response.json( + { status, detail: 'proxy text', retryable: true, ...body }, + { status, headers: { 'Content-Type': 'application/problem+json', ...headers } }, + ); + const cases: [string, Response, unknown][] = [ + [ + 'a rate limit, with its Retry-After', + proxyProblem(429, { code: 'rate_limited' }, { 'Retry-After': '12' }), + { code: 'rate_limited', upstreamStatus: null, retryAfterSeconds: 12 }, + ], + [ + 'an unreachable harness', + proxyProblem(502, { code: 'transient_unavailable' }), + { code: 'transient_unavailable', upstreamStatus: null, retryAfterSeconds: null }, + ], + [ + 'a harness that answered before the relay failed', + proxyProblem(502, { code: 'transient_unavailable', upstream_status: 422 }), + { code: 'transient_unavailable', upstreamStatus: 422, retryAfterSeconds: null }, + ], + [ + "a harness ToolError, which is not the proxy's", + Response.json(staleBody, { status: 409 }), + undefined, + ], + ]; + + for (const [name, response, proxy] of cases) { + await t.test(name, async (t) => { + t.mock.method(globalThis, 'fetch', async () => response); + const refusals: StreamRefusal[] = []; + + await runInterrupt('conv', 'int-1', switchReply, { + onFrame: () => {}, + onRefusal: (refusal) => { + refusals.push(refusal); + + return true; + }, + }); + + assert.deepEqual(refusals[0]?.proxy, proxy); + }); + } +}); + +test('runInterrupt: a request no server answered goes to onUnreachable, a server error does not', async (t) => { + const cases: [string, () => Promise, number, number][] = [ + [ + 'fetch rejects', + async () => { + throw new TypeError('Failed to fetch'); + }, + 1, + 0, + ], + ['a 502', async () => Response.json({ detail: 'proxy failed' }, { status: 502 }), 0, 1], + [ + 'an error frame', + async () => + new Response( + 'data: {"event":"error","seq":2,"error":{"error":"transient_unavailable","retry":"retry_after_delay","detail":"Failed to fetch"}}\n\n', + ), + 0, + 1, + ], + ]; + + for (const [name, respond, unreached, errors] of cases) { + await t.test(name, async (t) => { + t.mock.method(globalThis, 'fetch', respond); + const frames: StudioFrame[] = []; + let calls = 0; + + await runInterrupt('conv', 'int-1', switchReply, { + onFrame: (frame) => frames.push(frame), + onRefusal: () => false, + onUnreachable: () => { + calls += 1; + + return true; + }, + }); + + assert.equal(calls, unreached); + assert.equal(frames.filter((frame) => frame.event === 'error').length, errors); + }); + } +}); + +test('runInterrupt: an unreachable request onUnreachable leaves is an error frame', async (t) => { + t.mock.method(globalThis, 'fetch', async () => { + throw new TypeError('Failed to fetch'); + }); + const frames: StudioFrame[] = []; + + await runInterrupt('conv', 'int-1', switchReply, { + onFrame: (frame) => frames.push(frame), + onUnreachable: () => false, + }); + + assert.deepEqual( + frames.map((frame) => frame.event), + ['error'], + ); +}); + test('runInterrupt: a stale refusal hands onRefusal the current plan as sent', async (t) => { const currentPlan = { event: 'plan.proposed', diff --git a/src/lib/studio/wire.ts b/src/lib/studio/wire.ts index 8c90c19..0bb51b4 100644 --- a/src/lib/studio/wire.ts +++ b/src/lib/studio/wire.ts @@ -302,7 +302,14 @@ export type CancelRequest = { // Frames — discriminated on `event` // --------------------------------------------------------------------------- -export type PlanStep = { id: string; label: string; description?: string | null }; +/** One step of a plan. `gives_answer` is true on the one step whose result + * answers the user's question; a harness that predates it sends none. */ +export type PlanStep = { + id: string; + label: string; + description?: string | null; + gives_answer?: boolean | null; +}; /** One way a scope decision can be counted. `partial` options cover only * `years` (inclusive `[first, last]` ranges); `unavailable` ones say why in @@ -325,6 +332,9 @@ export type PlanDecision = { // A scope decision the user can switch: the harness's ScopeChoice id, // every option it offers, and the one the plan counts by now. A // decision without `choice_id` is an ordinary planner decision. + // `choice_id` is built from the requirement and the axis, so it holds + // across a conversation's switches. An older harness built it from a + // spec id a switch can re-pin. choice_id?: string | null; options?: PlanDecisionOption[] | null; active_option_id?: string | null; @@ -386,6 +396,55 @@ export type PlanProposed = { asks?: PlanAsk[] | null; // Set on the plan a switch re-offers. moved?: PlanMoved[] | null; + // One sentence: what is counted, for whom, when and from which source. + summary?: string | null; + // Set on the plan re-offered after a switch that did not apply. + switch_failed?: PlanSwitchFailed | null; + // Each decision that qualifies the answer (providers differ, a derived + // whole) as one reader sentence; absent from a harness that predates it. + caveats?: string[] | null; +}; + +/** A switch that did not apply: the option the user picked, the refusal's + * code, a reader's sentence saying why and what still holds, and whether + * picking the option again could apply (false for a failure that would + * repeat, such as an option Search cannot serve, and absent from a harness + * that predates it). A switch stopped through `/cancel` re-offers the unchanged + * plan with `code: "switch_cancelled"` and `retryable: true`. */ +export type PlanSwitchFailed = { + choice_id: string; + option_id: string; + code: string; + reason: string; + retryable?: boolean | null; +}; + +export type PlanApprovedStep = { label: string; gives_answer?: boolean | null }; + +/** One choice of an approved plan: its short name, the option label it + * holds, and, when the user's switches moved it, the label it held on the + * turn's first plan. */ +export type PlanApprovedChoice = { + question: string; + chosen: string; + changed_from?: string | null; +}; + +/** The plan the user approved, sent once on the `approve` answer before the + * resumed run's first frame. */ +export type PlanApproved = { + event: 'plan.approved'; + seq: number; + plan_version_id: string; + version: number; + revision?: string | null; + title: string; + summary?: string | null; + steps: PlanApprovedStep[]; + choices: PlanApprovedChoice[]; + // Each decision that qualifies the answer (providers differ, a derived + // whole) as one reader sentence; absent from a harness that predates it. + caveats?: string[] | null; }; export type StepProgress = { completed: number; total: number }; @@ -502,6 +561,7 @@ export type StudioFrame = | { event: 'message.delta'; seq: number; text: string } | { event: 'message.complete'; seq: number; text: string } | PlanProposed + | PlanApproved | { event: 'plan.versioned'; seq: number; From 5a928928400f08372bb8832e2cfc59b0aede7686 Mon Sep 17 00:00:00 2001 From: Jorge Rivera Date: Thu, 29 Oct 2026 10:51:28 +0100 Subject: [PATCH 10/14] fix(studio): let a tall plan card scroll so the approve bar stays on screen (#80) * fix(studio): let a tall plan card scroll so the approve bar stays on screen The plan stack had flex-shrink: 0, so a plan taller than the chat foot pushed the "Start building?" bar and the prompt input below the clipped 100dvh section. The card now gives up height and scrolls inside; the bar, input and note keep their natural height. * fix(studio): keep the plan card usable on short screens At 844x350 to 370 the plan card's scrollport collapsed to zero height, so View full plan and the scope controls could not be reached, even by keyboard. The card now stops shrinking at a floor of rem(120), its wrapper takes no box of its own (display: contents), and below that floor the chat foot scrolls as a whole, with bottom scroll padding so focus brings the input fully into view. --- .../Studio/workspace/ChatPanel.module.scss | 9 ++++++++- .../Studio/workspace/PlanCard.module.scss | 19 +++++++++++++------ 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/src/components/organisms/Studio/workspace/ChatPanel.module.scss b/src/components/organisms/Studio/workspace/ChatPanel.module.scss index 1e3c264..6c2a26f 100644 --- a/src/components/organisms/Studio/workspace/ChatPanel.module.scss +++ b/src/components/organisms/Studio/workspace/ChatPanel.module.scss @@ -586,11 +586,18 @@ // long clarify) outgrows the panel: that card scrolls inside and the input // never leaves the screen. Everything else in it keeps its natural height // (flex items don't shrink below their content unless they opt in with -// min-height: 0, which only the plan and the picker do). +// min-height: 0, which only the picker does; the plan card shrinks to a +// floor). On a screen too short for that floor, the foot itself scrolls, so +// every control stays reachable and focus brings it into view. .chatFoot { display: flex; flex-direction: column; min-height: 0; + overflow-y: auto; + + // Focus scrolls a textarea only as far as its caret line; this brings + // the rest of the input in with it. + scroll-padding-bottom: rem(24); } // The panel body: Chat (transcript + input) and Activity stacked in ONE grid diff --git a/src/components/organisms/Studio/workspace/PlanCard.module.scss b/src/components/organisms/Studio/workspace/PlanCard.module.scss index 97f6d6e..9981a50 100644 --- a/src/components/organisms/Studio/workspace/PlanCard.module.scss +++ b/src/components/organisms/Studio/workspace/PlanCard.module.scss @@ -1,16 +1,20 @@ @use "variables" as *; @use "mixins" as *; -// The plan card + the "Start building?" bar stack with a small seam. +// The plan card and the "Start building?" bar sit straight in the chat +// foot's flex column (the wrapper only groups them in the markup), so the +// card alone gives up height there and the bar keeps its natural height. .planWrap { - @include flex(column, flex-start, stretch); - - flex-shrink: 0; - gap: rem(8); + display: contents; } +// Squeezed, the card scrolls inside down to a usable floor; below that the +// whole chat foot scrolls instead. The scroll container still clips its +// rounded corners. .plan { - overflow: hidden; + min-height: rem(120); + overflow-y: auto; + overscroll-behavior: contain; background-color: var(--color-white); border: 1px solid var(--color-sage-light); @@ -130,6 +134,9 @@ // The extra bottom padding + the negative margin tuck the bar's lower // band UNDER the input that follows (the input paints over it). padding: rem(10) rem(10) rem(26) rem(16); + + // The seam to the card above. + margin-top: rem(8); margin-bottom: rem(-18); background-color: var(--color-grid-bg-light); border: 1px solid var(--color-sage-light); From 6dc181b8ca76af258d944b1768767cc5ba648ac4 Mon Sep 17 00:00:00 2001 From: Jorge Rivera Date: Thu, 29 Oct 2026 11:07:22 +0100 Subject: [PATCH 11/14] feat(studio): show planner decisions as switchable rows on the plan card (#76) * feat(studio): show planner decisions as switchable rows on the plan card The harness sends a planner decision the user can switch as a plan-card row with choice_id decision:. The ledger groups those rows under "Choices the plan made", apart from the scope rows under "Counted as", and a decision switch shows "Replanning" until the harness answers, since it runs a planner replan. switch_failed and Stop take the scope switch's paths. * fix(studio): read planner decision rows by the decision/ id * fix(studio): show a planner question asked during a switch A question the replan asked while a planner-decision switch waited kept the "Replanning" card on screen and hid the picker. The question now takes the card's place and its pause carries the waiting plan; the answer resumes as the same switch, so a fresh plan, switch_failed, Stop and a second question still end it. --- .../organisms/Studio/hooks/useChat.ts | 235 +++++++++++------- .../organisms/Studio/workspace/PlanCard.tsx | 7 +- .../Studio/workspace/PlanLedger.module.scss | 7 +- .../organisms/Studio/workspace/PlanLedger.tsx | 83 ++++--- src/lib/studio/plan.ts | 129 +++++++++- src/lib/studio/test/plan.test.ts | 179 +++++++++++++ src/lib/studio/wire.ts | 21 +- 7 files changed, 512 insertions(+), 149 deletions(-) diff --git a/src/components/organisms/Studio/hooks/useChat.ts b/src/components/organisms/Studio/hooks/useChat.ts index cd9da3a..8985af9 100644 --- a/src/components/organisms/Studio/hooks/useChat.ts +++ b/src/components/organisms/Studio/hooks/useChat.ts @@ -46,11 +46,16 @@ import { stoppedQueuedSwitch, switchEnded, endedSwitch, + switchLine, switchReply, unreachableSwitch, optionLabel, withApprovedPlan, + questionPause, + withQuestion, + answeredPause, type ApprovedPlan, + type Pause, type PlanState, } from '@/lib/studio/plan'; import { withAnswer } from '@/lib/studio/transcript'; @@ -280,12 +285,9 @@ export const useChat = (initial?: ChatSnapshot) => { // The pending pause's addressing (interrupt_id) with, for elicitations, // the normalized clarify that answerClarify() builds its reply and // status note from, and that skipClarify() reads `skippable` from. - // For plans, the plan that switchChoice() checks a pick against. - const pendingInterruptRef = useRef<{ - interruptId: string; - clarify?: Clarify; - plan?: PlanState; - } | null>(null); + // For plans, the plan that switchChoice() checks a pick against. A + // question a switch's replan asked carries the switch's waiting plan. + const pendingInterruptRef = useRef(null); // The latest plan this turn offered, or is waiting on a switch of. A // fresh plan.proposed carries its baseline (what the turn's first plan // held) and learns which choice its switch moved. A new turn clears it. @@ -692,20 +694,19 @@ export const useChat = (initial?: ChatSnapshot) => { // follows re-renders the card. break; case 'elicitation.request': { + // Also a question a switch's replan asks, which takes + // the waiting card's place and carries the switch. const clarify = clarifyFromFrame(frame); stall.stop(); grace.cancel(); - pendingInterruptRef.current = { - interruptId: clarify.interruptId, + pendingInterruptRef.current = questionPause( clarify, - }; - setState((prev) => ({ - ...prev, - ...NO_LINE, - thinking: false, - clarify, - })); + pendingInterruptRef.current, + ); + setState((prev) => + withQuestion({ ...prev, ...NO_LINE, thinking: false }, clarify), + ); break; } case 'suggestions': { @@ -1026,11 +1027,98 @@ export const useChat = (initial?: ChatSnapshot) => { setState((prev) => ({ ...prev, queued: prev.queued.filter((item) => item.id !== id) })); }, []); + // A switch's stream, from the pick (switchChoice) or from the answer to + // a question its replan asked (resume), while `waiting` is the pending + // plan. A fresh plan.proposed, its `switch_failed` included, replaces + // the card through the stream; after Stop the frames are still read, so + // the plan the harness re-offers on cancel ends the switch. A stream that + // ends without a plan (it closed, broke, sent `done` or an error, or was + // stopped) ends the card read-only, once, closing the turn's Activity + // entry (endedSwitch) and leaving the reply alone. `refuse` gives the card + // for a refusal, which stays up when live and is the read-only end + // otherwise; `unreached` and `unsent` give the live card for a request + // that never reached the harness and one stopped before it started. + // Without them a refusal or an unreached request ends as an error, and a + // Stop before the request started as stopped. + const streamSwitch = useCallback( + ( + turn: number, + waiting: PlanState, + start: (handlers: StreamHandlers, signal: AbortSignal) => Promise, + { + refuse, + unreached, + unsent, + }: { + refuse?: (refusal: StreamRefusal) => PlanState; + unreached?: () => PlanState; + unsent?: () => PlanState; + } = {}, + ) => { + // The read-only card a refusal that may have used up the pause + // ends on. + let refused: PlanState | null = null; + + stream(turn, start, { + onRefusal: + refuse && + ((refusal) => { + const card = refuse(refusal); + + if (card.live) { + offerPlan(card); + } else { + refused = card; + } + + return true; + }), + onUnreachable: + unreached && + (() => { + offerPlan(unreached()); + + return true; + }), + onEnd: ({ error, stopped, sent }) => { + // A fresh plan, a refusal or a question replaced the + // waiting card. + if (pendingInterruptRef.current?.plan !== waiting) { + return; + } + + if (!sent && unsent) { + offerPlan(unsent()); + + return; + } + + const { plan: ended, activity } = endedSwitch(waiting, { + error, + stopped, + refused, + }); + + pendingInterruptRef.current = null; + turnPlanRef.current = null; + setState((prev) => { + const after = switchEnded(prev, waiting, ended); + + return after === prev ? prev : endTurn(after, turn, activity); + }); + }, + }); + }, + [offerPlan, stream], + ); + // Continue a paused turn on /interrupt — shared by clarify answers, the // skip button and the plan actions. `answer` is what the user chose or // wrote, which enters the transcript. // A stale plan answer (an approve sent from an out-of-date card) is // refused with the current card, which replaces it to be answered again. + // The answer to a question a switch's replan asked puts the waiting card + // back and resumes as the switch (streamSwitch). const resume = useCallback( ( reply: ClarifyReply | Pick, @@ -1039,8 +1127,9 @@ export const useChat = (initial?: ChatSnapshot) => { ) => { const conversation = sessionRef.current; const pending = pendingInterruptRef.current; + const after = answeredPause(pending); - pendingInterruptRef.current = null; + pendingInterruptRef.current = after; setState((prev) => ({ // The decision shows on the status line; the user's answer @@ -1050,31 +1139,35 @@ export const useChat = (initial?: ChatSnapshot) => { messages: answer && pending ? withAnswer(prev.messages, answer) : prev.messages, thinking: true, clarify: null, - plan: null, + plan: after?.plan ?? null, })); if (!conversation || !pending) { return; } - stream( - turnCount.current, - (handlers, signal) => - runInterrupt(conversation, pending.interruptId, reply, handlers, signal), - { - onRefusal: (refusal) => { - const current = stalePlan(refusal, pending?.plan); + const start = (handlers: StreamHandlers, signal: AbortSignal) => + runInterrupt(conversation, pending.interruptId, reply, handlers, signal); - if (current) { - offerPlan(current); - } + if (after?.plan) { + streamSwitch(turnCount.current, after.plan, start); - return current !== null; - }, + return; + } + + stream(turnCount.current, start, { + onRefusal: (refusal) => { + const current = stalePlan(refusal, pending?.plan); + + if (current) { + offerPlan(current); + } + + return current !== null; }, - ); + }); }, - [offerPlan, stream], + [offerPlan, stream, streamSwitch], ); // Answer every question of the pending clarify at once, keyed by @@ -1141,17 +1234,16 @@ export const useChat = (initial?: ChatSnapshot) => { [resume], ); - // A scope decision's option, picked on the plan card. The card stays, - // its controls waiting, until the harness re-offers the plan with what - // changed, or with `switch_failed` saying why it did not apply. A - // refusal that leaves the pause alone keeps the card live and says why on - // the choice's row (refusedSwitch); a stale one (the plan moved on - // elsewhere) is answered with the current card, and any other ends the - // card read-only. So does a stream that ends without a plan (it closed, - // broke, sent `done` or an error, or was stopped), once, closing the - // turn's Activity entry (endedSwitch) and leaving the reply alone. A + // A scope choice's or planner decision's option, picked on the plan card. + // The card stays, its controls waiting, until the harness re-offers the + // plan with what changed, or with `switch_failed` saying why it did not + // apply. The page sets no deadline of its own, since a planner + // decision's replan takes tens of seconds. A refusal that leaves the + // pause alone keeps the card live and says why on the choice's row + // (refusedSwitch); a stale one (the plan moved on elsewhere) is answered + // with the current card, and any other ends the card read-only. A // request that never reached the harness keeps the card live, with "Try - // again". + // again". The rest of the switch's end is streamSwitch's. const switchChoice = useCallback( (choiceId: string, optionId: string) => { const conversation = sessionRef.current; @@ -1165,9 +1257,6 @@ export const useChat = (initial?: ChatSnapshot) => { const label = optionLabel(plan.decisions, choiceId, optionId); const turn = turnCount.current; - // The read-only card a refusal that may have used up the pause - // ends on. - let refused: PlanState | null = null; // The pause stays pending with the waiting plan. switchReply // refuses a second pick while it waits, and the plan the harness @@ -1177,72 +1266,36 @@ export const useChat = (initial?: ChatSnapshot) => { pendingInterruptRef.current = { interruptId: pending.interruptId, plan: waiting }; turnPlanRef.current = waiting; setState((prev) => ({ - ...withLine(prev, label ? `Switching to ${label}` : 'Switching'), + ...withLine(prev, switchLine(choiceId, label)), thinking: true, plan: waiting, })); - stream( + streamSwitch( turn, + waiting, (handlers, signal) => runInterrupt(conversation, pending.interruptId, reply, handlers, signal), { - onRefusal: (refusal) => { - const card = refusedSwitch(waiting, refusal); - - if (card.live) { - offerPlan(card); - } else { - refused = card; - } - + refuse: (refusal) => { // The card shows only a harness ToolError's detail. if (!refusal.error && refusal.detail) { console.warn('Studio: a switch was refused', refusal); } - return true; + return refusedSwitch(waiting, refusal); }, // The request never reached the harness, so the pause // waits. - onUnreachable: () => { - offerPlan(unreachableSwitch(waiting)); - - return true; - }, - onEnd: ({ error, stopped, sent }) => { - // A fresh plan or a refusal replaced the waiting card. - if (pendingInterruptRef.current?.plan !== waiting) { - return; - } - - // Stopped while it waited behind the stream that - // offered the plan. Nothing reached the harness, so - // the pause waits. - if (!sent) { - offerPlan(stoppedQueuedSwitch(waiting)); - - return; - } - - const { plan: ended, activity } = endedSwitch(waiting, { - error, - stopped, - refused, - }); - - pendingInterruptRef.current = null; - turnPlanRef.current = null; - setState((prev) => { - const after = switchEnded(prev, waiting, ended); - - return after === prev ? prev : endTurn(after, turn, activity); - }); - }, + unreached: () => unreachableSwitch(waiting), + // Stopped while it waited behind the stream that offered + // the plan. Nothing reached the harness, so the pause + // waits. + unsent: () => stoppedQueuedSwitch(waiting), }, ); }, - [offerPlan, stream], + [streamSwitch], ); // A clicked `apply_choice` chip runs a turn with the chip's choice and no diff --git a/src/components/organisms/Studio/workspace/PlanCard.tsx b/src/components/organisms/Studio/workspace/PlanCard.tsx index fbd0a24..3fa6587 100644 --- a/src/components/organisms/Studio/workspace/PlanCard.tsx +++ b/src/components/organisms/Studio/workspace/PlanCard.tsx @@ -21,9 +21,10 @@ type PlanCardProps = { /** * The research plan awaiting review, in the chat foot: the title and its - * summary, the scope choices ("Counted as", each switchable in place), the - * step that gives the answer, what to keep in mind about the answer, and - * "View full plan" to every step in a modal. Then the "Start building?" bar + * summary, the scope choices ("Counted as") and planner decisions ("Choices + * the plan made"), each switchable in place, the step that gives the + * answer, what to keep in mind about the answer, and "View full plan" to + * every step in a modal. Then the "Start building?" bar * with Cancel and Approve. Changes travel through the chat input below * (ChatPanel repurposes it as "Propose changes or ask a follow up…" while * the plan is pending, wired to the amend decision). diff --git a/src/components/organisms/Studio/workspace/PlanLedger.module.scss b/src/components/organisms/Studio/workspace/PlanLedger.module.scss index 7e9f169..57fc685 100644 --- a/src/components/organisms/Studio/workspace/PlanLedger.module.scss +++ b/src/components/organisms/Studio/workspace/PlanLedger.module.scss @@ -35,7 +35,7 @@ vertical-align: rem(1); } -// --- "Counted as": the scope choices as a ruled ledger ---------------------- +// --- The switchable choices as a ruled ledger, one head a group ------------ // A host sets --ledger-inset to align the rows with its own padding. .ledger { @@ -51,6 +51,11 @@ letter-spacing: -0.02em; } +// A group's head after another group's rows. +.row + .head { + border-top: 1px solid var(--color-net-light); +} + .row { display: grid; grid-template-columns: minmax(0, 1fr) auto; diff --git a/src/components/organisms/Studio/workspace/PlanLedger.tsx b/src/components/organisms/Studio/workspace/PlanLedger.tsx index 4f5fcfe..92a7c9d 100644 --- a/src/components/organisms/Studio/workspace/PlanLedger.tsx +++ b/src/components/organisms/Studio/workspace/PlanLedger.tsx @@ -1,7 +1,7 @@ 'use client'; import classNames from 'classnames'; -import { useEffect, useRef, useState, type RefObject } from 'react'; +import { Fragment, useEffect, useRef, useState, type RefObject } from 'react'; import styles from './PlanLedger.module.scss'; @@ -9,7 +9,9 @@ import { canSwitch, isActiveOption, isSelectable, + ledgerSections, stepCount, + switchPendingText, switchReply, yearsText, type PlanChoiceView, @@ -99,15 +101,16 @@ const NoticeRegion = ({ notice, onRetry }: { notice: PlanNotice | null; onRetry? ); /** - * One scope choice: its name, the value the plan counts by, "Changed" and - * the value it held when a switch moved it, and "Change", which opens the - * options in place, drawn as a radio list: buttons in a group, the active - * one pressed, each picking its option at once. A partial option names the - * years it covers and an unavailable one says why. While a switch is in - * flight every option ignores clicks but keeps focus (`aria-disabled`), and - * the picked one says the plan is updating. When the plan this row's switch - * produced arrives and the option that held focus is gone, focus moves to - * "Change". + * One scope choice or planner decision: its name, the value the plan holds, + * "Changed" and the value it held when a switch moved it, and "Change", + * which opens the options in place, drawn as a radio list: buttons in a + * group, the active one pressed, each picking its option at once. A partial + * option names the years it covers and an unavailable one says why. While a + * switch is in flight every option ignores clicks but keeps focus + * (`aria-disabled`), and the picked one says the plan is updating, or + * replanning on a planner decision, until the harness answers. When the plan + * this row's switch produced arrives and the option that held focus is gone, + * focus moves to "Change". */ const ChoiceRow = ({ choice, @@ -224,7 +227,7 @@ const ChoiceRow = ({ {isPending && ( - Updating plan + {switchPendingText(choiceId)} )} @@ -238,9 +241,11 @@ const ChoiceRow = ({ }; /** - * "Counted as": the plan's scope choices as a ruled ledger, one row each, - * then a line for each choice the switch moved that has no row. `plan` and - * `onSwitch` make the rows switchable. An approved plan passes neither. + * The plan's choices as a ruled ledger, one row each, in groups + * with their own heads (ledgerSections): "Counted as", the scope choices and + * a line for each choice the switch moved that has no row, then "Choices the + * plan made", the planner decisions. `plan` and `onSwitch` make the rows + * switchable. An approved plan passes neither. * `reasons` adds why the plan holds each choice. One row opens at a time: * the one being switched, else the one whose switch failed, until the user * opens or closes another. @@ -283,7 +288,8 @@ export const PlanLedger = ({ ? plan.notice : null; - const ruled = view.choices.length > 0 || view.moved.length > 0; + const sections = ledgerSections(view); + const ruled = sections.length > 0; // An approved plan without choices has nothing to show. A live plan keeps // its notice region mounted. @@ -303,27 +309,32 @@ export const PlanLedger = ({ } }} > - {ruled &&

    Counted as

    } - {view.choices.map((choice) => ( - - setPicked({ plan, open: open === choice.key ? null : choice.key }) - } - /> - ))} - {view.moved.map((move) => ( -

    - - Changed from {move.from} to {move.to}.{move.reason && ` ${move.reason}`} - -

    + {sections.map((section) => ( + +

    {section.head}

    + {section.choices.map((choice) => ( + + setPicked({ plan, open: open === choice.key ? null : choice.key }) + } + /> + ))} + {section.moved.map((move) => ( +

    + + Changed from {move.from} to {move.to}. + {move.reason && ` ${move.reason}`} + +

    + ))} +
    ))}
    diff --git a/src/lib/studio/plan.ts b/src/lib/studio/plan.ts index 7a06207..3aa509f 100644 --- a/src/lib/studio/plan.ts +++ b/src/lib/studio/plan.ts @@ -1,10 +1,11 @@ /** - * The plan card's state and its `switch`: how a scope decision's options - * read, which of them the user can pick, the interrupt reply a pick sends, - * what the card says when a switch does not apply, and the approved plan the - * answer keeps. + * The plan card's state and its `switch`: how the options of a scope choice + * or a planner decision read, which of them the user can pick, the interrupt + * reply a pick sends, what the card says when a switch does not apply, and + * the approved plan the answer keeps. */ +import type { Clarify } from './clarify'; import type { StreamRefusal } from './studioClient'; import type { InterruptResponse, @@ -167,6 +168,27 @@ export const optionLabel = ( .find((decision) => decision.choice_id === choiceId) ?.options?.find((option) => option.option_id === optionId)?.label ?? null; +/** The `choice_id` prefix of a planner decision's row. Switching one replans + * with the user's pick held, which takes tens of seconds; switching a scope + * choice re-runs Search. */ +export const PLANNER_CHOICE_PREFIX = 'decision/'; + +/** The choice is a planner decision's row (PLANNER_CHOICE_PREFIX). */ +export const isPlannerChoice = (choiceId: string): boolean => + choiceId.startsWith(PLANNER_CHOICE_PREFIX); + +/** The chat line while a switch to `label` runs: "Switching to net + * disbursements", or "Replanning with top 10" on a planner decision. */ +export const switchLine = (choiceId: string, label: string | null): string => { + const [verb, link] = isPlannerChoice(choiceId) ? ['Replanning', 'with'] : ['Switching', 'to']; + + return label ? `${verb} ${link} ${label}` : verb; +}; + +/** What the picked option says while its switch runs. */ +export const switchPendingText = (choiceId: string): string => + isPlannerChoice(choiceId) ? 'Replanning' : 'Updating plan'; + /** The `switch_failed.code` of a switch the user stopped through `/cancel`, * re-offering the unchanged plan. */ export const SWITCH_CANCELLED = 'switch_cancelled'; @@ -463,11 +485,18 @@ const unappliedNotice = ( }); /** The sentence " The plan still counts gross disbursements." for the option - * the pick's choice holds, or nothing when the plan lacks the choice. */ + * the pick's choice holds (" The plan keeps top 5." on a planner decision), + * or nothing when the plan lacks the choice. */ const stillCounts = (plan: PlanState, pick: PlanSwitch): string => { const decision = plan.decisions.find((candidate) => candidate.choice_id === pick.choiceId); - return decision ? ` The plan still counts ${activeLabel(decision)}.` : ''; + if (!decision) { + return ''; + } + + const verb = isPlannerChoice(pick.choiceId) ? 'keeps' : 'still counts'; + + return ` The plan ${verb} ${activeLabel(decision)}.`; }; /** @@ -696,6 +725,48 @@ export const switchEnded = ( ended: PlanState, ): TState => (state.plan === waiting ? { ...state, plan: ended } : state); +/** + * The pause the turn waits on: its interrupt id, with the question + * (`clarify`) or the plan it asks the user to answer. `switch` is the + * waiting plan of a switch whose replan asked the question, so answering it + * resumes the switch. + */ +export type Pause = { + interruptId: string; + clarify?: Clarify; + plan?: PlanState; + switch?: PlanState; +}; + +/** The pause a planner question opens. A question asked while a switch + * waits (the pending plan is switching) carries the waiting plan. */ +export const questionPause = ( + clarify: Clarify, + pending: Pause | null, +): Pause & { clarify: Clarify } => { + const waiting = pending?.plan?.switching ? pending.plan : null; + + return { interruptId: clarify.interruptId, clarify, ...(waiting && { switch: waiting }) }; +}; + +/** The conversation while a question waits: the question takes the place + * of any plan card, including a switch's waiting card. */ +export const withQuestion = ( + state: TState, + clarify: Clarify, +): TState => ({ ...state, clarify, plan: null }); + +/** + * What stays pending once the user answers `pause`: for a question a + * switch's replan asked, the switch's waiting plan, which the card shows + * again, so a fresh plan, its `switch_failed`, a Stop, or another question + * still read as the switch's. The card is a new object, so the end of the + * stream that asked the question, which knew the old one, leaves it alone. + * Nothing for any other pause. + */ +export const answeredPause = (pause: Pause | null): Pause | null => + pause?.switch ? { interruptId: pause.interruptId, plan: { ...pause.switch } } : null; + /** The approved plan an answer keeps, from its `plan.approved` frame. */ export const approvedFromFrame = (frame: PlanApproved): ApprovedPlan => ({ plan_version_id: frame.plan_version_id, @@ -729,9 +800,10 @@ export type PlanStepView = { givesAnswer: boolean; }; -/** One scope choice as a row: its name, the value it holds, what it held - * before a switch, why the plan holds it, and the decision behind it when - * the row can switch. */ +/** One switchable choice as a row: its name, the value it holds, what it + * held before a switch, why the plan holds it, the decision behind it when + * the row can switch, and whether it is a planner decision rather than a + * scope choice (isPlannerChoice). */ export type PlanChoiceView = { key: string; name: string; @@ -740,6 +812,7 @@ export type PlanChoiceView = { changedReason: string | null; reason: string | null; decision: PlanDecision | null; + planner: boolean; }; /** A choice a switch moved that has no row: what it held, what it holds @@ -779,6 +852,7 @@ export const planView = (plan: PlanState): PlanView => { changedReason: change?.reason ?? null, reason: decision.reason || null, decision, + planner: isPlannerChoice(choiceId), }); } else { others.push({ @@ -835,6 +909,7 @@ export const approvedView = (plan: ApprovedPlan): PlanView => { changedReason: null, reason: null, decision: null, + planner: false, })), moved: [], others: [], @@ -843,6 +918,42 @@ export const approvedView = (plan: ApprovedPlan): PlanView => { }; }; +/** The ledger's head over the scope choices and the moves without a row. */ +export const SCOPE_HEAD = 'Counted as'; + +/** The ledger's head over the planner decisions the user can switch. */ +export const PLANNER_HEAD = 'Choices the plan made'; + +/** One group of the ledger: its head, its rows, and the moves listed under + * them. */ +export type LedgerSection = { + key: 'scope' | 'planner'; + head: string; + choices: PlanChoiceView[]; + moved: PlanMoveView[]; +}; + +/** + * The ledger's groups, each present only with something to show: the scope + * choices with the moves a switch made on choices without a row ("Counted + * as"), then the planner decisions ("Choices the plan made"). + */ +export const ledgerSections = (view: PlanView): LedgerSection[] => { + const scope = view.choices.filter((choice) => !choice.planner); + const planner = view.choices.filter((choice) => choice.planner); + const sections: LedgerSection[] = []; + + if (scope.length > 0 || view.moved.length > 0) { + sections.push({ key: 'scope', head: SCOPE_HEAD, choices: scope, moved: view.moved }); + } + + if (planner.length > 0) { + sections.push({ key: 'planner', head: PLANNER_HEAD, choices: planner, moved: [] }); + } + + return sections; +}; + export const STEPS_UPDATED = 'Steps updated to match.'; /** diff --git a/src/lib/studio/test/plan.test.ts b/src/lib/studio/test/plan.test.ts index 5fd6c1b..4d7b364 100644 --- a/src/lib/studio/test/plan.test.ts +++ b/src/lib/studio/test/plan.test.ts @@ -7,6 +7,7 @@ import test from 'node:test'; import { PLAN_REVISION_STALE, + answeredPause, approveReply, approvedFromFrame, approvedView, @@ -17,8 +18,11 @@ import { SWITCH_OPTION_UNAVAILABLE, isActiveOption, isSelectable, + ledgerSections, + PLANNER_HEAD, planFromFrame, planView, + questionPause, refusedSwitch, endedPlan, endedSwitch, @@ -27,14 +31,20 @@ import { startSwitch, stoppedQueuedSwitch, switchEnded, + switchLine, switchOutcome, + switchPendingText, switchReply, + SCOPE_HEAD, UNREACHABLE_NOTICE, unreachableSwitch, withApprovedPlan, + withQuestion, yearsText, + type Pause, type PlanState, } from '../plan.ts'; +import { clarifyFromFrame } from '../clarify.ts'; import { withoutChips } from '../chips.ts'; import type { StreamRefusal } from '../studioClient.ts'; import type { PlanApproved, PlanDecision, PlanProposed, RetryMode } from '../wire.ts'; @@ -1457,3 +1467,172 @@ test('withApprovedPlan leaves a transcript that does not end on a reply', () => test('yearsText joins ranges and prints a one-year range once', () => { assert.equal(yearsText(countDecision.options![1]!.years!), '2015–2019, 2022'); }); + +/** A planner decision the card can switch, as the harness sends it. */ +const donorsDecision: PlanDecision = { + id: 'd3', + question: 'How many donors?', + chosen: 'Top 5', + reason: 'a short list reads best', + resolved_by: 'default', + choice_id: 'decision/donor_count', + active_option_id: 'option_1', + options: [ + { option_id: 'option_1', label: 'Top 5', status: 'available', reason: null, years: null }, + { option_id: 'option_2', label: 'Top 10', status: 'available', reason: null, years: null }, + ], +}; + +const DONORS_PICK = { choiceId: 'decision/donor_count', optionId: 'option_2' }; + +test('the ledger heads scope choices and planner decisions apart', async (t) => { + const heads = (overrides: Partial) => + ledgerSections(planView(planFromFrame(frame(overrides)))).map((section) => [ + section.head, + section.choices.map((choice) => choice.key), + ]); + const cases: [string, Partial, [string, string[]][]][] = [ + [ + 'both', + { decisions: [countDecision, donorsDecision] }, + [ + [SCOPE_HEAD, ['spec_1:measure_basis']], + [PLANNER_HEAD, ['decision/donor_count']], + ], + ], + [ + 'planner decisions only', + { decisions: [donorsDecision] }, + [[PLANNER_HEAD, ['decision/donor_count']]], + ], + [ + 'scope choices only', + { decisions: [countDecision] }, + [[SCOPE_HEAD, ['spec_1:measure_basis']]], + ], + ]; + + for (const [name, overrides, expected] of cases) { + await t.test(name, () => { + assert.deepEqual(heads(overrides), expected); + }); + } +}); + +test("a planner decision's switch says it is replanning", () => { + assert.equal(switchPendingText(DONORS_PICK.choiceId), 'Replanning'); + assert.equal(switchLine(DONORS_PICK.choiceId, 'Top 10'), 'Replanning with Top 10'); + assert.equal(switchPendingText(PICK.choiceId), 'Updating plan'); + assert.equal(switchLine(PICK.choiceId, 'Net disbursements'), 'Switching to Net disbursements'); +}); + +test("a planner decision's switch that did not apply says the plan keeps its option", () => { + const waiting = startSwitch(planFromFrame(frame({ decisions: [donorsDecision] })), DONORS_PICK); + + assert.equal( + stoppedQueuedSwitch(waiting).notice?.text, + 'Stopped before switching to Top 10. The plan keeps Top 5.', + ); + assert.equal( + refusedSwitch(waiting, { status: 429, detail: null, error: null }).notice?.text, + 'Studio could not switch to Top 10. The plan keeps Top 5.', + ); +}); + +/** A one-question planner question on pause `interruptId`. */ +const question = (interruptId: string) => + clarifyFromFrame({ + event: 'elicitation.request', + seq: 5, + interrupt_id: interruptId, + questions: [ + { id: 'q1', question: 'Which year?', input: 'single_select', allow_free_text: true }, + ], + }); + +test("a question asked while a switch waits takes the card's place and carries the switch", () => { + const waiting = startSwitch(planFromFrame(frame()), PICK); + const clarify = question('int-q'); + const pause = questionPause(clarify, { interruptId: waiting.interruptId, plan: waiting }); + + assert.equal(pause.interruptId, 'int-q'); + assert.equal(pause.clarify, clarify); + assert.equal(pause.switch, waiting); + + const screen = withQuestion({ ...pausedTurn(waiting), clarify: null }, clarify); + + assert.equal(screen.plan, null); + assert.equal(screen.clarify, clarify); +}); + +test('a question with no switch waiting carries none, and its answer leaves nothing pending', async (t) => { + const live = planFromFrame(frame()); + const cases: [string, Pause | null][] = [ + ['no pause', null], + ['a plan not switching', { interruptId: live.interruptId, plan: live }], + ['a question', questionPause(question('int-0'), null)], + ]; + + for (const [name, pending] of cases) { + await t.test(name, () => { + const pause = questionPause(question('int-q'), pending); + + assert.equal(pause.switch, undefined); + assert.equal(answeredPause(pause), null); + }); + } +}); + +test("answering a switch's question resumes the switch: its fresh plan, switch_failed and Stop end it", () => { + const waiting = startSwitch(planFromFrame(frame()), PICK); + const pause = questionPause(question('int-q'), { interruptId: 'int-1', plan: waiting }); + const resumed = answeredPause(pause); + const card = resumed?.plan; + + assert.ok(card); + assert.equal(resumed?.interruptId, 'int-q'); + assert.deepEqual(card, waiting); + // A new object, so the stream that asked the question cannot end it. + assert.notEqual(card, waiting); + assert.deepEqual(card.switching, PICK); + + const before = pausedTurn(card); + + assert.equal(switchEnded(before, waiting, endedPlan(waiting)), before); + + // The fresh plan marks the switched choice. + assert.equal(planFromFrame(switchedFrame(), card).switched, PICK.choiceId); + + // The plan re-offered on cancel says why on the choice's row. + const reoffered = planFromFrame( + frame({ + interrupt_id: 'int-2', + switch_failed: { + choice_id: PICK.choiceId, + option_id: PICK.optionId, + code: SWITCH_CANCELLED, + reason: 'The switch was stopped. The plan still counts gross.', + retryable: true, + }, + }), + card, + ); + + assert.equal(reoffered.notice?.choiceId, PICK.choiceId); + assert.equal(reoffered.live, true); + + // A Stop with no plan re-offered ends the card read-only. + const { plan: ended, activity } = endedSwitch(card, { + error: null, + stopped: true, + refused: null, + }); + const after = switchEnded(before, card, ended); + + assert.equal(activity, 'skipped'); + assert.equal(after.plan?.live, false); + assert.equal(after.plan?.notice?.choiceId, PICK.choiceId); + + // A second question during the resumed switch carries it again. + assert.equal(questionPause(question('int-q2'), resumed).switch, card); +}); diff --git a/src/lib/studio/wire.ts b/src/lib/studio/wire.ts index 0bb51b4..a599710 100644 --- a/src/lib/studio/wire.ts +++ b/src/lib/studio/wire.ts @@ -311,9 +311,10 @@ export type PlanStep = { gives_answer?: boolean | null; }; -/** One way a scope decision can be counted. `partial` options cover only - * `years` (inclusive `[first, last]` ranges); `unavailable` ones say why in - * `reason`, reader copy written by the harness. */ +/** One option of a switchable decision. A scope choice's `partial` options + * cover only `years` (inclusive `[first, last]` ranges); `unavailable` ones + * say why in `reason`, reader copy written by the harness. A planner + * decision's options are all `available`. */ export type PlanDecisionOption = { option_id: string; label: string; @@ -329,12 +330,14 @@ export type PlanDecision = { reason: string; resolved_by: 'default' | 'user'; alternatives?: string[] | null; - // A scope decision the user can switch: the harness's ScopeChoice id, - // every option it offers, and the one the plan counts by now. A - // decision without `choice_id` is an ordinary planner decision. - // `choice_id` is built from the requirement and the axis, so it holds - // across a conversation's switches. An older harness built it from a - // spec id a switch can re-pin. + // A decision the user can switch: its row's id, every option it offers, + // and the one the plan counts by now. A scope choice's `choice_id` is + // built from the requirement and the axis, so it holds across a + // conversation's switches (an older harness built it from a spec id a + // switch can re-pin). A planner decision's is `decision/`, with + // option ids `option_` in its options' order, and switching it + // replans with the pick held. A decision without `choice_id` cannot be + // switched. choice_id?: string | null; options?: PlanDecisionOption[] | null; active_option_id?: string | null; From 350e539ff95ec7cadc3664217202ba52d9bf4cb7 Mon Sep 17 00:00:00 2001 From: Jorge Rivera Date: Thu, 29 Oct 2026 16:55:10 +0100 Subject: [PATCH 12/14] fix: keep crawlers out of event counts, keep preview cards, and file approved planner decisions (#83) * fix: keep crawlers out of event counts, keep preview cards, and file approved planner decisions track drops bot requests, page openGraph keeps the site name and root card, /data share and spec URLs send X-Robots-Tag, approved choices file planner decisions by choice_id, a refused chip refunds the guest prompt, shared wordings are numbered in the entity menu, and a stale switch without a current card ends read-only. * fix: refund any refused guest turn and decide /data noindex in one function stream refunds a metered turn's prompt on any refusal, as meterTurn does. src/proxy.ts sets /data's X-Robots-Tag from dataPageRobots, the function the page metadata uses. Preview cards carry their size and type, and a gallery dataset previews with its own card. --- next.config.ts | 1 + .../gallery/[slug]/opengraph-image.tsx | 47 ++++++------- .../(studio)/insights/gallery/[slug]/page.tsx | 7 +- src/app/layout.tsx | 3 +- src/app/opengraph-image.tsx | 8 ++- .../organisms/DataSearch/SearchSentence.tsx | 9 ++- src/components/organisms/Studio/Studio.tsx | 20 ++++-- .../organisms/Studio/hooks/useChat.ts | 42 +++++++---- src/lib/bot.ts | 11 +++ src/lib/crawl.ts | 48 +++++++++++-- src/lib/events/track.ts | 10 ++- src/lib/proxy/requestLog.ts | 8 +-- src/lib/search/parsedEdit.ts | 15 ++++ src/lib/search/test/parsedEdit.test.ts | 23 ++++++ src/lib/site.ts | 57 +++++++++++++++ src/lib/studio/plan.ts | 23 +++--- .../test/fixtures/event-16-plan-approved.json | 37 ++++++++++ src/lib/studio/test/plan.test.ts | 44 +++++++++++- src/lib/studio/test/transcript.test.ts | 29 +++++++- src/lib/studio/transcript.ts | 19 +++++ src/lib/studio/wire.ts | 9 ++- src/lib/test/bot.test.ts | 33 +++++++++ src/lib/test/crawl.test.ts | 70 +++++++++++++++++-- src/proxy.ts | 22 ++++++ 24 files changed, 506 insertions(+), 89 deletions(-) create mode 100644 src/lib/bot.ts create mode 100644 src/lib/studio/test/fixtures/event-16-plan-approved.json create mode 100644 src/lib/test/bot.test.ts create mode 100644 src/proxy.ts diff --git a/next.config.ts b/next.config.ts index 5128860..ca39448 100644 --- a/next.config.ts +++ b/next.config.ts @@ -22,6 +22,7 @@ const nextConfig: NextConfig = { .filter(Boolean), // Share and embed links, the Studio, history and sign-in say noindex on // every response, redirects and errors included (src/lib/crawl.ts). + // src/proxy.ts sends /data's. async headers() { return noindexHeaders(); }, diff --git a/src/app/(studio)/insights/gallery/[slug]/opengraph-image.tsx b/src/app/(studio)/insights/gallery/[slug]/opengraph-image.tsx index e053dc5..08c4fdf 100644 --- a/src/app/(studio)/insights/gallery/[slug]/opengraph-image.tsx +++ b/src/app/(studio)/insights/gallery/[slug]/opengraph-image.tsx @@ -1,9 +1,10 @@ import { ImageResponse } from 'next/og'; import { GALLERY_ITEMS, galleryItem } from '@/components/organisms/Gallery/galleryData'; +import { CARD_SIZE, CARD_TYPE } from '@/lib/site'; -export const size = { width: 1200, height: 630 }; -export const contentType = 'image/png'; +export const size = CARD_SIZE; +export const contentType = CARD_TYPE; export const dynamicParams = false; @@ -19,32 +20,28 @@ const OpenGraphImage = async ({ params }: { params: Promise<{ slug: string }> }) const item = galleryItem((await params).slug); return new ImageResponse( - ( -
    -
    - DFO Gallery +
    +
    DFO Gallery
    +
    +
    + {item?.title ?? 'DFO Studio session'}
    -
    -
    - {item?.title ?? 'DFO Studio session'} -
    -
    - {(item?.description ?? '').slice(0, 120)} -
    +
    + {(item?.description ?? '').slice(0, 120)}
    - ), +
    , size, ); }; diff --git a/src/app/(studio)/insights/gallery/[slug]/page.tsx b/src/app/(studio)/insights/gallery/[slug]/page.tsx index 465ce77..ba07908 100644 --- a/src/app/(studio)/insights/gallery/[slug]/page.tsx +++ b/src/app/(studio)/insights/gallery/[slug]/page.tsx @@ -5,6 +5,7 @@ import { Suspense } from 'react'; import { GALLERY_ITEMS, galleryItem } from '@/components/organisms/Gallery/galleryData'; import Studio from '@/components/organisms/Studio/Studio'; import { gallerySession } from '@/lib/gallery/sessions'; +import { pageOpenGraph } from '@/lib/site'; type GalleryItemPageProps = { params: Promise<{ slug: string }> }; @@ -26,11 +27,11 @@ export const generateMetadata = async ({ params }: GalleryItemPageProps): Promis title: `${item.title} — DFO Gallery`, description: item.description, robots: { index: true, follow: true }, - openGraph: { - type: 'article', + openGraph: pageOpenGraph({ title: item.title, description: item.description, - }, + image: null, + }), }; }; diff --git a/src/app/layout.tsx b/src/app/layout.tsx index c689bbf..f6a0aaa 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -3,7 +3,7 @@ import Script from 'next/script'; import GoogleAnalytics from '@/components/atoms/GoogleAnalytics/GoogleAnalytics'; import { EMBED_PREFIX } from '@/lib/embed'; -import { SITE_ORIGIN } from '@/lib/site'; +import { SITE_NAME, SITE_ORIGIN } from '@/lib/site'; import '@fontsource/outfit/300.css'; import '@fontsource/outfit/400.css'; @@ -16,7 +16,6 @@ import '@fontsource/dm-mono/500.css'; import '@/styles/main.scss'; -const SITE_NAME = 'Development Finance Observatory'; const SITE_DESCRIPTION = 'The Development Finance Observatory integrates aid, debt, climate and health finance, domestic budgets, and remittances into a single open platform, where every number is traceable to its source.'; diff --git a/src/app/opengraph-image.tsx b/src/app/opengraph-image.tsx index fea575a..f8a1944 100644 --- a/src/app/opengraph-image.tsx +++ b/src/app/opengraph-image.tsx @@ -3,9 +3,11 @@ import { join } from 'node:path'; import { ImageResponse } from 'next/og'; -export const alt = 'Development Finance Observatory: Development Finance Data. Simplified.'; -export const size = { width: 1200, height: 630 }; -export const contentType = 'image/png'; +import { CARD_SIZE, CARD_TYPE, SITE_CARD } from '@/lib/site'; + +export const alt = SITE_CARD.alt; +export const size = CARD_SIZE; +export const contentType = CARD_TYPE; // Outfit, the site's typeface, from the @fontsource package the layout // already loads. The image has no request-time input, so it renders once at diff --git a/src/components/organisms/DataSearch/SearchSentence.tsx b/src/components/organisms/DataSearch/SearchSentence.tsx index e27de33..952444c 100644 --- a/src/components/organisms/DataSearch/SearchSentence.tsx +++ b/src/components/organisms/DataSearch/SearchSentence.tsx @@ -92,6 +92,7 @@ import { } from '@/lib/search/measureMenu'; import { entityChoiceGroups, + entityChoiceLabels, entityChoicePart, offersAmbiguousPicker, resolveTypedEdit, @@ -1901,6 +1902,8 @@ export const SearchSentenceHeading = ({ } if (!wordingParts.has(partIndex)) { + const labels = entityChoiceLabels(groups); + return withMarker( - {groups.flatMap((group) => + {groups.flatMap((group, groupIndex) => group.options.map((option, optionIndex) => ( {/* Two ambiguous wordings share one menu, each row naming its own. */} - 1 ? group.text : null}> + 1 ? labels[groupIndex] : null} + > {option.label} diff --git a/src/components/organisms/Studio/Studio.tsx b/src/components/organisms/Studio/Studio.tsx index b8abae6..53f7a33 100644 --- a/src/components/organisms/Studio/Studio.tsx +++ b/src/components/organisms/Studio/Studio.tsx @@ -42,7 +42,7 @@ import { logRefusedDescriptions } from '@/lib/studio/infoPanel'; import { GUEST_PROMPTS, promptEvent, type PromptIntent } from '@/lib/studio/promptAllowance'; import { takeSeeds } from '@/lib/studio/seedStore'; import { deriveTheme, themeVars } from '@/lib/studio/theme'; -import { isPrompt } from '@/lib/studio/transcript'; +import { isPrompt, isRefundedPrompt } from '@/lib/studio/transcript'; import type { Seed } from '@/lib/studio/wire'; import { pushConversation } from '@/lib/chat/historyClient'; import { uuid } from '@/lib/uuid'; @@ -165,20 +165,26 @@ const Studio = ({ const guestUsed = useSyncExternalStore(subscribeGuest, readGuestUsed, () => 0); // A prompt counts once it is actually sent (a queued one can still be // removed): the count follows the user turns in the transcript, so the - // queue's own drain is covered too. Only growth counts — New Chat empties - // the transcript without giving prompts back. + // queue's own drain is covered too. A prompt the harness refused before + // its turn started comes back, as the server's meter gives it back. Only + // growth counts, of either: New Chat empties the transcript without + // giving prompts back. const sentCount = conversation.messages.filter(isPrompt).length; + const refundedCount = conversation.messages.filter(isRefundedPrompt).length; const sentRef = useRef(sentCount); + const refundedRef = useRef(refundedCount); useEffect(() => { - const delta = sentCount - sentRef.current; + const spent = Math.max(0, sentCount - sentRef.current); + const refunded = Math.max(0, refundedCount - refundedRef.current); sentRef.current = sentCount; + refundedRef.current = refundedCount; - if (guest && delta > 0) { - writeGuestUsed(readGuestUsed() + delta); + if (guest && spent !== refunded) { + writeGuestUsed(Math.max(0, readGuestUsed() + spent - refunded)); } - }, [guest, sentCount]); + }, [guest, sentCount, refundedCount]); // Queued prompts hold their place in the limit until sent or removed. const walled = guest && guestUsed + conversation.queued.length >= GUEST_PROMPTS; diff --git a/src/components/organisms/Studio/hooks/useChat.ts b/src/components/organisms/Studio/hooks/useChat.ts index 8985af9..890d0b6 100644 --- a/src/components/organisms/Studio/hooks/useChat.ts +++ b/src/components/organisms/Studio/hooks/useChat.ts @@ -58,7 +58,7 @@ import { type Pause, type PlanState, } from '@/lib/studio/plan'; -import { withAnswer } from '@/lib/studio/transcript'; +import { withAnswer, withRefundedPrompt } from '@/lib/studio/transcript'; import { cancelTurn, fetchComponentRevision, @@ -88,12 +88,14 @@ import type { CardRef, Widget } from '@/lib/chat/widgets'; // so the transcript shows what the question was about. `answer` marks the // user's answer to a paused turn (a clarify choice or text, a plan // amendment): shown like any user message, but part of the turn it answers. +// `refunded` marks a prompt whose turn was refused, which the site's prompt +// meter gave back. // A bot turn's `followups` are prompts a click sends as the next message, // and its `chips` switch a scope choice of the answer's readings. `plan` is // the plan the user approved for this answer, saved with the transcript so // a reopened conversation can show it. export type ChatMessage = - | { role: 'user'; text: string; card?: { title: string }; answer?: true } + | { role: 'user'; text: string; card?: { title: string }; answer?: true; refunded?: true } | { role: 'bot'; text: string; @@ -412,7 +414,9 @@ export const useChat = (initial?: ChatSnapshot) => { // on screen. A caller without one gets `endOwn`. A caller with one (a // switch, whose harness may re-offer the plan after a Stop) also keeps // the frames after Stop, for up to SWITCH_STOP_GRACE_MS, with the chat - // line on "Stopping". + // line on "Stopping". `metered` marks a POST turn, whose prompt the + // site's meter gives back when the response is a refusal (meterTurn), so + // a refusal marks the turn's prompt refunded. const stream = useCallback( ( turn: number, @@ -421,10 +425,12 @@ export const useChat = (initial?: ChatSnapshot) => { onRefusal, onUnreachable, onEnd, + metered = false, }: { onRefusal?: (refusal: StreamRefusal) => boolean; onUnreachable?: () => boolean; onEnd?: (ending: StreamEnding) => void; + metered?: boolean; } = {}, ) => { const controller = new AbortController(); @@ -848,11 +854,17 @@ export const useChat = (initial?: ChatSnapshot) => { // handle() drops frames. A switch the user stopped then ends as // stopped (its `onEnd`), not on a live card whose buttons the // cancel has made useless. - const refuse = (refusal: StreamRefusal): boolean => - generationRef.current !== generation || - muted || - stopped || - (onRefusal?.(refusal) ?? false); + const refuse = (refusal: StreamRefusal): boolean => { + if (generationRef.current !== generation) { + return true; + } + + if (metered) { + setState((prev) => ({ ...prev, messages: withRefundedPrompt(prev.messages) })); + } + + return muted || stopped || (onRefusal?.(refusal) ?? false); + }; const unreachable = (): boolean => generationRef.current !== generation || muted || @@ -1012,11 +1024,14 @@ export const useChat = (initial?: ChatSnapshot) => { // A question handed over from search carries its seeds, the // datasets the user saw there, so the harness starts from them. - stream(turn, (handlers, signal) => - runTurn(conversation, recap ? `${recap}${text}` : text, handlers, signal, { - ...(componentRef && { component_ref: componentRef }), - ...(seeds && { seed_spec: seeds }), - }), + stream( + turn, + (handlers, signal) => + runTurn(conversation, recap ? `${recap}${text}` : text, handlers, signal, { + ...(componentRef && { component_ref: componentRef }), + ...(seeds && { seed_spec: seeds }), + }), + { metered: true }, ); }, [holdTurn, startTurn, stream], @@ -1318,6 +1333,7 @@ export const useChat = (initial?: ChatSnapshot) => { turn, (handlers, signal) => runApplyChoice(conversation, chip, handlers, signal), { + metered: true, onRefusal: (refusal) => { const detail = chipRefusalDetail(refusal); diff --git a/src/lib/bot.ts b/src/lib/bot.ts new file mode 100644 index 0000000..79fd5e2 --- /dev/null +++ b/src/lib/bot.ts @@ -0,0 +1,11 @@ +import { isbot } from 'isbot'; + +/** A crawler, link-preview fetcher or script, by its user agent. A request + * with no user agent comes from a script, so it counts as a bot. */ +export const isBotAgent = (userAgent: string | null | undefined): boolean => + !userAgent || isbot(userAgent); + +/** Whether an event's request came from a bot. An event with no request + * behind it (null headers) is the server's own, never a bot's. */ +export const isBotRequest = (headers: Headers | null): boolean => + headers !== null && isBotAgent(headers.get('user-agent')); diff --git a/src/lib/crawl.ts b/src/lib/crawl.ts index 731c922..a0fd661 100644 --- a/src/lib/crawl.ts +++ b/src/lib/crawl.ts @@ -1,13 +1,15 @@ /** * What search engines may index. A crawler reads `noindex` only on a URL * robots.txt lets it fetch, so these routes stay open to crawlers (app/robots.ts) - * and say `noindex` themselves. No runtime imports, so next.config.ts and - * `node --test` can load it. + * and say `noindex` themselves. Its one runtime import, lib/site.ts, has + * none of its own, so next.config.ts and `node --test` can load it. */ import type { Metadata } from 'next'; import type { GalleryItem } from '@/components/organisms/Gallery/galleryData'; +import { galleryCard, pageOpenGraph } from './site.ts'; + /** * The routes kept out of search results, each with everything under it: * share and embed links are unlisted by design (anyone with the link can @@ -16,16 +18,18 @@ import type { GalleryItem } from '@/components/organisms/Gallery/galleryData'; */ export const NOINDEX_ROUTES = ['/s', '/embed', '/studio', '/history', '/login', '/register']; +/** The header that keeps a response out of search results. */ +export const NOINDEX_HEADER = { key: 'X-Robots-Tag', value: 'noindex, nofollow' }; + /** * next.config.ts `headers()`: `X-Robots-Tag` on every response from a * noindex route, so its redirects, 404s and error pages say noindex too, * and a page whose metadata streams into `` says it in the headers. + * /data decides per URL, so src/proxy.ts sends its header + * (dataPageNoindex). */ export const noindexHeaders = () => - NOINDEX_ROUTES.map((route) => ({ - source: `${route}/:path*`, - headers: [{ key: 'X-Robots-Tag', value: 'noindex, nofollow' }], - })); + NOINDEX_ROUTES.map((route) => ({ source: `${route}/:path*`, headers: [NOINDEX_HEADER] })); const NOINDEX: Metadata['robots'] = { index: false, follow: false }; @@ -84,7 +88,11 @@ export const dataPageMetadata = ( return { title: `${item.title} — DFO Gallery`, description: item.description, - openGraph: { type: 'article', title: item.title, description: item.description }, + openGraph: pageOpenGraph({ + title: item.title, + description: item.description, + image: galleryCard(item.slug), + }), }; } @@ -92,3 +100,29 @@ export const dataPageMetadata = ( return robots ? { robots } : {}; }; + +/** + * A /data URL's query as the page's `searchParams` holds it: a key given + * once maps to its value, empty or not, and a repeated key to every value. + */ +export const dataPageQuery = (params: URLSearchParams): DataPageQuery => { + const query: Record = {}; + + for (const key of new Set(params.keys())) { + const values = params.getAll(key); + + query[key] = values.length > 1 ? values : (values[0] ?? ''); + } + + return query; +}; + +/** + * Whether src/proxy.ts sends `X-Robots-Tag: noindex` for a /data URL. It + * reads the URL through dataPageRobots, as the page's metadata does, so the + * header and the metadata cannot disagree. + */ +export const dataPageNoindex = ( + params: URLSearchParams, + gallery: readonly GalleryItem[], +): boolean => dataPageRobots(dataPageQuery(params), gallery) !== undefined; diff --git a/src/lib/events/track.ts b/src/lib/events/track.ts index bfb6e7e..f21bb72 100644 --- a/src/lib/events/track.ts +++ b/src/lib/events/track.ts @@ -1,5 +1,6 @@ import 'server-only'; +import { isBotRequest } from '@/lib/bot'; import { db } from '@/lib/db'; import { event } from '../../../db/schema'; import { logEvent } from './eventLog'; @@ -77,9 +78,16 @@ export type Who = { * Record one event. Fire-and-forget: analytics never cost a request, so a * failed insert or forward is logged and swallowed, and each sink fails * independently of the others. The log line goes first and cannot throw, so - * Cloud Logging has the event whether the insert or the forward fails. + * Cloud Logging has the event whether the insert or the forward fails. An + * event whose request came from a crawler, link-preview fetcher or script + * (isBotRequest) is not recorded anywhere, since no stored row or log line + * keeps the user agent to tell it from a person's afterwards. */ export const track = async (name: EventName, props: EventProps, who: Who) => { + if (isBotRequest(who.headers)) { + return; + } + logEvent(name, props, who); try { diff --git a/src/lib/proxy/requestLog.ts b/src/lib/proxy/requestLog.ts index 78d11d0..23578fe 100644 --- a/src/lib/proxy/requestLog.ts +++ b/src/lib/proxy/requestLog.ts @@ -1,8 +1,7 @@ import { subscribe, unsubscribe } from 'node:diagnostics_channel'; import { IncomingMessage, ServerResponse } from 'node:http'; -import { isbot } from 'isbot'; - +import { isBotAgent } from '../bot.ts'; import { requestCaller } from './caller.ts'; /** @@ -95,9 +94,6 @@ const classifyRequest = ( return { kind: prerender || !purpose.includes('prefetch') ? 'page' : 'prefetch', prerender }; }; -/** A request with no user agent comes from a script, so it counts as a bot. */ -const isBot = (userAgent: string | null): boolean => !userAgent || isbot(userAgent); - /** The log entry for a logged request. */ export const requestLogEntry = ( { path, method, headers }: RequestMetadata, @@ -116,7 +112,7 @@ export const requestLogEntry = ( kind, status, finished, - bot: isBot(headers.get('user-agent')), + bot: isBotAgent(headers.get('user-agent')), prerender, }; }; diff --git a/src/lib/search/parsedEdit.ts b/src/lib/search/parsedEdit.ts index 24a3200..995ebc0 100644 --- a/src/lib/search/parsedEdit.ts +++ b/src/lib/search/parsedEdit.ts @@ -137,6 +137,21 @@ export function entityChoiceGroups(noData: NoData | null | undefined): EntityCho return [...groups.values()]; } +/** + * What each group's rows say in a menu that merges every group: the + * wording, numbered in menu order where two groups share it ("aid from + * Congo to Congo" gives "Congo (1)", "Congo (2)"). + */ +export function entityChoiceLabels(groups: readonly EntityChoiceGroup[]): string[] { + return groups.map(({ text }, index) => { + if (groups.filter((group) => group.text === text).length === 1) return text; + + const place = groups.slice(0, index + 1).filter((group) => group.text === text).length; + + return `${text} (${place})`; + }); +} + /** The part an entity choice posts: its option's one-part request, * unchanged. Null for an option carrying no part. */ export const entityChoicePart = (option: NoDataOption): ParsedPart | null => diff --git a/src/lib/search/test/parsedEdit.test.ts b/src/lib/search/test/parsedEdit.test.ts index fb76c34..7658954 100644 --- a/src/lib/search/test/parsedEdit.test.ts +++ b/src/lib/search/test/parsedEdit.test.ts @@ -4,6 +4,7 @@ import test from 'node:test'; import { applyRestatementEdit, entityChoiceGroups, + entityChoiceLabels, entityChoicePart, offersAmbiguousPicker, resolveTypedEdit, @@ -289,6 +290,28 @@ test('entityChoiceGroups keeps one group per entity_choice, two written alike ap ); }); +test('entityChoiceLabels numbers groups that share a wording, in menu order', () => { + const groups = entityChoiceGroups(congoToCongoNoData); + const [first, second] = congoToCongoNoData.options ?? []; + + assert.ok(first && second); + assert.deepEqual(entityChoiceLabels(groups), ['Congo (1)', 'Congo (2)']); + // One option settles its group by entity_choice, the other by mention: + // two keys of different kinds, one wording. + const mixed = entityChoiceGroups({ + ...congoToCongoNoData, + options: [first, { ...second, entity_choice: null, mentions: ['Congo'] }], + }); + + assert.deepEqual( + mixed.map((group) => group.key), + [0, 'Congo'], + ); + assert.deepEqual(entityChoiceLabels(mixed), ['Congo (1)', 'Congo (2)']); + // A wording no other group shares keeps its own text. + assert.deepEqual(entityChoiceLabels(entityChoiceGroups(odaToCongoNoData)), ['Congo']); +}); + // --------------------------------------------------------------------------- // offersAmbiguousPicker: SearchCards and studioHandoffParts' shared gate for // whether a no_data part gets the "Choose a Match" picker rather than the diff --git a/src/lib/site.ts b/src/lib/site.ts index fef65e7..d295886 100644 --- a/src/lib/site.ts +++ b/src/lib/site.ts @@ -1,5 +1,62 @@ +import type { Metadata } from 'next'; + /** * The production origin. Dev and production build the same metadata, link * previews and sitemap, so no build-time variable sets it. */ export const SITE_ORIGIN = 'https://developmentfinance.org'; + +export const SITE_NAME = 'Development Finance Observatory'; + +/** The size and type of every link-preview card the site draws. */ +export const CARD_SIZE = { width: 1200, height: 630 }; +export const CARD_TYPE = 'image/png'; + +type Card = { url: string; width: number; height: number; type: string; alt?: string }; + +/** The site's own card, drawn by app/opengraph-image.tsx. */ +export const SITE_CARD: Card = { + url: '/opengraph-image', + ...CARD_SIZE, + type: CARD_TYPE, + alt: 'Development Finance Observatory: Development Finance Data. Simplified.', +}; + +/** + * Next suffixes a metadata image under a route group with a hash of its + * parent path, so the gallery item card's address carries one. + * src/lib/test/crawl.test.ts checks it against Next's own. + */ +const GALLERY_CARD_SUFFIX = '1pc5v7'; + +/** A gallery item's own card, drawn by + * app/(studio)/insights/gallery/[slug]/opengraph-image.tsx. */ +export const galleryCard = (slug: string): Card => ({ + url: `/insights/gallery/${slug}/opengraph-image-${GALLERY_CARD_SUFFIX}`, + ...CARD_SIZE, + type: CARD_TYPE, +}); + +/** + * The Open Graph tags of a page that names itself. Next replaces a + * segment's `openGraph` whole, so a page that sets one would drop the root + * layout's site name and card; these keep the site name and show `image`, + * the site's card unless the page names another. A route with an + * `opengraph-image` file of its own passes null, since Next reads the file + * only when the page's `openGraph` has no `images`. + */ +export const pageOpenGraph = ({ + title, + description, + image = SITE_CARD, +}: { + title: string; + description: string; + image?: Card | null; +}): NonNullable => ({ + type: 'article', + siteName: SITE_NAME, + title, + description, + ...(image && { images: image }), +}); diff --git a/src/lib/studio/plan.ts b/src/lib/studio/plan.ts index 3aa509f..2f555d5 100644 --- a/src/lib/studio/plan.ts +++ b/src/lib/studio/plan.ts @@ -130,7 +130,7 @@ export const PLAN_REVISION_STALE = 'plan_revision_stale'; /** Shown when a stale refusal carries no current plan (a pause the harness * stored before it kept cards). */ export const STALE_NOTICE = - 'This plan changed after this page received it, so it can no longer be switched here. Reload the conversation to see the current plan.'; + 'This plan changed after this page received it, so it can no longer be answered here. Reload the conversation to see the current plan.'; /** The index of the step whose result answers the question, on a live * plan and an approved one alike: the one the harness flags, else the last @@ -381,7 +381,7 @@ export const planFromFrame = (frame: PlanProposed, previous?: PlanState): PlanSt /** The plan offers switching: it is live, lists the action and carries the * revision a switch must echo. */ -export const canSwitch = (plan: PlanState): boolean => +export const canSwitch = (plan: PlanState): plan is PlanState & { revision: string } => plan.live && plan.actions.includes('switch') && plan.revision !== null; /** A decision's switchable options, or null for an ordinary decision. */ @@ -414,7 +414,7 @@ export const switchReply = ( choiceId: string, optionId: string, ): SwitchReply | null => { - if (!canSwitch(plan) || plan.switching || plan.revision === null) { + if (!canSwitch(plan) || plan.switching) { return null; } @@ -444,8 +444,9 @@ export const startSwitch = (plan: PlanState, pick: PlanSwitch): PlanState => ({ notice: null, }); +/** The harness refused a switch or approve sent against an older plan. */ const isStale = (refusal: StreamRefusal): boolean => - refusal.error?.constraint_id === PLAN_REVISION_STALE; + refusal.status === 409 && refusal.error?.constraint_id === PLAN_REVISION_STALE; /** The constraint_id of a switch the harness refused because the plan does * not offer the option. */ @@ -531,8 +532,8 @@ const leavesPause = ({ status, error }: StreamRefusal): boolean => * The card after the harness refused its switch. The card always stays. * * A 409 `plan_revision_stale` brings the current plan, and switching works - * again on it. Without one the card keeps its revision, so an approve from it - * is refused as stale, and drops the switch action. A refusal that leaves + * again on it. Without one the card ends read-only, saying to reload, since + * the harness refuses an approve or a switch from it as stale. A refusal that leaves * the pause alone (leavesPause) says on the choice's row that the switch did * not apply, with the harness's detail when a ToolError carries it and the * card's own words otherwise, since a proxy's text is written for @@ -555,11 +556,11 @@ export const refusedSwitch = ( const pick = plan.switching; const settled = { ...plan, switching: null }; - if (refusal.status === 409 && refusal.error?.constraint_id === PLAN_REVISION_STALE) { + if (isStale(refusal)) { return ( stalePlan(refusal, plan) ?? { ...settled, - actions: plan.actions.filter((action) => action !== 'switch'), + live: false, notice: planNotice({ text: STALE_NOTICE }), } ); @@ -909,7 +910,11 @@ export const approvedView = (plan: ApprovedPlan): PlanView => { changedReason: null, reason: null, decision: null, - planner: false, + // A row saved before the harness sent choice ids counts as + // scope. The production harness then listed only scope choices; + // a development harness before dfo-ai#106 also listed planner + // decisions without ids, so chats saved there file them as scope. + planner: choice.choice_id !== undefined && isPlannerChoice(choice.choice_id), })), moved: [], others: [], diff --git a/src/lib/studio/test/fixtures/event-16-plan-approved.json b/src/lib/studio/test/fixtures/event-16-plan-approved.json new file mode 100644 index 0000000..a9f52a0 --- /dev/null +++ b/src/lib/studio/test/fixtures/event-16-plan-approved.json @@ -0,0 +1,37 @@ +{ + "event": "plan.approved", + "seq": 1, + "plan_version_id": "plan_01j9ze2k7c", + "version": 1, + "revision": "rev_01j9ze2k7c", + "title": "Health aid to Ethiopia, 2016\u20132024", + "summary": "Gross disbursements for health to Ethiopia from all donors, 2016\u20132024, in constant 2023 US dollars, from OECD CRS.", + "steps": [ + { + "label": "Find the health sector codes" + }, + { + "label": "Fetch disbursements to Ethiopia" + }, + { + "label": "Compare levels before and after 2021", + "gives_answer": true + } + ], + "choices": [ + { + "choice_id": "req_1:basis", + "question": "Basis", + "chosen": "Gross disbursements" + }, + { + "choice_id": "decision/prices", + "question": "Prices", + "chosen": "Constant 2023 USD", + "changed_from": "Current USD" + } + ], + "caveats": [ + "As asked, the inputs count different providers' spending: bilateral aid from the OECD CRS and all official development assistance from DAC1." + ] +} diff --git a/src/lib/studio/test/plan.test.ts b/src/lib/studio/test/plan.test.ts index 4d7b364..00c3c44 100644 --- a/src/lib/studio/test/plan.test.ts +++ b/src/lib/studio/test/plan.test.ts @@ -3,6 +3,7 @@ * refuses before sending, and the card after a refusal or a fresh plan. */ import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; import test from 'node:test'; import { @@ -755,7 +756,7 @@ test('an approve carries the revision of the card the user approved', () => { }); }); -test('a stale switch without a current card keeps its revision, so an approve is refused', () => { +test('a stale switch without a current card ends read-only, offering nothing the harness refuses', () => { const plan = startSwitch(planFromFrame(frame()), { choiceId: 'spec_1:measure_basis', optionId: 'measure_basis:net', @@ -772,9 +773,9 @@ test('a stale switch without a current card keeps its revision, so an approve is choiceId: null, optionId: null, }); + assert.equal(refused.live, false); assert.equal(canSwitch(refused), false); assert.equal(switchReply(refused, 'spec_1:measure_basis', 'measure_basis:net'), null); - assert.deepEqual(approveReply(refused), { decision: 'approve', plan_revision: 'rev-a' }); }); test('an unavailable option keeps the card and shows the harness reason', () => { @@ -1390,10 +1391,21 @@ const approvedFrame: PlanApproved = { { label: 'Chart' }, ], choices: [ - { question: 'Basis', chosen: 'net disbursements', changed_from: 'gross disbursements' }, + { + choice_id: 'spec_1:measure_basis', + question: 'Basis', + chosen: 'net disbursements', + changed_from: 'gross disbursements', + }, ], }; +/** The harness's wire fixture (`services/harness/tests/wire_fixtures/studio` + * in dfo-ai), copied. */ +const approvedFixture = JSON.parse( + readFileSync(new URL('./fixtures/event-16-plan-approved.json', import.meta.url), 'utf8'), +) as PlanApproved; + test('plan.approved rides on the reply the approve resumed and survives a save and a fork', () => { const messages: ChatMessage[] = [ { role: 'user', text: 'What share of US aid went to health in 2023?' }, @@ -1450,6 +1462,32 @@ test('a stored approved plan without steps or choices still gives a view', () => } }); +test('an approved plan files planner decisions under their own head, by choice id', () => { + assert.deepEqual( + ledgerSections(approvedView(approvedFromFrame(approvedFixture))).map((section) => [ + section.head, + section.choices.map((choice) => choice.name), + ]), + [ + [SCOPE_HEAD, ['Basis']], + [PLANNER_HEAD, ['Prices']], + ], + ); + + // Saved before the harness sent choice ids: every choice counts as scope. + const stored = JSON.parse( + JSON.stringify({ + ...approvedFixture, + choices: approvedFixture.choices.map(({ question, chosen }) => ({ question, chosen })), + }), + ) as PlanApproved; + + assert.deepEqual( + ledgerSections(approvedView(stored)).map((section) => section.head), + [SCOPE_HEAD], + ); +}); + test('an approved plan with no step flagged answers with its last step', () => { const view = approvedView( approvedFromFrame({ ...approvedFrame, steps: [{ label: 'Fetch' }, { label: 'Sum' }] }), diff --git a/src/lib/studio/test/transcript.test.ts b/src/lib/studio/test/transcript.test.ts index fefd2a5..b11840d 100644 --- a/src/lib/studio/test/transcript.test.ts +++ b/src/lib/studio/test/transcript.test.ts @@ -3,7 +3,13 @@ import test from 'node:test'; import type { ChatMessage } from '../../../components/organisms/Studio/hooks/useChat.ts'; import type { ShareMark } from '../../chat/history.ts'; -import { isPrompt, shareMarkAfter, withAnswer } from '../transcript.ts'; +import { + isPrompt, + isRefundedPrompt, + shareMarkAfter, + withAnswer, + withRefundedPrompt, +} from '../transcript.ts'; const paused: ChatMessage[] = [ { role: 'user', text: 'Show me aid to Kenya' }, @@ -45,6 +51,27 @@ test('only a turn-opening user message is a prompt', () => { assert.equal(isPrompt({ role: 'bot', text: 'According to the OECD…' }), false); }); +test('a refused turn marks its prompt refunded, which still counts as a prompt', () => { + const messages: ChatMessage[] = [ + { role: 'user', text: 'Show me aid to Kenya' }, + { role: 'bot', text: 'USD 1.2bn' }, + { role: 'user', text: 'By donor' }, + { role: 'bot', text: '' }, + ]; + const refused = withRefundedPrompt(messages); + + assert.deepEqual(refused, [ + ...messages.slice(0, 2), + { role: 'user', text: 'By donor', refunded: true }, + messages[3], + ]); + assert.equal(refused.filter(isPrompt).length, 2); + assert.equal(refused.filter(isRefundedPrompt).length, 1); + // Marked once, and a transcript with no prompt is left as it was. + assert.equal(withRefundedPrompt(refused), refused); + assert.deepEqual(withRefundedPrompt([]), []); +}); + // The mark useConversationSave records for a share of `messages`. const markFor = (messages: ChatMessage[]): ShareMark => ({ messageIndex: messages.length, diff --git a/src/lib/studio/transcript.ts b/src/lib/studio/transcript.ts index 4ae9682..eb5a488 100644 --- a/src/lib/studio/transcript.ts +++ b/src/lib/studio/transcript.ts @@ -9,6 +9,25 @@ import type { ShareMark } from '@/lib/chat/history'; export const isPrompt = (message: ChatMessage): boolean => message.role === 'user' && !message.answer; +/** A prompt whose turn was refused, which the site's prompt meter gave + * back, so it no longer counts against a guest's limit. */ +export const isRefundedPrompt = (message: ChatMessage): boolean => + message.role === 'user' && !!message.refunded; + +/** The transcript once the turn its last prompt opened was refused: that + * prompt marked refunded. A refused turn is the latest, since a new turn + * waits until the one in flight ends. */ +export const withRefundedPrompt = (messages: ChatMessage[]): ChatMessage[] => { + const index = messages.findLastIndex(isPrompt); + const prompt = messages[index]; + + if (prompt?.role !== 'user' || prompt.refunded) { + return messages; + } + + return messages.map((message, at) => (at === index ? { ...prompt, refunded: true } : message)); +}; + /** * The transcript with the user's answer to a paused turn. It goes before the * turn's reply, which the resumed stream goes on writing into. diff --git a/src/lib/studio/wire.ts b/src/lib/studio/wire.ts index a599710..6c2c2b7 100644 --- a/src/lib/studio/wire.ts +++ b/src/lib/studio/wire.ts @@ -424,10 +424,13 @@ export type PlanSwitchFailed = { export type PlanApprovedStep = { label: string; gives_answer?: boolean | null }; -/** One choice of an approved plan: its short name, the option label it - * holds, and, when the user's switches moved it, the label it held on the - * turn's first plan. */ +/** One choice of an approved plan: its id (the plan card's + * `PlanDecision.choice_id`, `decision/` for a planner decision), its + * short name, the option label it holds, and, when the user's switches + * moved it, the label it held on the turn's first plan. */ export type PlanApprovedChoice = { + // Absent from a frame stored in a chat snapshot before the harness sent it. + choice_id?: string; question: string; chosen: string; changed_from?: string | null; diff --git a/src/lib/test/bot.test.ts b/src/lib/test/bot.test.ts new file mode 100644 index 0000000..a4605e8 --- /dev/null +++ b/src/lib/test/bot.test.ts @@ -0,0 +1,33 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { isBotRequest } from '../bot.ts'; + +const request = (userAgent?: string) => + new Headers(userAgent === undefined ? {} : { 'user-agent': userAgent }); + +test('isBotRequest: crawlers, link-preview fetchers and scripts are bots, browsers are not', () => { + for (const userAgent of [ + 'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)', + 'facebookexternalhit/1.1 (+http://www.facebook.com/externalhit_uatext.php)', + 'Slackbot-LinkExpanding 1.0 (+https://api.slack.com/robots)', + 'Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; GPTBot/1.2; +https://openai.com/gptbot)', + 'curl/8.7.1', + ]) { + assert.equal(isBotRequest(request(userAgent)), true, userAgent); + } + + assert.equal(isBotRequest(request()), true); + assert.equal( + isBotRequest( + request( + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Safari/605.1.15', + ), + ), + false, + ); +}); + +test('isBotRequest: an event with no request behind it is the server’s own', () => { + assert.equal(isBotRequest(null), false); +}); diff --git a/src/lib/test/crawl.test.ts b/src/lib/test/crawl.test.ts index 1fda1c2..bb90dd9 100644 --- a/src/lib/test/crawl.test.ts +++ b/src/lib/test/crawl.test.ts @@ -4,15 +4,26 @@ * sitemap lists pages it may fetch and index. */ import assert from 'node:assert/strict'; +import { existsSync } from 'node:fs'; import test from 'node:test'; import { getPathMatch } from 'next/dist/shared/lib/router/utils/path-match.js'; +import metadataRoute from 'next/dist/lib/metadata/get-metadata-route.js'; +import { searchParamsToUrlQuery } from 'next/dist/shared/lib/router/utils/querystring.js'; import type { GalleryItem } from '@/components/organisms/Gallery/galleryData'; import robots from '../../app/robots.ts'; -import { dataPageMetadata, dataPageRobots, NOINDEX_ROUTES, noindexHeaders } from '../crawl.ts'; +import { + dataPageMetadata, + dataPageNoindex, + dataPageQuery, + dataPageRobots, + NOINDEX_ROUTES, + noindexHeaders, +} from '../crawl.ts'; import type { DocsPage } from '../docs/corpus.ts'; +import { galleryCard, pageOpenGraph, SITE_CARD, SITE_NAME } from '../site.ts'; import { sitemapPaths } from '../sitemap.ts'; // robots.txt matching as Google does it: a rule matches from the start of @@ -86,6 +97,57 @@ test('every noindex route answers noindex, redirects and errors included', () => } }); +test('/data says noindex in its headers exactly where its metadata does', () => { + for (const search of [ + '', + 'share=bbbbbbbb', + 'share=cccccccc', + 'share=aaaaaaaa', + 'share=', + 'spec=', + 'spec=x', + 'spec=x&share=bbbbbbbb', + 'share=bbbbbbbb&share=bbbbbbbb', + 'share=cccccccc&share=bbbbbbbb', + 'q=aid+to+Ghana', + ]) { + const params = new URLSearchParams(search); + // The page's searchParams, as Next builds them from the URL. + const query = searchParamsToUrlQuery(params); + + assert.deepEqual(dataPageQuery(params), query, search); + assert.equal(dataPageNoindex(params, gallery), !!dataPageRobots(query, gallery), search); + } + + assert.equal(dataPageNoindex(new URLSearchParams('share='), gallery), true); + assert.equal( + dataPageNoindex(new URLSearchParams('share=cccccccc&share=bbbbbbbb'), gallery), + true, + ); + assert.equal(dataPageNoindex(new URLSearchParams('share=bbbbbbbb'), gallery), false); +}); + +test('a page that names itself keeps the site name and shows the site card', () => { + assert.deepEqual(pageOpenGraph({ title: 'About', description: 'Who we are.' }), { + type: 'article', + siteName: SITE_NAME, + title: 'About', + description: 'Who we are.', + images: SITE_CARD, + }); + assert.equal('images' in pageOpenGraph({ title: 'A', description: 'B', image: null }), false); +}); + +test('a gallery item card sits where Next serves it', () => { + const segment = '/(studio)/insights/gallery/[slug]'; + + assert.ok(existsSync(new URL(`../../app${segment}/opengraph-image.tsx`, import.meta.url))); + assert.equal( + galleryCard('a-search').url, + metadataRoute.fillMetadataSegment(segment, { slug: 'a-search' }, 'opengraph-image', false), + ); +}); + test('crawlers may fetch the noindex routes and not the routes for programs', () => { for (const route of NOINDEX_ROUTES) { assert.equal(blocked(route), false, route); @@ -134,8 +196,10 @@ test('a gallery search carries its title and description, other shares do not', description: 'Aid to Ghana since 2000.', openGraph: { type: 'article', + siteName: SITE_NAME, title: 'Aid to Ghana', description: 'Aid to Ghana since 2000.', + images: galleryCard('a-search'), }, }); assert.deepEqual(dataPageMetadata({ share: 'cccccccc' }, gallery), { @@ -166,9 +230,7 @@ test('every page the sitemap lists may be fetched and indexed', () => { assert.equal(robotsHeader(url.pathname), undefined, path); if (url.pathname === '/data') { - const query = Object.fromEntries(url.searchParams); - - assert.equal(dataPageRobots(query, gallery), undefined, path); + assert.equal(dataPageNoindex(url.searchParams, gallery), false, path); } } }); diff --git a/src/proxy.ts b/src/proxy.ts new file mode 100644 index 0000000..68827ea --- /dev/null +++ b/src/proxy.ts @@ -0,0 +1,22 @@ +import { NextResponse, type NextRequest } from 'next/server'; + +import { GALLERY_ITEMS } from '@/components/organisms/Gallery/galleryData'; +import { dataPageNoindex, NOINDEX_HEADER } from '@/lib/crawl'; + +/** + * `X-Robots-Tag` on a /data response the page's metadata keeps out of + * search (dataPageRobots): a version link, or a search share no gallery + * item opens. The header holds whatever the render does, redirects and + * errors included. + */ +export const proxy = (request: NextRequest) => { + const response = NextResponse.next(); + + if (dataPageNoindex(request.nextUrl.searchParams, GALLERY_ITEMS)) { + response.headers.set(NOINDEX_HEADER.key, NOINDEX_HEADER.value); + } + + return response; +}; + +export const config = { matcher: '/data' }; From 95731cdce4c3e222486fd8a0db85f402446bc627 Mon Sep 17 00:00:00 2001 From: Jorge Rivera Date: Thu, 29 Oct 2026 17:27:26 +0100 Subject: [PATCH 13/14] feat(motion-kit): render the Studio and capture site pages as Claude Motion films (#82) --- motion-kit/README.md | 279 +++++++ motion-kit/browser.ts | 22 + motion-kit/build.ts | 517 +++++++++++++ motion-kit/capture.ts | 308 ++++++++ motion-kit/chart.tsx | 388 ++++++++++ motion-kit/cli.ts | 59 ++ motion-kit/css.ts | 330 ++++++++ motion-kit/cues.ts | 245 ++++++ motion-kit/demos/kenya-debt-health.cues.json | 199 +++++ motion-kit/demos/kenya-debt-health.json | 510 +++++++++++++ motion-kit/fontMetrics.ts | 214 ++++++ motion-kit/html.ts | 143 ++++ motion-kit/motion.ts | 386 ++++++++++ motion-kit/pageCss.ts | 207 +++++ motion-kit/pages/ids-source.json | 20 + motion-kit/pages/kenya-debt-search.json | 23 + motion-kit/pages/uganda-oda-search.cues.json | 37 + motion-kit/pages/uganda-oda-search.json | 41 + motion-kit/preview.ts | 225 ++++++ motion-kit/recorder.ts | 449 +++++++++++ motion-kit/render.tsx | 708 ++++++++++++++++++ motion-kit/runtime/dfo-motion.js | 594 +++++++++++++++ motion-kit/snapshot.ts | 572 ++++++++++++++ motion-kit/spec.ts | 372 +++++++++ motion-kit/stateClasses.ts | 158 ++++ motion-kit/stubs/next-link.tsx | 20 + motion-kit/stubs/next-navigation.ts | 19 + motion-kit/stubs/server-only.ts | 2 + motion-kit/test/capture.test.ts | 414 ++++++++++ motion-kit/test/motion-kit.test.ts | 618 +++++++++++++++ package.json | 4 +- pnpm-lock.yaml | 10 + .../Studio/canvas/cards/ChartCard.tsx | 117 +-- .../Studio/canvas/chart/chartDatasets.ts | 116 +++ 34 files changed, 8232 insertions(+), 94 deletions(-) create mode 100644 motion-kit/README.md create mode 100644 motion-kit/browser.ts create mode 100644 motion-kit/build.ts create mode 100644 motion-kit/capture.ts create mode 100644 motion-kit/chart.tsx create mode 100644 motion-kit/cli.ts create mode 100644 motion-kit/css.ts create mode 100644 motion-kit/cues.ts create mode 100644 motion-kit/demos/kenya-debt-health.cues.json create mode 100644 motion-kit/demos/kenya-debt-health.json create mode 100644 motion-kit/fontMetrics.ts create mode 100644 motion-kit/html.ts create mode 100644 motion-kit/motion.ts create mode 100644 motion-kit/pageCss.ts create mode 100644 motion-kit/pages/ids-source.json create mode 100644 motion-kit/pages/kenya-debt-search.json create mode 100644 motion-kit/pages/uganda-oda-search.cues.json create mode 100644 motion-kit/pages/uganda-oda-search.json create mode 100644 motion-kit/preview.ts create mode 100644 motion-kit/recorder.ts create mode 100644 motion-kit/render.tsx create mode 100644 motion-kit/runtime/dfo-motion.js create mode 100644 motion-kit/snapshot.ts create mode 100644 motion-kit/spec.ts create mode 100644 motion-kit/stateClasses.ts create mode 100644 motion-kit/stubs/next-link.tsx create mode 100644 motion-kit/stubs/next-navigation.ts create mode 100644 motion-kit/stubs/server-only.ts create mode 100644 motion-kit/test/capture.test.ts create mode 100644 motion-kit/test/motion-kit.test.ts create mode 100644 src/components/organisms/Studio/canvas/chart/chartDatasets.ts diff --git a/motion-kit/README.md b/motion-kit/README.md new file mode 100644 index 0000000..9d7e83f --- /dev/null +++ b/motion-kit/README.md @@ -0,0 +1,279 @@ +# motion-kit + +motion-kit renders the DFO Studio workspace, and captures any other page of the site, as static HTML and CSS for Claude Motion. A Motion film is a 1920×1080 sandboxed stage that builds its DOM from text modules and animates it as a function of time, with no React, no ` + +`; +}; diff --git a/motion-kit/recorder.ts b/motion-kit/recorder.ts new file mode 100644 index 0000000..c163f74 --- /dev/null +++ b/motion-kit/recorder.ts @@ -0,0 +1,449 @@ +/** + * A 2D canvas context that draws nothing and writes down what it was asked + * to draw: every stroked or filled path as SVG path data in canvas px, and + * every piece of text with its place, angle, font and alignment. Chart.js + * draws into it, so the SVG built from the record is Chart.js's own layout. + * + * It covers the part of CanvasRenderingContext2D that Chart.js's bar and + * line charts use. Clipping is recorded as nothing: the shapes Chart.js clips + * already sit inside the plot. + */ + +type Matrix = [a: number, b: number, c: number, d: number, e: number, f: number]; + +const IDENTITY: Matrix = [1, 0, 0, 1, 0, 0]; + +type Style = { + matrix: Matrix; + fillStyle: unknown; + strokeStyle: unknown; + lineWidth: number; + lineCap: CanvasLineCap; + lineJoin: CanvasLineJoin; + lineDash: number[]; + globalAlpha: number; + font: string; + textAlign: CanvasTextAlign; + textBaseline: CanvasTextBaseline; +}; + +export type Shape = { + kind: 'fill' | 'stroke'; + d: string; + // The fill or stroke as Chart.js set it: a colour, or whatever the + // dataset's fill callback returned. + paint: unknown; + lineWidth: number; + lineCap: CanvasLineCap; + lineJoin: CanvasLineJoin; + lineDash: number[]; + alpha: number; + // The drawn length of a stroked path, every subpath summed. + length: number; + // Which dataset was drawing, or null for the scales. + dataset: number | null; +}; + +export type Text = { + text: string; + x: number; + y: number; + // Radians, clockwise, as the context was rotated. + rotation: number; + font: string; + color: unknown; + align: CanvasTextAlign; + baseline: CanvasTextBaseline; +}; + +/** A pattern made from a recorded tile: what `createPattern` hands back. Its + * string tag is the one Chart.js checks for to leave a fill alone. */ +export class RecordedPattern { + readonly width: number; + readonly height: number; + readonly shapes: Shape[]; + + constructor(width: number, height: number, shapes: Shape[]) { + this.width = width; + this.height = height; + this.shapes = shapes; + } + + get [Symbol.toStringTag](): string { + return 'CanvasPattern'; + } +} + +const round = (value: number) => Math.round(value * 100) / 100; + +const multiply = (m: Matrix, n: Matrix): Matrix => [ + m[0] * n[0] + m[2] * n[1], + m[1] * n[0] + m[3] * n[1], + m[0] * n[2] + m[2] * n[3], + m[1] * n[2] + m[3] * n[3], + m[0] * n[4] + m[2] * n[5] + m[4], + m[1] * n[4] + m[3] * n[5] + m[5], +]; + +type Point = { x: number; y: number }; + +export class RecordingContext { + canvas: unknown; + shapes: Shape[] = []; + texts: Text[] = []; + // Set by the drawing plugin around each dataset's draw. + dataset: number | null = null; + + private state: Style = { + matrix: IDENTITY, + fillStyle: '#000', + strokeStyle: '#000', + lineWidth: 1, + lineCap: 'butt', + lineJoin: 'miter', + lineDash: [], + globalAlpha: 1, + font: '10px sans-serif', + textAlign: 'start', + textBaseline: 'alphabetic', + }; + private stack: Style[] = []; + private path: string[] = []; + private length = 0; + private current: Point | null = null; + private start: Point | null = null; + + // Properties Chart.js sets but the record has no use for. + lineDashOffset = 0; + miterLimit = 10; + globalCompositeOperation = 'source-over'; + imageSmoothingEnabled = true; + shadowBlur = 0; + shadowColor = 'transparent'; + shadowOffsetX = 0; + shadowOffsetY = 0; + direction: CanvasDirection = 'inherit'; + + private readonly measure: (font: string, text: string) => number; + + constructor(measure: (font: string, text: string) => number) { + this.measure = measure; + } + + clear(): void { + this.shapes = []; + this.texts = []; + } + + // --- state -------------------------------------------------------------- + + save(): void { + this.stack.push({ ...this.state, lineDash: [...this.state.lineDash] }); + } + + restore(): void { + this.state = this.stack.pop() ?? this.state; + } + + get fillStyle(): unknown { + return this.state.fillStyle; + } + + set fillStyle(value: unknown) { + this.state.fillStyle = value; + } + + get strokeStyle(): unknown { + return this.state.strokeStyle; + } + + set strokeStyle(value: unknown) { + this.state.strokeStyle = value; + } + + get lineWidth(): number { + return this.state.lineWidth; + } + + set lineWidth(value: number) { + this.state.lineWidth = value; + } + + get lineCap(): CanvasLineCap { + return this.state.lineCap; + } + + set lineCap(value: CanvasLineCap) { + this.state.lineCap = value; + } + + get lineJoin(): CanvasLineJoin { + return this.state.lineJoin; + } + + set lineJoin(value: CanvasLineJoin) { + this.state.lineJoin = value; + } + + get globalAlpha(): number { + return this.state.globalAlpha; + } + + set globalAlpha(value: number) { + this.state.globalAlpha = value; + } + + get font(): string { + return this.state.font; + } + + set font(value: string) { + this.state.font = value; + } + + get textAlign(): CanvasTextAlign { + return this.state.textAlign; + } + + set textAlign(value: CanvasTextAlign) { + this.state.textAlign = value; + } + + get textBaseline(): CanvasTextBaseline { + return this.state.textBaseline; + } + + set textBaseline(value: CanvasTextBaseline) { + this.state.textBaseline = value; + } + + setLineDash(segments: number[]): void { + this.state.lineDash = [...segments]; + } + + getLineDash(): number[] { + return [...this.state.lineDash]; + } + + // --- transform ---------------------------------------------------------- + + setTransform(a: number, b: number, c: number, d: number, e: number, f: number): void { + this.state.matrix = [a, b, c, d, e, f]; + } + + resetTransform(): void { + this.state.matrix = IDENTITY; + } + + translate(x: number, y: number): void { + this.state.matrix = multiply(this.state.matrix, [1, 0, 0, 1, x, y]); + } + + rotate(angle: number): void { + const cos = Math.cos(angle); + const sin = Math.sin(angle); + + this.state.matrix = multiply(this.state.matrix, [cos, sin, -sin, cos, 0, 0]); + } + + scale(x: number, y: number): void { + this.state.matrix = multiply(this.state.matrix, [x, 0, 0, y, 0, 0]); + } + + private apply(x: number, y: number): Point { + const [a, b, c, d, e, f] = this.state.matrix; + + return { x: a * x + c * y + e, y: b * x + d * y + f }; + } + + // --- paths -------------------------------------------------------------- + + beginPath(): void { + this.path = []; + this.length = 0; + this.current = null; + this.start = null; + } + + moveTo(x: number, y: number): void { + const point = this.apply(x, y); + + this.path.push(`M${round(point.x)} ${round(point.y)}`); + this.current = point; + this.start = point; + } + + lineTo(x: number, y: number): void { + if (!this.current) { + this.moveTo(x, y); + + return; + } + + const point = this.apply(x, y); + + this.path.push(`L${round(point.x)} ${round(point.y)}`); + this.length += Math.hypot(point.x - this.current.x, point.y - this.current.y); + this.current = point; + } + + closePath(): void { + if (this.current && this.start) { + this.path.push('Z'); + this.length += Math.hypot(this.start.x - this.current.x, this.start.y - this.current.y); + this.current = this.start; + } + } + + rect(x: number, y: number, w: number, h: number): void { + this.moveTo(x, y); + this.lineTo(x + w, y); + this.lineTo(x + w, y + h); + this.lineTo(x, y + h); + this.closePath(); + } + + arc(x: number, y: number, r: number, from: number, to: number, ccw = false): void { + const [a, b, c, d] = this.state.matrix; + const radius = r * Math.sqrt(Math.abs(a * d - b * c)); + const at = (angle: number) => this.apply(x + r * Math.cos(angle), y + r * Math.sin(angle)); + let sweep = ccw ? from - to : to - from; + + if (sweep >= 2 * Math.PI) { + sweep = 2 * Math.PI; + } else { + sweep = ((sweep % (2 * Math.PI)) + 2 * Math.PI) % (2 * Math.PI); + } + + const first = at(from); + + if (this.current) { + this.lineTo(x + r * Math.cos(from), y + r * Math.sin(from)); + } else { + this.path.push(`M${round(first.x)} ${round(first.y)}`); + this.current = first; + this.start = first; + } + + if (sweep === 0 || radius === 0) { + return; + } + + // SVG cannot draw a whole circle as one arc, so every arc is drawn in + // halves or less. + const steps = Math.ceil(sweep / Math.PI); + const direction = ccw ? -1 : 1; + + for (let i = 1; i <= steps; i++) { + const angle = from + (direction * sweep * i) / steps; + const point = at(angle); + + this.path.push( + `A${round(radius)} ${round(radius)} 0 0 ${ccw ? 0 : 1} ${round(point.x)} ${round(point.y)}`, + ); + this.current = point; + } + + this.length += radius * sweep; + } + + // --- painting ----------------------------------------------------------- + + private record(kind: Shape['kind']): void { + if (!this.path.length) { + return; + } + + this.shapes.push({ + kind, + d: this.path.join(''), + paint: kind === 'fill' ? this.state.fillStyle : this.state.strokeStyle, + lineWidth: this.state.lineWidth, + lineCap: this.state.lineCap, + lineJoin: this.state.lineJoin, + lineDash: [...this.state.lineDash], + alpha: this.state.globalAlpha, + length: round(this.length), + dataset: this.dataset, + }); + } + + stroke(): void { + this.record('stroke'); + } + + fill(): void { + this.record('fill'); + } + + clip(): void {} + + fillRect(x: number, y: number, w: number, h: number): void { + const saved = { path: this.path, length: this.length, current: this.current }; + + this.beginPath(); + this.rect(x, y, w, h); + this.record('fill'); + Object.assign(this, saved); + } + + strokeRect(x: number, y: number, w: number, h: number): void { + const saved = { path: this.path, length: this.length, current: this.current }; + + this.beginPath(); + this.rect(x, y, w, h); + this.record('stroke'); + Object.assign(this, saved); + } + + clearRect(): void {} + + // --- text --------------------------------------------------------------- + + fillText(text: string, x: number, y: number): void { + const [a, b] = this.state.matrix; + const point = this.apply(x, y); + + this.texts.push({ + text, + x: round(point.x), + y: round(point.y), + rotation: Math.atan2(b, a), + font: this.state.font, + color: this.state.fillStyle, + align: this.state.textAlign, + baseline: this.state.textBaseline, + }); + } + + strokeText(): void {} + + createPattern(image: RecordingCanvas): RecordedPattern { + return new RecordedPattern(image.width, image.height, [...image.context.shapes]); + } + + measureText(text: string): TextMetrics { + const width = this.measure(this.state.font, text); + + return { width, actualBoundingBoxLeft: 0, actualBoundingBoxRight: width } as TextMetrics; + } +} + +/** A canvas element whose 2D context is a recording one. */ +export class RecordingCanvas { + width: number; + height: number; + style: Record = {}; + readonly context: RecordingContext; + + constructor(width: number, height: number, measure: (font: string, text: string) => number) { + this.width = width; + this.height = height; + this.context = new RecordingContext(measure); + this.context.canvas = this; + } + + getContext(): RecordingContext { + return this.context; + } +} diff --git a/motion-kit/render.tsx b/motion-kit/render.tsx new file mode 100644 index 0000000..b469ee2 --- /dev/null +++ b/motion-kit/render.tsx @@ -0,0 +1,708 @@ +import classNames from 'classnames'; +import type { CSSProperties } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; + +import { type ChartLayout, layoutChart, renderPlot, type SeriesPath } from './chart.tsx'; +import { outfitMeasure } from './fontMetrics.ts'; +import { addAttributes, type Element, hasClass, parseMarkup, walk } from './html.ts'; +import { CELL, DEFAULT_VIEWPORT, type DemoSpec, WRAPPER } from './spec.ts'; + +import Button from '@/components/atoms/Button/Button'; +import buttonStyles from '@/components/atoms/Button/Button.module.scss'; +import GridPattern from '@/components/atoms/GridPattern/GridPattern'; +import segmentedStyles from '@/components/molecules/SegmentedTabs/SegmentedTabs.module.scss'; +import Header from '@/components/organisms/Header/Header'; +import cardStyles from '@/components/organisms/Studio/canvas/cards/Card.module.scss'; +import CardBody from '@/components/organisms/Studio/canvas/cards/CardBody'; +import CardChrome from '@/components/organisms/Studio/canvas/cards/CardChrome'; +import CardHead from '@/components/organisms/Studio/canvas/cards/CardHead'; +import chartStyles from '@/components/organisms/Studio/canvas/cards/ChartCard.module.scss'; +import type { CardExtras } from '@/components/organisms/Studio/canvas/cards/types'; +import { legendShown } from '@/components/organisms/Studio/canvas/chart/chartDatasets'; +import { seriesVars } from '@/components/organisms/Studio/canvas/chart/chartSetup'; +import { + effectiveKind, + GAP, + heightIsDerived, + resolveCardView, + spawnSpan, + widgetTitle, +} from '@/components/organisms/Studio/canvas/layout'; +import boardStyles from '@/components/organisms/Studio/canvas/Whiteboard.module.scss'; +import type { useChat } from '@/components/organisms/Studio/hooks/useChat'; +import studioStyles from '@/components/organisms/Studio/Studio.module.scss'; +import activityStyles from '@/components/organisms/Studio/workspace/ActivityLog.module.scss'; +import pillStyles from '@/components/organisms/Studio/workspace/ApprovedPlanPill.module.scss'; +import ChatPanel from '@/components/organisms/Studio/workspace/ChatPanel'; +import chatStyles from '@/components/organisms/Studio/workspace/ChatPanel.module.scss'; +import barStyles from '@/components/organisms/Studio/workspace/PlanApproveBar.module.scss'; +import planStyles from '@/components/organisms/Studio/workspace/PlanCard.module.scss'; +import WorkspaceToolbar from '@/components/organisms/Studio/workspace/WorkspaceToolbar'; +import type { ChartWidget, Widget } from '@/lib/chat/widgets'; +import { cardRefOf } from '@/lib/studio/cardDownload'; +import { legendLabels } from '@/lib/studio/seriesLabels'; +import { DEFAULT_THEME, deriveTheme, type ThemeDerived, themeVars } from '@/lib/studio/theme'; +import { isPrompt } from '@/lib/studio/transcript'; + +/** + * The Studio workspace as static markup: the skeleton of Studio.tsx and the + * slots of Whiteboard.tsx, rebuilt here because both fill themselves from + * effects, around the app's own components (Header, WorkspaceToolbar, + * ChatPanel, CardBody, CardHead, CardChrome). A chart card keeps + * ChartCard's DOM with the canvas swapped for the plot Chart.js draws in + * Node (chart.tsx). + */ + +// A card's padding and border on each side (Card.module.scss `.card`): +// rem(16) and 1 px, which the plot's width leaves out. +const CARD_INSET = 2 * 16 + 2; + +const noop = () => {}; + +export type CardOut = { + id: string; + kind: Widget['kind']; + hook: string; + col: number; + row: number; + w: number; + h: number; + // px on the whiteboard sheet, from its top-left corner. + x: number; + y: number; + width: number; + height: number; +}; + +export type ChartOut = { + cardId: string; + line: boolean; + plot: { width: number; height: number }; + valueTicks: ChartLayout['valueTicks']; + valueTitle: string | null; + series: (SeriesPath & { label: string })[]; +}; + +export type Rendered = { + html: string; + hooks: string[]; + cards: CardOut[]; + charts: ChartOut[]; + // Hashed class names an animation toggles: the chat panel's active view. + classes: Record; + // Rules for the states the app sets from measurements in effects. + css: string; + states: PlanStates; +}; + +/** The plan's two states, both in the one fragment: the hooks that belong + * to each, for a film to show one and hide the other, and the attributes + * that change once the plan is approved (the chat input's placeholder and + * label), by hook. A state the spec does not hold is absent. */ +export type PlanStates = { + pending?: { hooks: string[] }; + approved?: { hooks: string[]; attrs: Record> }; +}; + +// --- cards ---------------------------------------------------------------------- + +const extrasFor = (widget: Widget): CardExtras => { + const { view, choices } = resolveCardView(widget, undefined); + const isText = widget.kind === 'text'; + + return { + pinned: false, + title: widgetTitle(widget), + view, + viewChoices: choices, + onPin: noop, + onRemove: noop, + onAsk: noop, + onInfo: isText ? null : noop, + onDownload: cardRefOf(widget) ? noop : null, + onDownloadImage: noop, + onEdit: noop, + editLabel: isText ? 'Edit Card' : 'Edit Title', + onNote: isText ? null : noop, + onRemoveNote: isText ? null : noop, + onView: noop, + onShare: noop, + readOnly: false, + }; +}; + +// ChartCard's DOM (head, legend, plot, chrome) around the drawn plot. +const StaticChartCard = ({ + widget, + theme, + extras, + plot, + plotHeight, +}: { + widget: ChartWidget; + theme: ThemeDerived; + extras: CardExtras; + plot: React.ReactNode; + plotHeight: number; +}) => { + const names = legendLabels(widget.series.map((s) => s.label)); + + // The app measures the card's chrome and gives the card the rows that + // chrome and the plot need. Here the plot keeps its height and the card + // grows past its slot when the chrome needs more than the slot gives. + return ( +
    + + {legendShown(widget) && ( +
      + {widget.series.map((s, index) => ( +
    • + +
    • + ))} +
    + )} +
    + {plot} +
    + +
    + ); +}; + +type Placed = { + widget: Widget; + col: number; + row: number; + w: number; + h: number; + body: React.ReactNode; +}; + +// A Whiteboard slot (Whiteboard.tsx): position and size in cells, the card +// box with its grab bar and resize grip, the card inside. +const Slot = ({ placed }: { placed: Placed }) => { + const { widget, col, row, w, h, body } = placed; + const { view } = resolveCardView(widget, undefined); + const isText = widget.kind === 'text'; + + return ( +
    +
    + + {!isText && ( + + + + )} + {body} +
    +
    + ); +}; + +// --- the workspace -------------------------------------------------------------- + +const cardTabsOf = (widgets: Widget[]) => { + // Studio.tsx's tabs: a kind gets a tab once it is on the sheet. + const counts = widgets.reduce>((acc, widget) => { + const kind = effectiveKind(widget, undefined); + + acc[kind] = (acc[kind] ?? 0) + 1; + + return acc; + }, {}); + const tabFor = (id: string, label: string, kind: string) => + counts[kind] ? [{ id, label, count: counts[kind] }] : []; + + return [ + { id: 'all', label: 'All', count: widgets.length }, + ...tabFor('stats', 'Stats', 'key_number'), + ...tabFor('charts', 'Charts', 'chart'), + ...tabFor('tables', 'Tables', 'table'), + ...tabFor('notes', 'Notes', 'text'), + ]; +}; + +const chatOf = (spec: DemoSpec): ReturnType => { + // Every card belongs to the turn of the last answer. + const lastBot = spec.messages.findLastIndex((message) => message.role === 'bot'); + const turn = spec.messages.slice(0, Math.max(lastBot, 0)).filter(isPrompt).length; + + return { + messages: spec.messages, + widgets: spec.cards.map(({ widget }) => ({ ...widget, turn })), + queued: [], + activity: spec.activity ?? [], + status: null, + statusDetail: null, + statusSince: null, + stalled: false, + thinking: false, + clarify: null, + plan: spec.plan ?? null, + agentRemovals: 0, + takeAgentRemoved: () => [], + syncCanvas: noop, + reportHidden: noop, + canvasVersion: 0, + send: noop, + unqueue: noop, + answerClarify: noop, + skipClarify: noop, + respondPlan: noop, + switchChoice: noop, + applyChoice: noop, + stop: noop, + reset: noop, + hydrate: noop, + snapshot: () => ({ sessionId: null, componentRefs: [], turnCount: turn, canvas: null }), + }; +}; + +const Workspace = ({ spec, placed }: { spec: DemoSpec; placed: Placed[] }) => { + const theme = deriveTheme(DEFAULT_THEME); + const widgets = spec.cards.map((card) => card.widget); + const rightCol = Math.max(0, ...placed.map((card) => card.col + card.w)); + const bottomRow = Math.max(0, ...placed.map((card) => card.row + card.h)); + + return ( +
    + + +
    +
    +
    + +
    + {placed.map((card) => ( + + ))} +
    +
    + +
    + + + +
    + +
    + + true} + onGo={noop} + onAdd={noop} + onRemove={noop} + /> +
    +
    + ); +}; + +// --- hooks ---------------------------------------------------------------------- + +const only = (elements: Element[], what: string): Element => { + if (elements.length !== 1) { + throw new Error(`render: expected one ${what}, found ${elements.length}`); + } + + return elements[0]!; +}; + +/** The hooks of each plan state: the card awaiting approval with its + * approve bar (pending), and the pill under the answer (approved). A + * state the spec does not hold has none. */ +const planHooks = (spec: DemoSpec): { pending: string[]; approved: string[] } => ({ + pending: spec.plan ? ['plan', ...(spec.plan.live ? ['plan-bar', 'plan-approve'] : [])] : [], + approved: spec.messages.some((message) => message.role === 'bot' && message.plan) + ? ['plan-pill'] + : [], +}); + +// The chat input's attributes in `markup`. +const promptAttrs = (markup: string): Record => { + const foot = only( + walk(parseMarkup(markup)).filter((el) => hasClass(el, chatStyles.chatFoot!)), + 'chat foot', + ); + + return only( + walk(foot.children).filter((el) => el.name === 'textarea'), + 'prompt field', + ).attrs; +}; + +const planStates = (spec: DemoSpec, placed: Placed[], markup: string): PlanStates => { + const { pending, approved } = planHooks(spec); + + if (!approved.length) { + return pending.length ? { pending: { hooks: pending } } : {}; + } + + // What the chat foot says once no plan waits: rendered without the plan. + const before = promptAttrs(markup); + const after = promptAttrs( + renderToStaticMarkup(), + ); + const changed = Object.entries(after).filter(([name, value]) => before[name] !== value); + + return { + ...(pending.length ? { pending: { hooks: pending } } : {}), + approved: { + hooks: approved, + attrs: changed.length ? { prompt: Object.fromEntries(changed) } : {}, + }, + }; +}; + +// The hooks inside the app's own components, found by their classes. +const chatHooks = (html: string, spec: DemoSpec): string => { + const all = walk(parseMarkup(html)); + const within = (root: Element) => walk(root.children); + const additions: { element: Element; name: string; value: string }[] = []; + const hook = (element: Element, value: string) => + additions.push({ element, name: 'data-v', value }); + + const panel = only( + all.filter((el) => hasClass(el, chatStyles.chatPanel!)), + 'chat panel', + ); + const views = all.filter((el) => hasClass(el, chatStyles.view!)); + const [chatView, activityView] = views; + + if (views.length !== 2 || !chatView || !activityView) { + throw new Error(`render: expected the chat and activity views, found ${views.length}`); + } + + hook(panel, 'chat'); + hook(chatView, 'view-chat'); + hook(activityView, 'view-activity'); + + const transcript = only( + within(chatView).filter((el) => hasClass(el, chatStyles.chatScroll!)), + 'transcript', + ); + + if (transcript.children.length !== spec.messages.length) { + throw new Error( + `render: ${spec.messages.length} messages drew ${transcript.children.length} elements`, + ); + } + + transcript.children.forEach((element, index) => hook(element, `msg-${index}`)); + + const foot = only( + within(chatView).filter((el) => hasClass(el, chatStyles.chatFoot!)), + 'chat foot', + ); + + hook( + only( + within(foot).filter((el) => el.name === 'textarea'), + 'prompt field', + ), + 'prompt', + ); + hook( + only( + within(foot).filter((el) => el.name === 'button' && el.attrs.type === 'submit'), + 'send button', + ), + 'send', + ); + + // The plan awaiting approval (PlanCard in the chat foot) and the approved + // plan under its answer (ApprovedPlanPill): planHooks names them. + const names = planHooks(spec); + + if (names.pending.length) { + const card = only( + within(foot).filter((el) => el.name === 'section' && hasClass(el, planStyles.plan!)), + 'plan card', + ); + + hook(card, 'plan'); + + if (spec.plan?.live) { + const bar = only( + within(foot).filter((el) => hasClass(el, barStyles.bar!)), + 'approve bar', + ); + + hook(bar, 'plan-bar'); + hook( + only( + within(bar).filter( + (el) => el.name === 'button' && hasClass(el, buttonStyles.dark!), + ), + 'Approve button', + ), + 'plan-approve', + ); + } + } + + if (names.approved.length) { + hook( + only( + within(transcript).filter((el) => hasClass(el, pillStyles.pill!)), + 'approved plan pill', + ), + 'plan-pill', + ); + } + + within(activityView) + .filter((el) => el.name === 'section' && hasClass(el, activityStyles.turn!)) + .forEach((turn, index) => { + hook(turn, `activity-${index}`); + within(turn) + .filter((el) => el.name === 'li' && hasClass(el, activityStyles.tool!)) + .forEach((line, step) => hook(line, `activity-${index}-${step}`)); + }); + + // The app's entrance reveals start hidden until an observer marks them; + // the film shows them revealed. + for (const element of all) { + if ('data-reveal' in element.attrs || 'data-reveal-group' in element.attrs) { + additions.push({ element, name: 'data-revealed', value: '' }); + } + } + + return addAttributes(html, additions); +}; + +const hooksIn = (html: string): string[] => + walk(parseMarkup(html)).flatMap((element) => element.attrs['data-v'] ?? []); + +// SegmentedTabs measures the active tab in an effect and slides its +// indicator (SegmentedTabs.module.scss `.indicator`: the pill's full height, +// charcoal, the tab's own radius) under it. The film paints the active tab. +const measuredStates = `.${segmentedStyles.choice}[data-active="true"] { background-color: var(--color-charcoal); }\n`; + +/** A chart laid out in the default theme at `width` px, as a card shows it. */ +export const layoutDefaultChart = ( + widget: ChartWidget, + { line, width, fontsDir }: { line: boolean; width: number; fontsDir: string }, +): ChartLayout => + layoutChart({ + widget, + line, + theme: deriveTheme(DEFAULT_THEME), + width, + measure: outfitMeasure(fontsDir), + }); + +// --- entry ---------------------------------------------------------------------- + +export const renderStudio = (spec: DemoSpec, { fontsDir }: { fontsDir: string }): Rendered => { + const theme = deriveTheme(DEFAULT_THEME); + const measure = outfitMeasure(fontsDir); + const charts: ChartOut[] = []; + + const placed: Placed[] = spec.cards.map(({ widget, slot }) => { + const resolved = resolveCardView(widget, undefined); + const span = spawnSpan(widget, resolved); + const w = slot.w ?? span.w; + const h = slot.h ?? span.h; + const extras = extrasFor(widget); + const { view, chartSource } = resolved; + let body: React.ReactNode; + + if ((view === 'chart-bar' || view === 'chart-line') && chartSource) { + const line = view === 'chart-line'; + const layout = layoutChart({ + widget: chartSource, + line, + theme, + width: w * CELL - CARD_INSET, + measure, + }); + const plot = renderPlot(layout, widget.id, line); + + charts.push({ + cardId: widget.id, + line, + plot: { width: layout.width, height: layout.height }, + valueTicks: layout.valueTicks, + valueTitle: layout.valueTitle, + series: plot.series.map((path) => ({ + ...path, + label: chartSource.series[path.index]?.label ?? '', + })), + }); + body = ( + + ); + } else { + body = ( + + ); + } + + return { widget, col: slot.col, row: slot.row, w, h, body }; + }); + + const viewport = spec.viewport ?? DEFAULT_VIEWPORT; + const markup = renderToStaticMarkup(); + const html = chatHooks(markup, spec); + const states = planStates(spec, placed, markup); + const hooks = hooksIn(html); + const expected = [ + 'chat', + 'canvas', + 'prompt', + 'send', + ...spec.messages.map((_, index) => `msg-${index}`), + ...(states.pending?.hooks ?? []), + ...(states.approved?.hooks ?? []), + ...(spec.activity ?? []).map((_, index) => `activity-${index}`), + ...spec.cards.map(({ widget }) => `card-${widget.id}`), + ...charts.flatMap((chart) => chart.series.map((series) => series.hook)), + ]; + + for (const name of expected) { + const count = hooks.filter((other) => other === name).length; + + if (count !== 1) { + throw new Error(`render: hook "${name}" appears ${count} times`); + } + } + + return { + html: `
    ${html}
    `, + hooks, + cards: placed.map(({ widget, col, row, w, h }) => ({ + id: widget.id, + kind: widget.kind, + hook: `card-${widget.id}`, + col, + row, + w, + h, + x: col * CELL, + y: row * CELL, + width: w * CELL, + height: h * CELL, + })), + charts, + classes: { viewActive: chatStyles.viewActive! }, + css: measuredStates, + states, + }; +}; diff --git a/motion-kit/runtime/dfo-motion.js b/motion-kit/runtime/dfo-motion.js new file mode 100644 index 0000000..3e0ecd7 --- /dev/null +++ b/motion-kit/runtime/dfo-motion.js @@ -0,0 +1,594 @@ +// The film's shared.js: places a generated Studio or a captured page on a +// Motion stage and animates it from a cue sheet. Runs once before any scene; +// each scene calls DFO.mount(kit, …) with its own kit. Plain ES, no imports: +// Motion runs it as-is in its sandbox. +// +// What the app does in JavaScript is mirrored here from its components: +// PromptForm grows its field up to MAX_LINES, and ChatPanel pins the +// transcript to its latest line. What the app does in CSS plays from the +// manifest's own keyframes, timings and easing (cue kind `animate`). +window.DFO = (() => { + const STAGE = { width: 1920, height: 1080 }; + + // PromptForm.tsx MAX_LINES. + const PROMPT_MAX_LINES = 5; + + const manifestOf = (kit) => JSON.parse(kit.module('manifest.json')); + + const cssOf = (kit, manifest) => + manifest.modules.css.map((name) => kit.module(name)).join('\n'); + + // CSS easing functions, as the browser evaluates them. + const bezier = (x1, y1, x2, y2) => { + const at = (a, b, s) => 3 * a * s * (1 - s) ** 2 + 3 * b * s * s * (1 - s) + s ** 3; + return (x) => { + if (x <= 0 || x >= 1) return x <= 0 ? 0 : 1; + let lo = 0; + let hi = 1; + for (let i = 0; i < 40; i++) { + const mid = (lo + hi) / 2; + if (at(x1, x2, mid) < x) lo = mid; + else hi = mid; + } + return at(y1, y2, (lo + hi) / 2); + }; + }; + const NAMED = { + linear: (x) => x, + ease: bezier(0.25, 0.1, 0.25, 1), + 'ease-in': bezier(0.42, 0, 1, 1), + 'ease-out': bezier(0, 0, 0.58, 1), + 'ease-in-out': bezier(0.42, 0, 0.58, 1), + }; + const easing = (spec) => { + if (NAMED[spec]) return NAMED[spec]; + const cubic = /^cubic-bezier\(([^)]+)\)$/.exec(spec); + if (cubic) return bezier(...cubic[1].split(',').map(Number)); + const steps = /^steps\((\d+)(?:,\s*([\w-]+))?\)$/.exec(spec); + if (steps) { + const n = Number(steps[1]); + const start = steps[2] === 'jump-start' || steps[2] === 'start'; + return (x) => Math.min(1, Math.floor(x * n + (start ? 1 : 0)) / n); + } + throw new Error('DFO: unknown easing ' + spec); + }; + + // Where a keyframe list leaves a property unset at 0% or 100%, the element + // shows its own value: opacity 1, no offset, no scaling. + const NUMBER = /-?\d*\.?\d+(?:e-?\d+)?/g; + const resting = (prop, sample) => { + if (prop === 'opacity') return '1'; + if (prop === 'translate') return sample.replace(NUMBER, '0'); + if (prop === 'scale') return sample.replace(NUMBER, '1'); + if (prop === 'transform') { + return sample.replace( + /([a-zA-Z3]+)\(([^)]*)\)/g, + (_, fn, args) => + fn + '(' + args.replace(NUMBER, /^scale/.test(fn) ? '1' : '0') + ')', + ); + } + return null; + }; + // Interpolates two values of one shape number by number; a value of + // another shape switches at the midpoint, as discrete CSS properties do. + const mix = (a, b, p) => { + const an = a.match(NUMBER) ?? []; + const bn = b.match(NUMBER) ?? []; + if (an.length !== bn.length || a.replace(NUMBER, '#') !== b.replace(NUMBER, '#')) { + return p < 0.5 ? a : b; + } + let i = 0; + return a.replace(NUMBER, () => { + const v = Number(an[i]) + (Number(bn[i]) - Number(an[i])) * p; + i++; + return String(Math.round(v * 1000) / 1000); + }); + }; + + // A keyframe list as full 0%..100% steps, each holding every property. + const steps = (frames) => { + const props = [...new Set(frames.flatMap((f) => Object.keys(f.style)))]; + const sorted = [...frames].sort((a, b) => a.offset - b.offset); + const ends = []; + if (sorted[0].offset > 0) ends.push({ offset: 0, style: {} }); + ends.push(...sorted); + if (sorted[sorted.length - 1].offset < 1) ends.push({ offset: 1, style: {} }); + for (const prop of props) { + const sample = sorted.find((f) => f.style[prop] != null).style[prop]; + for (const f of ends) { + if (f.style[prop] == null) f.style = { ...f.style, [prop]: resting(prop, sample) }; + } + } + return { props, frames: ends }; + }; + const sample = ({ props, frames }, p) => { + const out = {}; + let i = 0; + while (i < frames.length - 2 && frames[i + 1].offset <= p) i++; + const a = frames[i]; + const b = frames[i + 1]; + const local = b.offset === a.offset ? 1 : (p - a.offset) / (b.offset - a.offset); + for (const prop of props) { + const from = a.style[prop]; + const to = b.style[prop]; + out[prop] = + from == null || to == null ? (local < 0.5 ? from : to) : mix(from, to, local); + } + return out; + }; + + // One CSS animation from the manifest, as a function of seconds since it + // was triggered: null where the element shows its own style. + const player = (entry, frames) => { + const ease = easing(entry.timing); + const fill = entry.fill; + const runs = entry.iterations === 'infinite' ? Infinity : entry.iterations; + const plan = steps(frames); + // Iteration `pass` at progress p, in the direction it plays. + const at = (pass, p) => { + const odd = pass % 2 === 1; + const back = + entry.direction === 'reverse' || + (entry.direction === 'alternate' && odd) || + (entry.direction === 'alternate-reverse' && !odd); + return sample(plan, ease(back ? 1 - p : p)); + }; + return (local) => { + const t = local - entry.delay; + if (t < 0) return fill === 'backwards' || fill === 'both' ? at(0, 0) : null; + const total = entry.duration * runs; + if (t >= total) { + if (fill !== 'forwards' && fill !== 'both') return null; + const part = runs % 1; + return part ? at(Math.floor(runs), part) : at(runs - 1, 1); + } + return at(Math.floor(t / entry.duration), (t % entry.duration) / entry.duration); + }; + }; + + // Wraps every word inside `el` in a span, once, and returns the spans in + // reading order, so a cue can reveal them by count. + const wordSpans = (kit, el) => { + const walker = el.ownerDocument.createTreeWalker(el, NodeFilter.SHOW_TEXT); + const nodes = []; + + for (let node = walker.nextNode(); node; node = walker.nextNode()) { + if (node.nodeValue.trim()) nodes.push(node); + } + + const spans = []; + + for (const node of nodes) { + const frag = el.ownerDocument.createDocumentFragment(); + + for (const part of node.nodeValue.split(/(\s+)/)) { + if (!part) continue; + if (/^\s+$/.test(part)) { + frag.append(part); + continue; + } + const span = kit.el('span', { text: part }); + spans.push(span); + frag.append(span); + } + node.replaceWith(frag); + } + + return spans; + }; + + // Prepares one cue against the mounted fragment; returns `apply(t)`, the + // attributes it writes as [element, name] pairs (`touches`: `style` for + // most, `class` or the state's own for `tab` and `state`, which set + // `attrs`) and the time it ends. Every frame starts from those + // attributes' own values (`mount` restores them), so a cue writes only + // from its own time, and cues apply in list order: a later cue on the + // same element wins. + const prepare = (kit, studio, cue) => { + const { seg, eo, ei, eob, lerp } = kit; + + if (cue.kind === 'animate') { + // Plays the app's own animation: every manifest entry on the hook + // (or inside it, or with that keyframe name), triggered at `at`. + const entries = (studio.manifest.animations ?? []).filter((a) => + cue.name + ? a.name === cue.name + : a.v === cue.v && (cue.sel == null || a.sel === cue.sel), + ); + if (!entries.length) + throw new Error('DFO: no app animation for ' + (cue.name ?? cue.v)); + const runs = entries.map((entry) => { + const el = entry.v + ? studio.q(entry.v, entry.sel) + : studio.root.querySelector(entry.sel); + if (!el) throw new Error('DFO: nothing at ' + entry.sel); + return { el, play: player(entry, studio.manifest.keyframes[entry.name]) }; + }); + const end = Math.max( + ...entries.map((a) => + a.iterations === 'infinite' ? 0 : a.delay + a.duration * a.iterations, + ), + ); + return { + touches: runs.map((r) => [r.el, 'style']), + end: cue.at + end, + apply(t) { + for (const { el, play } of runs) { + const style = play(t - cue.at); + for (const prop of Object.keys(style ?? {})) el.style[prop] = style[prop]; + } + }, + }; + } + + if (cue.kind === 'state') { + // Switches the fragment's attributes to a manifest state's, as the + // app does when the plan is approved (the prompt's placeholder). + const attrs = studio.manifest.states?.[cue.to]?.attrs ?? {}; + const changes = Object.entries(attrs).flatMap(([hook, values]) => + Object.entries(values).map(([name, value]) => ({ + el: studio.q(hook), + name, + value, + })), + ); + return { + touches: changes.map((c) => [c.el, c.name]), + attrs: true, + end: cue.at, + apply(t) { + if (t < cue.at) return; + for (const c of changes) c.el.setAttribute(c.name, c.value); + }, + }; + } + + const el = studio.q(cue.v, cue.sel); + const s = el.style; + const dur = cue.dur ?? 0.4; + const only = (end, apply) => ({ touches: [[el, 'style']], end, apply }); + // An entering element takes no room before its time. + const before = (t) => { + if (t >= cue.at) return false; + s.display = 'none'; + return true; + }; + + switch (cue.kind) { + case 'show': + // dur 0: the element simply appears, as a new message does. + return only(cue.at + dur, (t) => { + if (before(t) || !dur) return; + const p = eo(seg(t, cue.at, cue.at + dur)); + s.opacity = p; + s.transform = 'translateY(' + lerp(cue.dy ?? 10, 0, p) + 'px)'; + }); + case 'pop': { + // For film overlays; product cards use `animate`. + const end = cue.at + (cue.dur ?? 0.45); + return only(end, (t) => { + if (before(t)) return; + const p = seg(t, cue.at, end); + s.opacity = eo(Math.min(1, p * 1.6)); + s.transform = 'scale(' + lerp(0.94, 1, eob(p, 1.5)) + ')'; + }); + } + case 'stream': { + // The words are spans of the cue's own, so they keep their + // state between frames and change only when the count does. + const spans = wordSpans(kit, el); + const wps = cue.wps ?? 9; + let shown = -1; + return only(cue.at + spans.length / wps, (t) => { + before(t); + const n = Math.max(0, Math.min(spans.length, Math.floor((t - cue.at) * wps))); + if (n === shown) return; + // Unstreamed words take no room, so the transcript grows + // line by line as the app's does. + spans.forEach((span, i) => (span.style.display = i < n ? '' : 'none')); + shown = n; + }); + } + case 'draw': { + if (typeof el.getTotalLength !== 'function') + throw new Error('DFO: a draw cue needs an SVG shape at ' + cue.v); + const length = el.getTotalLength(); + const points = cue.points ? studio.q(cue.points) : null; + const end = cue.at + (cue.dur ?? 1.6); + return { + touches: points + ? [ + [el, 'style'], + [points, 'style'], + ] + : [[el, 'style']], + end, + apply(t) { + if (before(t)) return; + const p = kit.eio(seg(t, cue.at, end)); + s.strokeDasharray = String(length); + s.strokeDashoffset = String(length * (1 - p)); + if (points) points.style.opacity = p >= 1 ? 1 : 0; + }, + }; + } + case 'type': { + const typer = kit.typist(cue.text, cue.at, cue.cps ?? 24); + return only(Math.max(typer.end, cue.clearAt ?? 0), (t) => { + const text = cue.clearAt != null && t >= cue.clearAt ? '' : typer.at(t); + el.value = text; + el.textContent = text; + // PromptForm's own sizing: the field's content height, + // capped at MAX_LINES lines. + s.height = 'auto'; + const line = parseFloat(getComputedStyle(el).lineHeight) || el.scrollHeight; + s.height = Math.min(el.scrollHeight, line * PROMPT_MAX_LINES) + 'px'; + }); + } + case 'press': + return only(cue.at + 0.16, (t) => { + if (t < cue.at) return; + s.transform = + 'scale(' + + (1 - 0.06 * Math.sin(Math.PI * seg(t, cue.at, cue.at + 0.16))) + + ')'; + }); + case 'remove': + // Gone at once, as the app unmounts it (the pending plan on approval). + return only(cue.at, (t) => { + if (t >= cue.at) s.display = 'none'; + }); + case 'hide': + // For film overlays: fades out, then folds the height it has + // as earlier cues leave it. + return only(cue.at + 0.2 + dur, (t) => { + if (t < cue.at) return; + const height = el.offsetHeight; + const fold = kit.eio(seg(t, cue.at + 0.2, cue.at + 0.2 + dur)); + s.opacity = 1 - ei(seg(t, cue.at, cue.at + 0.25)); + if (fold <= 0) return; + s.overflow = 'hidden'; + s.height = lerp(height, 0, fold) + 'px'; + if (fold < 1) return; + s.marginTop = s.marginBottom = '0'; + s.visibility = 'hidden'; + }); + case 'tab': { + // Switches the chat panel between Chat and Activity at `at`. + const active = studio.manifest.classes?.viewActive; + if (!active) throw new Error('DFO: a tab cue needs the Studio, not a page capture'); + const from = studio.q(cue.from); + return { + touches: [ + [el, 'class'], + [from, 'class'], + ], + attrs: true, + end: cue.at, + apply(t) { + if (t < cue.at) return; + el.classList.add(active); + from.classList.remove(active); + }, + }; + } + default: + throw new Error('DFO: unknown cue kind ' + cue.kind + ' on ' + cue.v); + } + }; + + /** + * Mounts the generated Studio, or one captured page state, in a scene. + * Options: `state` (a captured page's state; default its first), `keys` + * (camera keys; a key may name a `hook` to centre on), `fit` (scale on + * top of fitting the stage), `desk` (the colour around the frame). A + * capture's key or cue that names a state in `in` plays in that state only. + * Returns: + * - q(hook, sel?): the element with that data-v hook, or `sel` inside it; + * - focus(hook, z): a camera key centred on that element; + * - cue(list): adds cues; render(t): applies cues, then the camera; + * - end: when the last cue or camera hold ends, in seconds. + * + * Camera keys are {t, cx, cy, z, hold?, from?, drift?} in stage px, where + * the whole frame fits the stage at z = 1: the view rests on a key for + * `hold` seconds, then eases to the next one, arriving at its `t`. A key + * that names a hook follows that element where the layout has put it at + * that moment. + */ + const mount = (kit, options = {}) => { + const manifest = manifestOf(kit); + const { width, height } = manifest.viewport; + const fit = Math.min(STAGE.width / width, STAGE.height / height) * (options.fit ?? 1); + const left = (STAGE.width - width * fit) / 2; + const top = (STAGE.height - height * fit) / 2; + + kit.css(cssOf(kit, manifest)); + kit.css( + '.dfo-desk { position:absolute; inset:0; overflow:hidden; background:' + + (options.desk ?? '#ecece0') + + ' }' + + '.dfo-cam { position:absolute; left:0; top:0; width:' + + STAGE.width + + 'px; height:' + + STAGE.height + + 'px; transform-origin:0 0 }' + + '.dfo-frame { position:absolute; transform-origin:0 0; border-radius:14px; overflow:hidden;' + + ' box-shadow:0 24px 70px rgba(0,0,0,.12) }', + ); + + const desk = kit.h( + '
    ', + ); + const camLayer = desk.firstChild; + const frame = camLayer.firstChild; + // A Studio render has one fragment; a page capture has one per state. + const html = manifest.modules.html; + const state = + typeof html === 'string' ? undefined : (options.state ?? Object.keys(html)[0]); + const file = state === undefined ? html : html[state]; + if (!file) throw new Error('DFO: no captured state ' + state); + // A capture's cue or key with `in` plays in that state only. + const here = (item) => item.in == null || item.in === state; + frame.innerHTML = kit.module(file); + Object.assign(frame.style, { + left: left + 'px', + top: top + 'px', + width: width + 'px', + height: height + 'px', + transform: 'scale(' + fit + ')', + }); + kit.stage.append(desk); + + // ChatPanel keeps its transcript at scrollTop = scrollHeight. A film + // cannot scroll an element, so the same offset moves the transcript's + // children with `translate`, which no cue uses. + const transcript = frame.querySelector('[data-v="view-chat"] [class*="chatScroll"]'); + const pin = () => { + if (!transcript) return; + for (const child of transcript.children) child.style.translate = ''; + const shift = Math.max(0, transcript.scrollHeight - transcript.clientHeight); + for (const child of transcript.children) { + child.style.translate = shift ? '0 ' + -shift + 'px' : ''; + } + }; + + const cues = []; + let keys = []; + // Each attribute a cue writes, by element and name, with its own value, + // which every frame starts from so no cue's value outlives its time. + const own = new Map(); + const apply = (t) => { + for (const [el, values] of own) { + for (const [name, value] of values) { + if (value == null) el.removeAttribute(name); + else el.setAttribute(name, value); + } + } + // Attributes and classes first (`state` and `tab`): a cue that + // measures, such as the prompt's height under its placeholder, + // must see this frame's. + for (const c of cues) if (c.attrs) c.apply(t); + for (const c of cues) if (!c.attrs) c.apply(t); + }; + + // An element's box in stage px at z = 1, as it shows inside the frame: + // the frame's own rendered size gives the scale above it (the fit, the + // camera and the player's scaling of the stage), so every transform + // inside the frame counts, the transcript pin and a capture's scroll + // offset among them. + const box = (el) => { + if (!frame.contains(el) || !el.getClientRects().length) + throw new Error('DFO: cannot place ' + (el.dataset.v ?? el.className)); + const f = frame.getBoundingClientRect(); + const r = el.getBoundingClientRect(); + const scale = f.width / frame.offsetWidth; + return { + x: left + ((r.left - f.left) / scale) * fit, + y: top + ((r.top - f.top) / scale) * fit, + w: (r.width / scale) * fit, + h: (r.height / scale) * fit, + }; + }; + + // A key that names a hook centres on that element as the layout + // stands at the key's own time: the cues are applied at k.t to + // measure it. A fixed time gives a fixed layout, so it is measured + // once. + const placed = new Map(); + const point = (k) => { + if (!k.hook) return k; + if (!placed.has(k)) { + apply(k.t); + pin(); + const r = box(studio.q(k.hook)); + placed.set(k, { ...k, cx: r.x + r.w / 2, cy: r.y + r.h / 2 }); + } + return placed.get(k); + }; + + const view = (t) => { + const at = (k, s) => { + const d = k.drift ?? [0, 0, 0]; + const dt = Math.max(0, s - k.t); + return { + cx: k.cx + d[0] * dt, + cy: k.cy + d[1] * dt, + lz: Math.log(k.z) + d[2] * dt, + }; + }; + let i = 0; + while (i + 1 < keys.length && keys[i + 1].t <= t) i++; + const a = point(keys[i]); + const next = keys[i + 1]; + if (!next || t <= a.t) return at(a, Math.max(t, a.t)); + const from = next.from ?? a.t + (a.hold ?? 0); + if (t <= from) return at(a, t); + const b = point(next); + const p = kit.eio(kit.seg(t, from, b.t)); + const s = at(a, from); + return { + cx: kit.lerp(s.cx, b.cx, p), + cy: kit.lerp(s.cy, b.cy, p), + lz: kit.lerp(s.lz, Math.log(b.z), p), + }; + }; + + const studio = { + manifest, + root: frame, + // `sel` narrows to a descendant, for controls with no hook of their own. + q(hook, sel) { + const base = '[data-v="' + hook + '"]'; + const el = frame.querySelector(sel ? base + ' ' + sel : base); + if (!el) throw new Error('DFO: nothing at ' + hook + (sel ? ' ' + sel : '')); + return el; + }, + box, + focus(hook, z = 1.6) { + const r = box(studio.q(hook)); + return { cx: r.x + r.w / 2, cy: r.y + r.h / 2, z }; + }, + cue(list) { + for (const c of list.filter(here)) { + const prepared = prepare(kit, studio, c); + for (const [el, name] of prepared.touches) { + if (!own.has(el)) own.set(el, new Map()); + const values = own.get(el); + if (!values.has(name)) values.set(name, el.getAttribute(name)); + } + cues.push(prepared); + } + return studio; + }, + // When the last cue ends or the camera's last hold does. + get end() { + const last = keys[keys.length - 1]; + return Math.max(last.t + (last.hold ?? 0), ...cues.map((c) => c.end)); + }, + render(t) { + // The camera first: placing a key may apply the cues at + // another time, so the cues for t come after. + const v = view(t); + apply(t); + pin(); + const z = Math.exp(v.lz); + camLayer.style.transform = + 'translate(' + + (STAGE.width / 2 - v.cx * z) + + 'px,' + + (STAGE.height / 2 - v.cy * z) + + 'px) scale(' + + z + + ')'; + }, + }; + + const wide = { cx: STAGE.width / 2, cy: STAGE.height / 2, z: 1 }; + // No keys holds the whole stage in view. + const mine = (options.keys ?? []).filter(here); + keys = (mine.length ? mine : [{ t: 0 }]).map((k) => ({ ...wide, ...k })); + return studio; + }; + + return { mount }; +})(); diff --git a/motion-kit/snapshot.ts b/motion-kit/snapshot.ts new file mode 100644 index 0000000..7c7151d --- /dev/null +++ b/motion-kit/snapshot.ts @@ -0,0 +1,572 @@ +/** + * The functions a page capture runs inside the browser, through Playwright's + * `page.evaluate`. Playwright sends each one as source text, so each is + * self-contained: no imports, no helpers from this module, only its + * argument and the page's globals. + */ + +import type { PageHook } from './spec.ts'; + +/** A box in viewport px. */ +export type Box = { x: number; y: number; width: number; height: number }; + +/** A stylesheet's text and the URL its relative `url()`s resolve against. + * `media` is the owner's `media` attribute, when it has one. */ +export type SheetSource = { key: string; base: string; text: string; media: string }; + +export type Snapshot = { + // The state as one `
    ` fragment. + html: string; + // The computed font size of the page's root element, in px. + rootFontSize: number; + // What the sanitiser removed and could not inline, one line each. + dropped: string[]; + sheets: SheetSource[]; +}; + +/** + * Stamps each hook's `data-v` on the one element its selector finds, after + * clearing the stamps of an earlier state, and returns each hook's box, or + * null when the selector finds nothing in this state. Throws when a + * selector finds more than one element. + */ +export function stampHooks(hooks: PageHook[]): Record { + for (const element of document.querySelectorAll('[data-v]')) { + element.removeAttribute('data-v'); + } + + const boxes: Record = {}; + const round = (n: number) => Math.round(n * 100) / 100; + + for (const { v, selector } of hooks) { + const found = document.querySelectorAll(selector); + + if (found.length > 1) { + throw new Error(`hook "${v}": "${selector}" matches ${found.length} elements`); + } + + const element = found[0]; + + if (!element) { + boxes[v] = null; + continue; + } + + element.setAttribute('data-v', v); + + const rect = element.getBoundingClientRect(); + + boxes[v] = { + x: round(rect.x), + y: round(rect.y), + width: round(rect.width), + height: round(rect.height), + }; + } + + return boxes; +} + +/** For each selector, whether it finds an element in the page now. A + * selector the browser cannot parse finds nothing. */ +export function matchSelectors(selectors: string[]): boolean[] { + return selectors.map((selector) => { + try { + return document.querySelector(selector) !== null; + } catch { + return false; + } + }); +} + +/** + * The page as it stands, made static for a film: + * + * - `document.body` is copied into an inert document (no scripts run, no + * custom element upgrades, no loads) inside `
    `, with + * `
    ` and `
    ` standing for the root and + * the body and carrying their attributes; + * - scripts, styles, links, frames, media, templates, comments, event + * attributes and elements with a shadow root are removed; + * - a canvas becomes an `` of its pixels, and images, ``s and + * inline `url()`s become `data:` URLs; what cannot be fetched is dropped + * and reported, as is any other URL that leaves the page; + * - form controls carry their current value, checked and selected states + * as attributes; + * - hovered, focused and active elements carry `data-mk-hover`, + * `data-mk-focus`, `data-mk-focus-visible`, `data-mk-focus-within` and + * `data-mk-active`, which the stylesheet's dynamic pseudo-classes become; + * - a scrolled element's children (the body's, for the window) are + * translated by the scroll offset, past any element that holds a fixed + * one, so fixed elements stay where they show, and a stuck element is + * moved back to where it shows. + * + * Also returns the text of every stylesheet in the page. + */ +export async function snapshotPage({ + viewport, + state, +}: { + viewport: { width: number; height: number }; + state: string; +}): Promise { + const dropped: string[] = []; + const drop = (what: string) => { + if (!dropped.includes(what)) { + dropped.push(what); + } + }; + const inside = (url: string) => url.startsWith('data:') || url.startsWith('#'); + const absolute = (url: string) => new URL(url, document.baseURI).href; + const cache = new Map>(); + const dataUrl = (url: string): Promise => { + const href = absolute(url); + + if (!cache.has(href)) { + cache.set( + href, + (async () => { + try { + const response = await fetch(href); + + if (!response.ok) { + return null; + } + + const blob = await response.blob(); + + return await new Promise((resolve, reject) => { + const reader = new FileReader(); + + reader.onload = () => resolve(String(reader.result)); + reader.onerror = () => reject(reader.error); + reader.readAsDataURL(blob); + }); + } catch { + return null; + } + })(), + ); + } + + return cache.get(href)!; + }; + const inlineCssUrls = async (text: string, what: string): Promise => { + let out = text; + + for (const match of text.matchAll(/url\(\s*(['"]?)([^'")]*)\1\s*\)/g)) { + const url = match[2]!; + + if (inside(url)) { + continue; + } + + const data = await dataUrl(url); + + if (!data) { + drop(`${what}: url(${url})`); + } + + out = out.replace(match[0], data ? `url("${data}")` : 'none'); + } + + return out; + }; + + // --- measurements on the live page, before anything changes ------------- + + const scroller = document.scrollingElement ?? document.documentElement; + const live = [document.body, ...document.body.querySelectorAll('*')]; + const computed = live.map((element) => getComputedStyle(element)); + const sticky = live.filter((_, index) => computed[index]!.position === 'sticky'); + const stuckAt = sticky.map((element) => element.getBoundingClientRect()); + + // Where each sticky element would sit unstuck: static takes the same + // room in the flow, so switching them all at once moves nothing else. + const inlinePosition = sticky.map((element) => (element as HTMLElement).style.position); + + for (const element of sticky) { + (element as HTMLElement).style.position = 'static'; + } + + const unstuckAt = sticky.map((element) => element.getBoundingClientRect()); + + sticky.forEach((element, index) => { + (element as HTMLElement).style.position = inlinePosition[index]!; + }); + + // --- the copy ------------------------------------------------------------- + + const inert = document.implementation.createHTMLDocument(''); + const copies = (() => { + const body = inert.importNode(document.body, true); + + return [body, ...body.querySelectorAll('*')]; + })(); + const shift = new Map(); + const addShift = (element: Element, x: number, y: number) => { + const current = shift.get(element) ?? { x: 0, y: 0 }; + + shift.set(element, { x: current.x + x, y: current.y + y }); + }; + const scrollOf = (element: Element) => + element === document.body + ? { x: scroller.scrollLeft, y: scroller.scrollTop } + : { x: element.scrollLeft, y: element.scrollTop }; + // A translated ancestor would become a fixed element's containing block + // and carry it away from where it shows, so the scroll moves an element + // that holds a fixed one by moving its other children instead. + const fixed = live.filter((_, index) => computed[index]!.position === 'fixed'); + const scrollWith = (element: Element, x: number, y: number) => { + const index = live.indexOf(element); + + if (computed[index]!.position === 'fixed') { + return; + } + + if (!fixed.some((other) => element.contains(other))) { + addShift(element, x, y); + + return; + } + + if ( + [...element.childNodes].some((node) => node.nodeType === 3 && node.textContent?.trim()) + ) { + drop( + `${copies[index]!.localName}: text beside a scrolled element's moved children stays unscrolled`, + ); + } + + for (const child of element.children) { + scrollWith(child, x, y); + } + }; + + live.forEach((element, index) => { + const { x, y } = scrollOf(element); + + if (!x && !y) { + return; + } + + if ( + [...element.childNodes].some((node) => node.nodeType === 3 && node.textContent?.trim()) + ) { + drop( + `${copies[index]!.localName}: text beside the children of a scrolled element stays unscrolled`, + ); + } + + for (const child of element.children) { + scrollWith(child, -x, -y); + } + }); + + sticky.forEach((element, index) => { + const dx = stuckAt[index]!.x - unstuckAt[index]!.x; + const dy = stuckAt[index]!.y - unstuckAt[index]!.y; + + if (dx || dy) { + addShift(element, dx, dy); + } + }); + + const remove = new Set(); + const pending: Promise[] = []; + const MEDIA = new Set(['video', 'audio', 'object', 'embed', 'iframe', 'frame', 'portal']); + const GONE = new Set(['script', 'noscript', 'template', 'style', 'link', 'meta', 'base']); + const URL_ATTRIBUTES = [ + 'src', + 'srcset', + 'action', + 'formaction', + 'poster', + 'ping', + 'background', + ]; + + live.forEach((element, index) => { + const copy = copies[index]!; + const name = element.localName; + const style = computed[index]!; + + if (GONE.has(name) || element.shadowRoot) { + if (element.shadowRoot) { + drop(`<${name}> with a shadow root`); + } + + remove.add(copy); + + return; + } + + if (MEDIA.has(name)) { + // A box of the same size keeps the layout. + const box = inert.createElement('div'); + const rect = element.getBoundingClientRect(); + + for (const key of ['class', 'data-v']) { + const value = element.getAttribute(key); + + if (value !== null) { + box.setAttribute(key, value); + } + } + + box.setAttribute( + 'style', + `display:${style.display === 'inline' ? 'inline-block' : style.display};width:${rect.width}px;height:${rect.height}px`, + ); + copy.replaceWith(box); + drop( + `<${name}${element.getAttribute('src') ? ` src="${element.getAttribute('src')}"` : ''}> (left as an empty box)`, + ); + + return; + } + + // Attributes: event handlers go; URLs that leave the page go. + for (const attribute of [...copy.attributes]) { + const key = attribute.name.toLowerCase(); + const value = attribute.value; + + if (key.startsWith('on')) { + copy.removeAttribute(attribute.name); + } else if ((key === 'href' || key === 'xlink:href') && !inside(value)) { + if (name === 'a' || name === 'area') { + // `#` keeps the link a link for `:link` and `[href]`. + copy.setAttribute(attribute.name, '#'); + } else { + drop(`<${name}> ${key}="${value}"`); + copy.removeAttribute(attribute.name); + } + } else if (URL_ATTRIBUTES.includes(key) && !(name === 'img' && key === 'src')) { + if (!inside(value)) { + copy.removeAttribute(attribute.name); + } + } else if (key === 'style' && /url\(/.test(value)) { + pending.push( + inlineCssUrls(value, `<${name}> style`).then((text) => { + // The scroll shift below may have set a translate + // already; it outlives the rewrite. + const { translate } = (copy as HTMLElement).style; + + copy.setAttribute('style', text); + + if (translate) { + (copy as HTMLElement).style.translate = translate; + } + }), + ); + } + } + + copy.removeAttribute('autofocus'); + + if (name === 'img') { + const image = element as HTMLImageElement; + const source = image.currentSrc || image.src; + + copy.removeAttribute('srcset'); + copy.removeAttribute('sizes'); + + if (source && !source.startsWith('data:')) { + pending.push( + dataUrl(source).then((data) => { + if (data) { + copy.setAttribute('src', data); + } else { + copy.removeAttribute('src'); + drop(``); + } + }), + ); + } + } else if (name === 'source') { + remove.add(copy); + } else if (name === 'image' && element.namespaceURI === 'http://www.w3.org/2000/svg') { + const href = element.getAttribute('href') ?? element.getAttribute('xlink:href'); + + if (href && !inside(href)) { + pending.push( + dataUrl(href).then((data) => { + if (data) { + copy.setAttribute('href', data); + } else { + drop(``); + } + }), + ); + } + } else if (name === 'canvas') { + const canvas = element as HTMLCanvasElement; + const image = inert.createElement('img'); + + for (const attribute of copy.attributes) { + image.setAttribute(attribute.name, attribute.value); + } + + try { + image.setAttribute('src', canvas.toDataURL('image/png')); + } catch { + drop(' whose pixels cannot be read (left blank)'); + } + + image.setAttribute('width', String(canvas.width)); + image.setAttribute('height', String(canvas.height)); + + // The canvas's laid-out size, so the pixel buffer (device px) + // does not set it. + const rect = element.getBoundingClientRect(); + + image.style.width = `${rect.width}px`; + image.style.height = `${rect.height}px`; + copy.replaceWith(image); + copies[index] = image; + } else if (name === 'input') { + const input = element as HTMLInputElement; + + if (input.type === 'checkbox' || input.type === 'radio') { + copy.toggleAttribute('checked', input.checked); + } else if (input.type !== 'file') { + copy.setAttribute('value', input.value); + } + } else if (name === 'textarea') { + copy.textContent = (element as HTMLTextAreaElement).value; + } else if (name === 'option') { + copy.toggleAttribute('selected', (element as HTMLOptionElement).selected); + } + + // The interaction states the film cannot have, as attributes. + for (const [pseudo, mark] of [ + [':hover', 'data-mk-hover'], + [':active', 'data-mk-active'], + [':focus', 'data-mk-focus'], + [':focus-visible', 'data-mk-focus-visible'], + [':focus-within', 'data-mk-focus-within'], + ] as const) { + if (element.matches(pseudo)) { + copies[index]!.setAttribute(mark, ''); + } + } + + const moved = shift.get(element); + + if (moved) { + // The individual `translate` property composes with any + // `transform` the element has; an existing one is added to. + const [tx = '0px', ty = '0px', tz] = + style.translate === 'none' ? [] : style.translate.split(' '); + const add = (length: string, px: number) => + length.endsWith('px') && !length.includes('(') + ? `${parseFloat(length) + px}px` + : `calc(${length} + ${px}px)`; + + (copies[index] as HTMLElement).style.translate = [ + add(tx, moved.x), + add(ty, moved.y), + ...(tz ? [tz] : []), + ].join(' '); + } + }); + + await Promise.all(pending); + + for (const copy of remove) { + copy.remove(); + } + + const comments = inert.createTreeWalker(copies[0]!, NodeFilter.SHOW_COMMENT); + const commentNodes: Node[] = []; + + while (comments.nextNode()) { + commentNodes.push(comments.currentNode); + } + + for (const comment of commentNodes) { + comment.parentNode?.removeChild(comment); + } + + // --- the fragment --------------------------------------------------------- + + const stand = (tag: 'html' | 'body', source: Element): HTMLElement => { + const element = inert.createElement('div'); + + for (const attribute of source.attributes) { + if (!attribute.name.toLowerCase().startsWith('on')) { + element.setAttribute(attribute.name, attribute.value); + } + } + + element.setAttribute(`data-mk-${tag}`, ''); + + return element; + }; + const wrapper = inert.createElement('div'); + const html = stand('html', document.documentElement); + const body = stand('body', copies[0]!); + + body.append(...copies[0]!.childNodes); + html.append(body); + wrapper.append(html); + wrapper.setAttribute('class', 'dfo-ui'); + wrapper.setAttribute('data-viewport', `${viewport.width}x${viewport.height}`); + wrapper.setAttribute('data-state', state); + + // The root's background (or the body's, when the root has none) paints + // the whole viewport in a browser; the wrapper stands for the viewport. + const rootStyle = getComputedStyle(document.documentElement); + const canvasStyle = + rootStyle.backgroundColor === 'rgba(0, 0, 0, 0)' && rootStyle.backgroundImage === 'none' + ? computed[0]! + : rootStyle; + + if (canvasStyle.backgroundColor !== 'rgba(0, 0, 0, 0)') { + wrapper.style.backgroundColor = canvasStyle.backgroundColor; + } + + // --- the stylesheets ------------------------------------------------------ + + const sheets: SheetSource[] = []; + + for (const sheet of [...document.styleSheets, ...document.adoptedStyleSheets]) { + if (sheet.disabled) { + continue; + } + + const media = sheet.media.mediaText; + + if (sheet.href) { + const response = await fetch(sheet.href).catch(() => null); + + if (!response) { + drop(`stylesheet ${sheet.href}`); + continue; + } + + // A sheet the server no longer has (a rebuild since the page + // loaded) would leave its rules out of the film unnoticed. + if (!response.ok) { + throw new Error( + `motion-kit: stylesheet ${sheet.href} answered ${response.status} when re-read`, + ); + } + + sheets.push({ key: sheet.href, base: sheet.href, text: await response.text(), media }); + } else { + const text = + sheet.ownerNode?.textContent ?? + [...sheet.cssRules].map((rule) => rule.cssText).join('\n'); + + sheets.push({ key: `inline:${text}`, base: document.baseURI, text, media }); + } + } + + return { + html: wrapper.outerHTML, + rootFontSize: parseFloat(rootStyle.fontSize), + dropped, + sheets, + }; +} diff --git a/motion-kit/spec.ts b/motion-kit/spec.ts new file mode 100644 index 0000000..4455a28 --- /dev/null +++ b/motion-kit/spec.ts @@ -0,0 +1,372 @@ +/** + * The demo spec: one Studio conversation, frozen in its running state, that + * the generator renders to static markup. Every shape below is the app's + * own type, so a spec says exactly what the Studio would hold. + */ + +import type { ChatMessage, PlanState } from '../src/components/organisms/Studio/hooks/useChat.ts'; +import type { Widget } from '../src/lib/chat/widgets.ts'; +import type { ActivityEntry } from '../src/lib/studio/activity.ts'; + +/** Where a card sits on the whiteboard, in grid cells. `w` and `h` default + * to the span the Studio gives a fresh card of that kind (`spawnSpan`). */ +export type CardSlot = { col: number; row: number; w?: number; h?: number }; + +export type DemoCard = { widget: Widget; slot: CardSlot }; + +export type Viewport = { width: number; height: number }; + +export type DemoSpec = { + // The conversation title in the tools row. + title: string; + // The prompt the film types into the chat input. + question: string; + // The transcript. A bot message's `plan` is the plan the user approved + // for that answer, shown under it. + messages: ChatMessage[]; + // The plan awaiting approval, as useChat holds it. + plan?: PlanState; + activity?: ActivityEntry[]; + cards: DemoCard[]; + // CSS px at a 16 px root; 1280×800 when absent. + viewport?: Viewport; +}; + +export const DEFAULT_VIEWPORT: Viewport = { width: 1280, height: 800 }; + +/** The narrowest viewport a demo renders at: below it the Studio takes its + * phone layout (`breakpoint(phone)`, under 660 px), which the generator + * does not model. */ +export const MIN_DEMO_WIDTH = 660; + +// What PlanCard and PlanLedger read from a PlanState. +const PLAN_FIELDS = [ + 'title', + 'summary', + 'steps', + 'answerStepId', + 'decisions', + 'caveats', + 'movedElsewhere', + 'version', + 'actions', + 'interruptId', + 'live', + 'revision', + 'baseline', + 'changed', + 'switched', + 'stepsUpdated', + 'switching', + 'notice', +] as const satisfies readonly (keyof PlanState)[]; + +/** The design cell in px at a 16 px root (GRID_CELL in GridPattern). */ +export const CELL = 20; + +/** The class of the element that wraps the fragment and scopes its CSS. */ +export const WRAPPER = 'dfo-ui'; + +/** The root font size the fragment is laid out at. */ +export const ROOT_FONT_SIZE = 16; + +const fail = (message: string): never => { + throw new Error(`demo spec: ${message}`); +}; + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +/** + * The spec as parsed JSON, checked for what the generator relies on: the + * required fields, card ids that are unique and usable as hook names, and + * bot messages that render (an empty one renders nothing, so it would have + * no element to carry its hook). + */ +export const readSpec = (raw: unknown): DemoSpec => { + if (!isRecord(raw)) { + return fail('expected an object'); + } + + for (const key of ['title', 'question'] as const) { + if (typeof raw[key] !== 'string' || !raw[key]) { + fail(`"${key}" must be a non-empty string`); + } + } + + if (!Array.isArray(raw.messages) || !Array.isArray(raw.cards)) { + return fail('"messages" and "cards" must be arrays'); + } + + if (raw.viewport !== undefined) { + const viewport = raw.viewport as Partial; + + if ( + !isRecord(viewport) || + !Number.isInteger(viewport.width) || + !Number.isInteger(viewport.height) || + viewport.height! <= 0 + ) { + fail('"viewport" needs whole, positive "width" and "height"'); + } + + if (viewport.width! < MIN_DEMO_WIDTH) { + fail( + `"viewport" is ${viewport.width} px wide; the Studio takes its phone layout ` + + `under ${MIN_DEMO_WIDTH} px, which the generator does not render`, + ); + } + } + + if (raw.plan !== undefined) { + const missing = isRecord(raw.plan) + ? PLAN_FIELDS.filter((key) => !(key in (raw.plan as object))) + : PLAN_FIELDS; + + if (missing.length) { + fail(`"plan" is a PlanState as useChat holds it; it lacks ${missing.join(', ')}`); + } + } + + const spec = raw as DemoSpec; + + spec.messages.forEach((message, index) => { + if ( + message.role === 'bot' && + !message.text && + !message.followups?.length && + !message.chips?.length && + !message.plan + ) { + fail(`message ${index} is an empty bot turn, which renders nothing`); + } + }); + + if (spec.messages.filter((message) => message.role === 'bot' && message.plan).length > 1) { + fail('only one answer may carry an approved "plan"; its pill is the "plan-pill" hook'); + } + + const ids = new Set(); + + for (const { widget, slot } of spec.cards) { + if (!/^[\w-]+$/.test(widget.id)) { + fail(`card id "${widget.id}" must be letters, digits, "_" or "-"`); + } + + if (ids.has(widget.id)) { + fail(`card id "${widget.id}" is used twice`); + } + + ids.add(widget.id); + + if (!isRecord(slot)) { + return fail(`card "${widget.id}" needs a "slot"`); + } + + for (const key of ['col', 'row'] as const) { + if (!Number.isInteger(slot[key]) || slot[key] < 0) { + fail(`card "${widget.id}" needs a whole, non-negative "${key}"`); + } + } + + for (const key of ['w', 'h'] as const) { + const span = slot[key]; + + if (span !== undefined && (!Number.isInteger(span) || span <= 0)) { + fail(`card "${widget.id}" needs a whole, positive "${key}" when it sets one`); + } + } + } + + return spec; +}; + +// --- page capture ------------------------------------------------------------- + +/** A `data-v` hook stamped on the one element `selector` finds. */ +export type PageHook = { v: string; selector: string }; + +export type PageStep = + | { type: 'click' | 'hover' | 'focus'; selector: string } + | { type: 'type'; selector: string; text: string } + | { type: 'press'; key: string } + | { type: 'wait'; selector?: string; ms?: number } + | { type: 'scroll'; selector?: string; y: number } + | { type: 'capture'; state: string }; + +/** A route of the running site, driven through `steps` and snapshotted at + * each `capture` step. */ +export type PageSpec = { + name: string; + // The route with its query, e.g. "/data?q=kenya+debt". + path: string; + // CSS px; 1280×800 when absent. + viewport?: Viewport; + hooks?: PageHook[]; + // A spec with no capture step captures once at the end, as "page". + steps?: PageStep[]; +}; + +const failPage = (message: string): never => { + throw new Error(`page spec: ${message}`); +}; + +const NAME = /^[\w-]+$/; + +const isText = (value: unknown): value is string => typeof value === 'string' && value !== ''; + +const readStep = (raw: unknown, index: number): PageStep => { + const at = `step ${index}`; + + if (!isRecord(raw)) { + return failPage(`${at} must be an object`); + } + + const need = (key: string) => { + if (!isText(raw[key])) { + failPage(`${at} (${String(raw.type)}) needs a non-empty "${key}"`); + } + }; + const optionalNumber = (key: string) => { + if (raw[key] !== undefined && (typeof raw[key] !== 'number' || raw[key] < 0)) { + failPage(`${at} (${String(raw.type)}) "${key}" must be a number of at least 0`); + } + }; + + switch (raw.type) { + case 'click': + case 'hover': + case 'focus': + need('selector'); + break; + case 'type': + need('selector'); + + if (typeof raw.text !== 'string') { + failPage(`${at} (type) needs a "text" string`); + } + + break; + case 'press': + need('key'); + break; + case 'wait': + if (raw.selector !== undefined) { + need('selector'); + } + + optionalNumber('ms'); + + if (raw.selector === undefined && raw.ms === undefined) { + failPage(`${at} (wait) needs a "selector" or "ms"`); + } + + break; + case 'scroll': + if (raw.selector !== undefined) { + need('selector'); + } + + if (typeof raw.y !== 'number') { + failPage(`${at} (scroll) needs a "y" number`); + } + + break; + case 'capture': + need('state'); + + if (!NAME.test(raw.state as string)) { + failPage(`${at} state "${String(raw.state)}" must be letters, digits, "_" or "-"`); + } + + break; + default: + failPage(`${at} has the unknown type "${String(raw.type)}"`); + } + + return raw as PageStep; +}; + +/** + * The page spec as parsed JSON, checked for what the capture relies on: a + * name and a route, a whole-px viewport, hooks with unique names usable as + * `data-v` values, known steps with their fields, and capture states that + * are unique and usable as file names. + */ +export const readPageSpec = (raw: unknown): PageSpec => { + if (!isRecord(raw)) { + return failPage('expected an object'); + } + + if (!isText(raw.name) || !NAME.test(raw.name)) { + failPage('"name" must be letters, digits, "_" or "-"'); + } + + if (!isText(raw.path) || !raw.path.startsWith('/')) { + failPage('"path" must be a route starting with "/"'); + } + + if (raw.viewport !== undefined) { + const viewport = raw.viewport as Partial; + + if ( + !isRecord(viewport) || + !Number.isInteger(viewport.width) || + !Number.isInteger(viewport.height) || + viewport.width! <= 0 || + viewport.height! <= 0 + ) { + failPage('"viewport" needs whole, positive "width" and "height"'); + } + } + + const hooks = raw.hooks ?? []; + const steps = raw.steps ?? []; + + if (!Array.isArray(hooks) || !Array.isArray(steps)) { + return failPage('"hooks" and "steps" must be arrays'); + } + + const names = new Set(); + + for (const hook of hooks) { + if (!isRecord(hook) || !isText(hook.v) || !isText(hook.selector)) { + failPage('each hook needs a non-empty "v" and "selector"'); + } + + const { v } = hook as PageHook; + + if (!NAME.test(v)) { + failPage(`hook "${v}" must be letters, digits, "_" or "-"`); + } + + if (names.has(v)) { + failPage(`hook "${v}" is named twice`); + } + + names.add(v); + } + + const states = new Set(); + + for (const step of steps.map(readStep)) { + if (step.type === 'capture') { + if (states.has(step.state)) { + failPage(`state "${step.state}" is captured twice`); + } + + states.add(step.state); + } + } + + return raw as PageSpec; +}; + +/** The spec's steps, ending in a capture of "page" when none captures. */ +export const pageSteps = (spec: PageSpec): PageStep[] => { + const steps = spec.steps ?? []; + + return steps.some((step) => step.type === 'capture') + ? steps + : [...steps, { type: 'capture', state: 'page' }]; +}; diff --git a/motion-kit/stateClasses.ts b/motion-kit/stateClasses.ts new file mode 100644 index 0000000..8524151 --- /dev/null +++ b/motion-kit/stateClasses.ts @@ -0,0 +1,158 @@ +import { dirname, join, relative, resolve } from 'node:path'; + +import ts from 'typescript'; + +/** + * The state classes of the app's components: for each class a component + * always applies, the classes it adds to the same element on a condition, + * read from the `classNames(…)` calls in the component's source. A + * condition reads `cond && styles.x`, `cond ? styles.x : styles.y` or + * `{ [styles.x]: cond }`; the rest of the call's `styles.*` arguments are + * the element's own classes. Class names come out as the bundle hashed + * them, from the class map of each SCSS module (`styles`, by repo path). + */ + +export type Source = { path: string; text: string }; + +/** Class maps of the bundle's SCSS modules, by path from the repo root. */ +export type StyleModules = Record>; + +/** Each own class mapped to the state classes that toggle beside it. */ +export type StateClasses = Record; + +const modulePath = (specifier: string, from: string, repo: string): string => + relative( + repo, + specifier.startsWith('@/') + ? join(repo, 'src', specifier.slice(2)) + : resolve(dirname(from), specifier), + ); + +export const stateClasses = ( + sources: Source[], + styles: StyleModules, + repo: string, +): StateClasses => { + const found = new Map>(); + + for (const { path, text } of sources) { + const file = ts.createSourceFile( + path, + text, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TSX, + ); + // The local names of the `classnames` import and of each SCSS module. + const joiners = new Set(); + const modules = new Map>(); + + for (const statement of file.statements) { + const name = ts.isImportDeclaration(statement) && statement.importClause?.name?.text; + + if (!name || !ts.isStringLiteral(statement.moduleSpecifier)) { + continue; + } + + const specifier = statement.moduleSpecifier.text; + + if (specifier === 'classnames') { + joiners.add(name); + } else if (specifier.endsWith('.module.scss')) { + const map = styles[modulePath(specifier, path, repo)]; + + if (map) { + modules.set(name, map); + } + } + } + + if (!joiners.size || !modules.size) { + continue; + } + + const className = (node: ts.Expression): string | undefined => { + if (ts.isPropertyAccessExpression(node) && ts.isIdentifier(node.expression)) { + return modules.get(node.expression.text)?.[node.name.text]; + } + + if ( + ts.isElementAccessExpression(node) && + ts.isIdentifier(node.expression) && + ts.isStringLiteral(node.argumentExpression) + ) { + return modules.get(node.expression.text)?.[node.argumentExpression.text]; + } + + return undefined; + }; + + const read = ( + node: ts.Expression, + conditional: boolean, + own: string[], + states: string[], + ) => { + const inner = ts.isParenthesizedExpression(node) ? node.expression : node; + const name = className(inner); + + if (name) { + (conditional ? states : own).push(name); + } else if (ts.isBinaryExpression(inner)) { + const operator = inner.operatorToken.kind; + + if (operator === ts.SyntaxKind.AmpersandAmpersandToken) { + read(inner.right, true, own, states); + } else if ( + operator === ts.SyntaxKind.BarBarToken || + operator === ts.SyntaxKind.QuestionQuestionToken + ) { + read(inner.left, true, own, states); + read(inner.right, true, own, states); + } + } else if (ts.isConditionalExpression(inner)) { + read(inner.whenTrue, true, own, states); + read(inner.whenFalse, true, own, states); + } else if (ts.isObjectLiteralExpression(inner)) { + for (const property of inner.properties) { + if ( + ts.isPropertyAssignment(property) && + ts.isComputedPropertyName(property.name) + ) { + read(property.name.expression, true, own, states); + } + } + } + }; + + const visit = (node: ts.Node) => { + if ( + ts.isCallExpression(node) && + ts.isIdentifier(node.expression) && + joiners.has(node.expression.text) + ) { + const own: string[] = []; + const states: string[] = []; + + for (const argument of node.arguments) { + read(argument, false, own, states); + } + + for (const base of own) { + const set = found.get(base) ?? new Set(); + + states.forEach((state) => set.add(state)); + found.set(base, set); + } + } + + ts.forEachChild(node, visit); + }; + + visit(file); + } + + return Object.fromEntries( + [...found].filter(([, set]) => set.size).map(([base, set]) => [base, [...set].toSorted()]), + ); +}; diff --git a/motion-kit/stubs/next-link.tsx b/motion-kit/stubs/next-link.tsx new file mode 100644 index 0000000..ee8c0cc --- /dev/null +++ b/motion-kit/stubs/next-link.tsx @@ -0,0 +1,20 @@ +import type { AnchorHTMLAttributes } from 'react'; + +// Link's own props, which an anchor does not take. +const LINK_PROPS = new Set(['prefetch', 'replace', 'scroll', 'shallow', 'locale']); + +/** `next/link` for a render outside Next: a plain anchor. */ +const Link = ({ + href, + ...rest +}: Omit, 'href'> & { + href: string | { pathname?: string }; + [prop: string]: unknown; +}) => ( + !LINK_PROPS.has(key)))} + /> +); + +export default Link; diff --git a/motion-kit/stubs/next-navigation.ts b/motion-kit/stubs/next-navigation.ts new file mode 100644 index 0000000..77fbf1a --- /dev/null +++ b/motion-kit/stubs/next-navigation.ts @@ -0,0 +1,19 @@ +/** + * `next/navigation` for a render outside Next: the Studio's own path, a + * router that goes nowhere and no query. + */ + +const noop = () => {}; + +export const usePathname = (): string => '/studio'; + +export const useRouter = () => ({ + push: noop, + replace: noop, + refresh: noop, + back: noop, + forward: noop, + prefetch: noop, +}); + +export const useSearchParams = (): URLSearchParams => new URLSearchParams(); diff --git a/motion-kit/stubs/server-only.ts b/motion-kit/stubs/server-only.ts new file mode 100644 index 0000000..59b90ef --- /dev/null +++ b/motion-kit/stubs/server-only.ts @@ -0,0 +1,2 @@ +// `server-only` for a render outside Next: the import is a guard, nothing more. +export {}; diff --git a/motion-kit/test/capture.test.ts b/motion-kit/test/capture.test.ts new file mode 100644 index 0000000..5b11a63 --- /dev/null +++ b/motion-kit/test/capture.test.ts @@ -0,0 +1,414 @@ +/** + * Page capture: the page spec's validation, the sanitiser's leak check, and + * captures of a small fixture page served from this process, so the tests + * need Chrome (or CHROMIUM_PATH) but not the site. The browser tests skip + * when no browser starts. + */ +import assert from 'node:assert/strict'; +import { mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'; +import { createServer } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { after, before, test } from 'node:test'; + +import type { Browser } from 'playwright-core'; + +import { launchBrowser } from '../browser.ts'; +import { generatePage } from '../capture.ts'; +import { htmlLeaks } from '../html.ts'; +import { pageSteps, readPageSpec } from '../spec.ts'; + +const PNG = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==', + 'base64', +); + +const FIXTURE = ` + + + + + + +
    Fixture
    + + + + + + +
    tile
    + + + + +
    elsewhere +

    one

    two

    three

    four

    +

    a

    b

    +

    styled inline

    +
    +

    pinned

    + +`; + +const CSS = ` +body { margin: 0; padding-top: 3rem; font-size: 1rem; } +.tall { height: 1000px; } +.pin { position: fixed; top: 20px; right: 0; margin: 0; } +.bar { position: fixed; top: 0; left: 0; right: 0; height: 2rem; } +.tile { width: 50vw; background: url(/tile.png) repeat; } +.tile:hover { outline: 2px solid rgb(0, 0, 255); } +.go::before { content: '>'; } +.list { height: 40px; overflow: auto; } +.list p { height: 30px; margin: 0; } +.never-rendered { color: red; } +@media (max-width: 400px) { .tile { width: 10px; } } +@keyframes spin { to { transform: rotate(1turn); } } +`; + +const serve = async () => { + // Served once: the page loads it, and the capture's re-read finds it gone. + let flakyServed = false; + const server = createServer((request, response) => { + const files: Record = { + '/': ['text/html', FIXTURE], + '/flaky': ['text/html', '

    flaky

    '], + '/flaky.css': ['text/css', 'p { color: red; }'], + '/site.css': ['text/css', CSS], + '/logo.png': ['image/png', PNG], + '/tile.png': ['image/png', PNG], + '/frame.html': ['text/html', '

    frame

    '], + }; + const path = new URL(request.url ?? '/', 'http://x').pathname; + const gone = path === '/flaky.css' && flakyServed; + + flakyServed ||= path === '/flaky.css'; + + const file = gone ? undefined : files[path]; + + response.writeHead(file ? 200 : 404, { 'content-type': file?.[0] ?? 'text/plain' }); + response.end(file?.[1] ?? 'not found'); + }); + + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + + return { server, base: `http://127.0.0.1:${(server.address() as AddressInfo).port}` }; +}; + +test('a page spec is checked for its route, hooks, steps and states', () => { + const spec = { + name: 'kenya', + path: '/data', + hooks: [{ v: 'prompt', selector: 'textarea' }], + steps: [ + { type: 'capture', state: 'empty' }, + { type: 'type', selector: 'textarea', text: 'Kenya' }, + { type: 'wait', ms: 10 }, + { type: 'scroll', y: 200 }, + { type: 'capture', state: 'typed' }, + ], + }; + + assert.deepEqual(readPageSpec(structuredClone(spec)), spec); + + const refusals: [unknown, RegExp][] = [ + [{ ...spec, name: 'a b' }, /"name"/], + [{ ...spec, path: 'data' }, /"path"/], + [{ ...spec, viewport: { width: 0, height: 800 } }, /"viewport"/], + [ + { + ...spec, + hooks: [ + { v: 'x', selector: 'a' }, + { v: 'x', selector: 'b' }, + ], + }, + /named twice/, + ], + [{ ...spec, hooks: [{ v: 'x y', selector: 'a' }] }, /hook "x y"/], + [{ ...spec, steps: [{ type: 'drag', selector: 'a' }] }, /unknown type "drag"/], + [{ ...spec, steps: [{ type: 'type', selector: 'a' }] }, /"text"/], + [{ ...spec, steps: [{ type: 'click' }] }, /"selector"/], + [{ ...spec, steps: [{ type: 'wait' }] }, /"selector" or "ms"/], + [{ ...spec, steps: [{ type: 'scroll' }] }, /"y"/], + [{ ...spec, steps: [{ type: 'capture', state: 'a/b' }] }, /state "a\/b"/], + [ + { + ...spec, + steps: [ + { type: 'capture', state: 'a' }, + { type: 'capture', state: 'a' }, + ], + }, + /captured twice/, + ], + ]; + + for (const [raw, message] of refusals) { + assert.throws(() => readPageSpec(raw), message); + } + + assert.deepEqual(pageSteps({ name: 'p', path: '/', steps: [{ type: 'press', key: 'Tab' }] }), [ + { type: 'press', key: 'Tab' }, + { type: 'capture', state: 'page' }, + ]); +}); + +test('the leak check finds what a film cannot hold', () => { + assert.deepEqual( + htmlLeaks( + '
    ' + + 'x' + + '
    ', + ), + [ + '