diff --git a/apps/web/package.json b/apps/web/package.json index bb6894ed..6f545fe0 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -14,6 +14,7 @@ "@corbits-builder/client": "workspace:*", "@corbits-builder/schemas": "workspace:*", "@corbits/react-ui": "0.5.1", + "@corbits/slidedeck": "workspace:*", "@radix-ui/react-dialog": "1.3.1", "@radix-ui/react-dropdown-menu": "2.2.1", "@radix-ui/react-slot": "1.5.0", diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index d012c0b9..8939b459 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -187,16 +187,18 @@ function ProjectLoadFailure({ onBackToProjects: () => void; }) { const [copied, setCopied] = useState(false); - const copyDetail = () => { - void navigator.clipboard - .writeText(detail) - .then(() => { - setCopied(true); - setTimeout(() => setCopied(false), 1500); - }) - .catch(() => { - // Clipboard access can be denied; the text is still selectable in the disclosure. - }); + const copyDetail = async () => { + try { + await navigator.clipboard.writeText(detail); + } catch (cause) { + // Clipboard access can be denied, or missing outside a secure origin; + // the text stays selectable in the disclosure. + console.warn("Copying the failure detail failed.", cause); + toast.error(wording.message.copyFailed); + return; + } + setCopied(true); + setTimeout(() => setCopied(false), 1500); }; return ( @@ -216,7 +218,7 @@ function ProjectLoadFailure({
{wording.projectLoadFailure.details}
{detail}
- diff --git a/apps/web/src/pages/workspace/agent-activity.tsx b/apps/web/src/pages/workspace/agent-activity.tsx index f2688353..d3736137 100644 --- a/apps/web/src/pages/workspace/agent-activity.tsx +++ b/apps/web/src/pages/workspace/agent-activity.tsx @@ -4,7 +4,7 @@ * carried. A phrase is a fixed reading of the latest call, never a guess at * progress. */ -import { useQuery } from "@tanstack/react-query"; +import { queryOptions, useQuery } from "@tanstack/react-query"; import type { AgentActivity } from "@corbits-builder/client"; import { readProjectActivity, @@ -135,8 +135,8 @@ export function activityPhrase(agent: AgentActivity): { }; } -export function useProjectActivity(projectId: string) { - return useQuery({ +export function projectActivityQuery(projectId: string) { + return queryOptions({ queryKey: keys.projectActivity.of(projectId), queryFn: ({ signal }) => readProjectActivity(projectId, signal), // While anyone is mid-turn, a hand-off and each step read sooner. @@ -146,12 +146,16 @@ export function useProjectActivity(projectId: string) { }); } +export function useProjectActivity(projectId: string) { + return useQuery(projectActivityQuery(projectId)); +} + /** * Each live run's state by its latest committed event, polled while any run is live. A run that * parks on an approval raises no room event, so this is how the page learns the person's turn came. */ -export function useProjectRunStates(projectId: string) { - return useQuery({ +export function projectRunStatesQuery(projectId: string) { + return queryOptions({ queryKey: keys.projectRunStates.of(projectId), queryFn: ({ signal }) => readProjectRunStates(projectId, signal), refetchInterval: (query) => @@ -162,6 +166,10 @@ export function useProjectRunStates(projectId: string) { }); } +export function useProjectRunStates(projectId: string) { + return useQuery(projectRunStatesQuery(projectId)); +} + /** The runs held on an approval, as one string a React effect can depend on. */ export function heldOnApproval( states: { runs: readonly { runId: string; state: string }[] } | undefined, @@ -174,8 +182,12 @@ export function heldOnApproval( .join(","); } -/** The roles with a run mid-turn right now, from the hub's native activity read. */ -export function useRolesAtWork(projectId: string): ReadonlySet { - const agents = useProjectActivity(projectId).data?.agents ?? []; - return new Set(agents.map((agent) => agent.role)); +/** The roles with a run mid-turn right now, from the hub's native activity read; undefined until it is read. */ +export function useRolesAtWork( + projectId: string, +): ReadonlySet | undefined { + const agents = useProjectActivity(projectId).data?.agents; + return agents === undefined + ? undefined + : new Set(agents.map((agent) => agent.role)); } diff --git a/apps/web/src/pages/workspace/approaches-comparison.tsx b/apps/web/src/pages/workspace/approaches-comparison.tsx index 567d4f86..c946838a 100644 --- a/apps/web/src/pages/workspace/approaches-comparison.tsx +++ b/apps/web/src/pages/workspace/approaches-comparison.tsx @@ -18,6 +18,7 @@ import type { } from "@corbits-builder/schemas/approaches"; import { useState } from "react"; import { wording } from "../../config.ts"; +import { humanFault } from "./human-fault.ts"; const text = wording.approaches; @@ -48,14 +49,17 @@ export function ApproachesComparison({ }) { const [choosing, setChoosing] = useState(null); const [fault, setFault] = useState(null); - const choose = (approach: Approach) => { + const choose = async (approach: Approach) => { if (onChoose === undefined) return; setChoosing(approach.id); setFault(null); - void onChoose(text.message(approach.name)) - .then(setFault) - .catch(() => setFault(text.notSent)) - .finally(() => setChoosing(null)); + try { + setFault(await onChoose(text.message(approach.name))); + } catch (cause) { + setFault(humanFault(cause, text.notSent)); + } finally { + setChoosing(null); + } }; return (
@@ -95,7 +99,7 @@ export function ApproachesComparison({ size="sm" variant={recommended ? "primary" : "outline"} disabled={choosing !== null} - onClick={() => choose(approach)} + onClick={() => void choose(approach)} > {choosing === approach.id ? text.choosing : text.choose} diff --git a/apps/web/src/pages/workspace/approval-package-preview.test.tsx b/apps/web/src/pages/workspace/approval-package-preview.test.tsx index 946ddc50..37e64cca 100644 --- a/apps/web/src/pages/workspace/approval-package-preview.test.tsx +++ b/apps/web/src/pages/workspace/approval-package-preview.test.tsx @@ -1,6 +1,9 @@ import { describe, expect, test } from "bun:test"; +import type { ReactNode } from "react"; import { renderToStaticMarkup } from "react-dom/server"; +import { prerender } from "react-dom/static"; import type { ApprovalPackageBundle } from "@corbits-builder/schemas/artifact"; +import { wording } from "../../config.ts"; import { APPROVAL_PACKAGE_MEDIA_TYPE, ApprovalPackagePreview, @@ -8,6 +11,12 @@ import { parseApprovalPackage, } from "./approval-package-preview.tsx"; +// Waits for the lazily loaded slides, as a browser would. +async function render(node: ReactNode) { + const { prelude } = await prerender(node); + return new Response(prelude).text(); +} + const sampleBundle: ApprovalPackageBundle = { manifest: { audience: "Executive steering committee", @@ -91,13 +100,13 @@ describe("approval package preview", () => { expect(parseApprovalPackage(JSON.stringify({ manifest: {} }))).toBeNull(); const pdfFile = approvalPackagePdfFile("portal-package-v1", sampleBundle); - expect(pdfFile.name).toBe("portal-package-v1.pdf"); - expect(pdfFile.type).toBe("application/pdf"); + expect(pdfFile?.name).toBe("portal-package-v1.pdf"); + expect(pdfFile?.type).toBe("application/pdf"); expect(APPROVAL_PACKAGE_MEDIA_TYPE).toContain("approval-package+json"); }); - test("renders slides view with theme colors, comparison columns, and speaker notes", () => { - const html = renderToStaticMarkup( + test("renders slides view with theme colors, comparison columns, and speaker notes", async () => { + const html = await render( { expect(html).toContain("#1e293b"); }); - test("renders PDF view with print-accurate page sheets and download link without blocked data: iframe", () => { - const html = renderToStaticMarkup( + test("renders PDF view with print-accurate page sheets and download link without blocked data: iframe", async () => { + const html = await render( { expect(html).toContain("Customer Portal Approval Package"); }); - test("enforces high-contrast text on dark-background slide decks", () => { + test("a PDF whose base64 does not decode offers no download and says it could not be read", async () => { + const html = await render( + , + ); + expect(html).toContain(wording.approvalPackage.pdfUnreadable); + expect(html).not.toContain(wording.approvalPackage.downloadPdf); + expect(html).toContain("Page 1 of 2"); + }); + + test("a deck that is not a valid slide deck says it could not be read in place of its slides and pages, and keeps the PDF download and sources", async () => { + const content = JSON.stringify({ + ...sampleBundle, + deck: { + theme: { colors: { background: "#ffffff" } }, + slides: [{ layout: "title", title: "Half a deck" }], + }, + }); + const views = await Promise.all( + (["slides", "pdf", "sources"] as const).map((tab) => + render( + , + ), + ), + ); + const [slides, pdf, sources] = views; + expect(slides).toContain(wording.approvalPackage.unreadable); + expect(slides).not.toContain("Half a deck"); + expect(pdf).toContain(wording.approvalPackage.unreadable); + expect(pdf).toContain(wording.approvalPackage.downloadPdf); + expect(pdf).not.toContain("Half a deck"); + expect(sources).toContain("Customer Portal Brief"); + }); + + test("draws text inside groups on free slides, and nested list items are numbered like their parent unless they say otherwise", async () => { + const html = await render( + , + ); + expect(html).toContain("

Grouped headline

"); + expect(html).toContain("Pilot
  1. Pick regions
"); + expect(html).toContain("Launch
  • Announce
"); + }); + + test("enforces high-contrast text on dark-background slide decks", async () => { const darkDeckBundle: ApprovalPackageBundle = { ...sampleBundle, deck: { @@ -147,6 +249,7 @@ describe("approval package preview", () => { text: "#f8fafc", muted: "#64748b", }, + fonts: { heading: "Inter", body: "Inter" }, }, slides: [ { @@ -158,7 +261,7 @@ describe("approval package preview", () => { ], }, }; - const html = renderToStaticMarkup( + const html = await render( {text.slidesUnavailable}

; } -interface DeckTheme { - colors?: DeckThemeColors | undefined; - fonts?: - | { - heading?: string; - body?: string; - } - | undefined; -} - -interface ResolvedSlidePalette { - isDark: boolean; - background: string; - text: string; - heading: string; - subtitle: string; - accentBar: string; - columnHeading: string; - columnBg: string; - columnBorder: string; -} - -type SlideBodyBlock = - | string - | { - type?: string; - kind?: string; - text?: unknown; - ordered?: boolean; - items?: unknown[]; - }; - -interface DeckSlide { - layout?: string; - title?: string; - subtitle?: string; - body?: string | SlideBodyBlock[]; - left?: unknown; - right?: unknown; - elements?: unknown[]; - caption?: string; - notes?: string; - speakerNotes?: string; -} - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function normalizeHex(color: string | undefined): string | undefined { - if (color === undefined || color.trim() === "") return undefined; - const trimmed = color.trim(); - if (/^[0-9a-fA-F]{6}$/.test(trimmed)) return `#${trimmed}`; - return trimmed; -} - -function parseHexRgb(hex: string | undefined): [number, number, number] | null { - const normalized = normalizeHex(hex); - if (normalized === undefined) return null; - const raw = normalized.startsWith("#") ? normalized.slice(1) : normalized; - if (/^[0-9a-fA-F]{3}$/.test(raw)) { - const r = Number.parseInt(raw.charAt(0) + raw.charAt(0), 16); - const g = Number.parseInt(raw.charAt(1) + raw.charAt(1), 16); - const b = Number.parseInt(raw.charAt(2) + raw.charAt(2), 16); - return [r, g, b]; - } - if (/^[0-9a-fA-F]{6}$/.test(raw)) { - const r = Number.parseInt(raw.slice(0, 2), 16); - const g = Number.parseInt(raw.slice(2, 4), 16); - const b = Number.parseInt(raw.slice(4, 6), 16); - return [r, g, b]; +// The slide deck schema is costly to build, so it loads only once a package's slides are shown. +// A chunk that fails to load (a redeploy dropped its hash, the network, a blocker) would throw +// while rendering and take the whole app down, so it shows a line in the slides' place instead. +async function slidesModule() { + try { + return await import("./approval-package-slides.tsx"); + } catch (cause) { + if (!(cause instanceof TypeError)) throw cause; + console.warn("The approval package slides could not load.", cause); + return null; } - return null; } +const DeckSlides = lazy(async () => ({ + default: (await slidesModule())?.DeckSlides ?? SlidesUnavailable, +})); +const DeckPages = lazy(async () => ({ + default: (await slidesModule())?.DeckPages ?? SlidesUnavailable, +})); -function channelLuminance(channel: number): number { - const srgb = channel / 255; - return srgb <= 0.03928 ? srgb / 12.92 : ((srgb + 0.055) / 1.055) ** 2.4; -} - -function relativeLuminance(hex: string | undefined): number | null { - const rgb = parseHexRgb(hex); - if (rgb === null) return null; - return ( - 0.2126 * channelLuminance(rgb[0]) + - 0.7152 * channelLuminance(rgb[1]) + - 0.0722 * channelLuminance(rgb[2]) - ); -} - -function pickLightOnDark( - color: string | undefined, - minLum: number, - fallback: string, -): string { - if (color === undefined) return fallback; - const lum = relativeLuminance(color); - return lum !== null && lum >= minLum ? color : fallback; -} - -function pickDarkOnLight( - color: string | undefined, - maxLum: number, - fallback: string, -): string { - if (color === undefined) return fallback; - const lum = relativeLuminance(color); - return lum !== null && lum <= maxLum ? color : fallback; -} - -// Dark-background decks can still declare dark brand tokens for primary/secondary; -// clamping by relative luminance keeps headings and subtitles legible on the stage. -function resolveSlidePalette(theme: DeckTheme): ResolvedSlidePalette { - const colors = theme.colors; - const rawBg = normalizeHex(colors?.background); - const bgLum = relativeLuminance(rawBg); - const isDark = bgLum !== null && bgLum < 0.25; - - const primary = normalizeHex(colors?.primary); - const secondary = normalizeHex(colors?.secondary ?? colors?.muted); - const accent = normalizeHex(colors?.accent ?? colors?.primary); - const rawText = normalizeHex(colors?.text); - - if (isDark) { - const brightPrimary = pickLightOnDark(primary, 0.35, "#38bdf8"); - return { - isDark: true, - background: rawBg === undefined ? "#0f172a" : rawBg, - text: pickLightOnDark(rawText, 0.55, "#f8fafc"), - heading: pickLightOnDark(primary, 0.55, "#f8fafc"), - subtitle: pickLightOnDark(secondary, 0.5, "#cbd5e1"), - accentBar: pickLightOnDark(accent, 0.25, brightPrimary), - columnHeading: pickLightOnDark(accent, 0.35, brightPrimary), - columnBg: "rgba(255, 255, 255, 0.055)", - columnBorder: "rgba(255, 255, 255, 0.14)", - }; - } - - return { - isDark: false, - background: rawBg === undefined ? "#ffffff" : rawBg, - text: pickDarkOnLight(rawText, 0.35, "#0f172a"), - heading: pickDarkOnLight(primary, 0.45, "#0f172a"), - subtitle: pickDarkOnLight(secondary, 0.45, "#475569"), - accentBar: primary ?? accent ?? "#0284c7", - columnHeading: pickDarkOnLight(primary, 0.45, "#0284c7"), - columnBg: "rgba(15, 23, 42, 0.035)", - columnBorder: "rgba(15, 23, 42, 0.1)", - }; -} +const loadingSlides =

{text.loadingSlides}

; export function parseApprovalPackage( content: string, @@ -189,370 +54,37 @@ export function parseApprovalPackage( } } +/** The package's PDF as a file, or null when its base64 does not decode. */ export function approvalPackagePdfFile( stem: string, bundle: ApprovalPackageBundle, -): File { +): File | null { const dataUrlMatch = /^data:[^;,]*;base64,(.*)$/s.exec(bundle.pdfBase64); const encoded = ( dataUrlMatch === null ? bundle.pdfBase64 : (dataUrlMatch[1] as string) ).replace(/\s+/g, ""); - let bytes = new Uint8Array(0); + let binary: string; try { - const binary = atob(encoded); - bytes = new Uint8Array(binary.length); - for (let index = 0; index < binary.length; index += 1) { - bytes[index] = binary.charCodeAt(index); - } + binary = atob(encoded); } catch (error) { - if (!(error instanceof Error)) throw error; - bytes = new Uint8Array(0); - } - return new File([bytes], `${stem}.pdf`, { type: "application/pdf" }); -} - -function readDeckTheme(deck: Record): DeckTheme { - const rawTheme = deck["theme"]; - if (!isRecord(rawTheme)) { - return { colors: undefined, fonts: undefined }; - } - const colors = isRecord(rawTheme["colors"]) - ? (rawTheme["colors"] as DeckThemeColors) - : undefined; - const fonts = isRecord(rawTheme["fonts"]) - ? (rawTheme["fonts"] as DeckTheme["fonts"]) - : undefined; - return { colors, fonts }; -} - -function readDeckSlides(deck: Record): DeckSlide[] { - const rawSlides = deck["slides"]; - if (!Array.isArray(rawSlides)) return []; - return rawSlides.filter(isRecord) as DeckSlide[]; -} - -function slideStageStyle( - theme: DeckTheme, - palette: ResolvedSlidePalette, -): CSSProperties { - const style: Record = { - backgroundColor: palette.background, - color: palette.text, - "--slide-bg": palette.background, - "--slide-fg": palette.text, - "--slide-heading": palette.heading, - "--slide-subtitle": palette.subtitle, - "--slide-accent": palette.accentBar, - "--slide-column-bg": palette.columnBg, - "--slide-column-border": palette.columnBorder, - }; - if (theme.fonts?.body !== undefined) { - style["fontFamily"] = theme.fonts.body; - } - return style as CSSProperties; -} - -function renderRichRun(run: unknown, key: number): ReactNode { - if (typeof run === "string") return run; - if (!isRecord(run) || typeof run["text"] !== "string") return null; - let node: ReactNode = run["text"]; - if (run["bold"] === true || Number(run["weight"]) >= 600) { - node = {node}; - } - if (run["italic"] === true) { - node = {node}; - } - if (run["underline"] === true) { - node = {node}; - } - if (run["strike"] === true) { - node = {node}; - } - return {node}; -} - -function renderRichText(value: unknown): ReactNode { - if (typeof value === "string") return value; - if (Array.isArray(value)) { - return value.map((run, index) => renderRichRun(run, index)); - } - return null; -} - -function renderNestedList(nestedItems: unknown[], ordered: boolean): ReactNode { - if (nestedItems.length === 0) return null; - if (ordered) { - return
    {renderListItems(nestedItems)}
; - } - return
    {renderListItems(nestedItems)}
; -} - -function renderListItems(items: unknown[]) { - return items.map((item, index) => { - if (isRecord(item) && "text" in item) { - const nestedOrdered = item["ordered"] === true; - return ( -
  • - {renderRichText(item["text"])} - {Array.isArray(item["items"]) - ? renderNestedList(item["items"], nestedOrdered) - : null} -
  • - ); - } - return
  • {renderRichText(item)}
  • ; - }); -} - -function SlideBodyBlockView({ block }: { block: SlideBodyBlock }) { - if (typeof block === "string") return

    {block}

    ; - const blockType = block.type ?? block.kind; - if (blockType === "paragraph") { - const rendered = renderRichText(block.text); - return rendered === null ? null :

    {rendered}

    ; - } - if (blockType === "list" && Array.isArray(block.items)) { - if (block.ordered === true) { - return
      {renderListItems(block.items)}
    ; - } - return
      {renderListItems(block.items)}
    ; - } - const fallback = renderRichText(block.text); - return fallback === null ? null :

    {fallback}

    ; -} - -function SlideBodyContent({ body }: { body: unknown }) { - if (typeof body === "string") { - return ( -
    -

    {body}

    -
    - ); - } - if (!Array.isArray(body) || body.length === 0) return null; - return ( -
    - {body.map((block, index) => ( - - ))} -
    - ); -} - -function SlideColumns({ - slide, - headingColor, -}: { - slide: DeckSlide; - headingColor: string; -}) { - if (slide.layout === "two-column") { - return ( -
    -
    - -
    -
    - -
    -
    - ); - } - if (slide.layout === "comparison") { - const leftSide = isRecord(slide.left) ? slide.left : undefined; - const rightSide = isRecord(slide.right) ? slide.right : undefined; - const leftHeading = - typeof leftSide?.["heading"] === "string" - ? leftSide["heading"] - : undefined; - const rightHeading = - typeof rightSide?.["heading"] === "string" - ? rightSide["heading"] - : undefined; - return ( -
    -
    - {leftHeading === undefined ? null : ( -

    - {leftHeading} -

    - )} - -
    -
    - {rightHeading === undefined ? null : ( -

    - {rightHeading} -

    - )} - -
    -
    - ); - } - return null; -} - -function collectFreeParagraphs(elements: unknown[]): ReactNode[] { - const out: ReactNode[] = []; - for (const [elIdx, el] of elements.entries()) { - if (!isRecord(el)) continue; - if (el["type"] === "group" && Array.isArray(el["children"])) { - out.push(...collectFreeParagraphs(el["children"])); - continue; - } - if (el["type"] === "text" && Array.isArray(el["paragraphs"])) { - for (const [pIdx, para] of el["paragraphs"].entries()) { - if (!isRecord(para) || !Array.isArray(para["runs"])) continue; - out.push( -

    - {renderRichText(para["runs"])} -

    , - ); - } - } + if ( + !(error instanceof DOMException && error.name === "InvalidCharacterError") + ) + throw error; + return null; } - return out; -} - -function SlideCanvas({ - slide, - index, - total, - theme, - pageFooter, -}: { - slide: DeckSlide; - index: number; - total: number; - theme: DeckTheme; - pageFooter?: string; -}) { - const palette = resolveSlidePalette(theme); - const layout = slide.layout ?? "content"; - const titleStyle: CSSProperties = { color: palette.heading }; - if (theme.fonts?.heading !== undefined) { - titleStyle.fontFamily = theme.fonts.heading; + const bytes = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index += 1) { + bytes[index] = binary.charCodeAt(index); } - const freeParagraphs = - layout === "free" && Array.isArray(slide.elements) - ? collectFreeParagraphs(slide.elements) - : []; - return ( -
    - - ); -} - -function SlideCard({ - slide, - index, - total, - theme, -}: { - slide: DeckSlide; - index: number; - total: number; - theme: DeckTheme; -}) { - return ( -
    -
    - - {text.slideCount(index + 1, total)} - - {typeof slide.layout === "string" ? ( - {slide.layout} - ) : null} -
    - -
    - ); + return new File([bytes], `${stem}.pdf`, { type: "application/pdf" }); } export function SlidesView({ deck }: { deck: Record }) { - const theme = readDeckTheme(deck); - const slides = readDeckSlides(deck); - if (slides.length === 0) { - return

    {text.noSlides}

    ; - } return ( -
    - {slides.map((slide, index) => ( - - ))} -
    + + + ); } @@ -566,20 +98,24 @@ function PdfView({ stem: string; }) { const [blobUrl, setBlobUrl] = useState(null); + const file = useMemo( + () => approvalPackagePdfFile(stem, bundle), + [stem, bundle], + ); useEffect(() => { if ( + file === null || typeof URL === "undefined" || typeof URL.createObjectURL !== "function" ) { return; } - const file = approvalPackagePdfFile(stem, bundle); const objectUrl = URL.createObjectURL(file); setBlobUrl(objectUrl); return () => { URL.revokeObjectURL(objectUrl); }; - }, [stem, bundle]); + }, [file]); const dataUrlMatch = /^data:[^;,]*;base64,(.*)$/s.exec(bundle.pdfBase64); const fallbackHref = @@ -588,9 +124,6 @@ function PdfView({ : bundle.pdfBase64; const pdfHref = blobUrl ?? fallbackHref; - const theme = readDeckTheme(bundle.deck); - const slides = readDeckSlides(bundle.deck); - return (
    @@ -600,43 +133,33 @@ function PdfView({ {stem}.pdf
    -
    - {blobUrl === null ? null : ( + {file === null ? ( +

    {text.pdfUnreadable}

    + ) : ( + +
    + )}
    - {slides.length === 0 ? ( -

    {text.noSlides}

    - ) : ( -
    - {slides.map((slide, index) => ( -
    - -
    - ))} -
    - )} + + +
    ); } @@ -767,7 +290,7 @@ export function ApprovalPackagePreview({ }) { const [activeTab, setActiveTab] = useState(initialTab); - const bundle = parseApprovalPackage(content); + const bundle = useMemo(() => parseApprovalPackage(content), [content]); if (bundle === null) { return

    {text.unreadable}

    ; } diff --git a/apps/web/src/pages/workspace/approval-package-slides.tsx b/apps/web/src/pages/workspace/approval-package-slides.tsx new file mode 100644 index 00000000..259d2c7a --- /dev/null +++ b/apps/web/src/pages/workspace/approval-package-slides.tsx @@ -0,0 +1,443 @@ +import { Badge } from "@corbits/react-ui"; +import { + freeElements, + validateDeck, + type Block, + type Deck, + type FreeElement, + type FreeRun, + type ListItem, + type RichText, + type Slide, + type Theme, +} from "@corbits/slidedeck/deck"; +import { useMemo, type CSSProperties, type ReactNode } from "react"; +import { wording } from "../../config.ts"; + +const text = wording.approvalPackage; + +interface ResolvedSlidePalette { + isDark: boolean; + background: string; + text: string; + heading: string; + subtitle: string; + accentBar: string; + columnHeading: string; + columnBg: string; + columnBorder: string; +} + +function normalizeHex(color: string): string { + return color.startsWith("#") ? color : `#${color}`; +} + +function channelLuminance(channel: number): number { + const srgb = channel / 255; + return srgb <= 0.03928 ? srgb / 12.92 : ((srgb + 0.055) / 1.055) ** 2.4; +} + +function relativeLuminance(hex: string): number { + const raw = hex.slice(1); + return ( + 0.2126 * channelLuminance(Number.parseInt(raw.slice(0, 2), 16)) + + 0.7152 * channelLuminance(Number.parseInt(raw.slice(2, 4), 16)) + + 0.0722 * channelLuminance(Number.parseInt(raw.slice(4, 6), 16)) + ); +} + +function pickLightOnDark( + color: string, + minLum: number, + fallback: string, +): string { + return relativeLuminance(color) >= minLum ? color : fallback; +} + +function pickDarkOnLight( + color: string, + maxLum: number, + fallback: string, +): string { + return relativeLuminance(color) <= maxLum ? color : fallback; +} + +// Dark-background decks can still declare dark brand tokens for primary/secondary; +// clamping by relative luminance keeps headings and subtitles legible on the stage. +function resolveSlidePalette(theme: Theme): ResolvedSlidePalette { + const background = normalizeHex(theme.colors.background); + const primary = normalizeHex(theme.colors.primary); + const secondary = normalizeHex(theme.colors.secondary); + const accent = normalizeHex(theme.colors.accent); + const rawText = normalizeHex(theme.colors.text); + + if (relativeLuminance(background) < 0.25) { + const brightPrimary = pickLightOnDark(primary, 0.35, "#38bdf8"); + return { + isDark: true, + background, + text: pickLightOnDark(rawText, 0.55, "#f8fafc"), + heading: pickLightOnDark(primary, 0.55, "#f8fafc"), + subtitle: pickLightOnDark(secondary, 0.5, "#cbd5e1"), + accentBar: pickLightOnDark(accent, 0.25, brightPrimary), + columnHeading: pickLightOnDark(accent, 0.35, brightPrimary), + columnBg: "rgba(255, 255, 255, 0.055)", + columnBorder: "rgba(255, 255, 255, 0.14)", + }; + } + + return { + isDark: false, + background, + text: pickDarkOnLight(rawText, 0.35, "#0f172a"), + heading: pickDarkOnLight(primary, 0.45, "#0f172a"), + subtitle: pickDarkOnLight(secondary, 0.45, "#475569"), + accentBar: primary, + columnHeading: pickDarkOnLight(primary, 0.45, "#0284c7"), + columnBg: "rgba(15, 23, 42, 0.035)", + columnBorder: "rgba(15, 23, 42, 0.1)", + }; +} + +function readDeck(value: unknown): Deck | null { + const validated = validateDeck(value); + return validated.ok ? validated.deck : null; +} + +function slideStageStyle( + theme: Theme, + palette: ResolvedSlidePalette, +): CSSProperties { + const style: Record = { + backgroundColor: palette.background, + color: palette.text, + fontFamily: theme.fonts.body, + "--slide-bg": palette.background, + "--slide-fg": palette.text, + "--slide-heading": palette.heading, + "--slide-subtitle": palette.subtitle, + "--slide-accent": palette.accentBar, + "--slide-column-bg": palette.columnBg, + "--slide-column-border": palette.columnBorder, + }; + return style as CSSProperties; +} + +interface Emphasis { + bold: boolean; + italic: boolean; + underline: boolean; + strike: boolean; +} + +function renderEmphasis(text: string, emphasis: Emphasis, key: number) { + let node: ReactNode = text; + if (emphasis.bold) node = {node}; + if (emphasis.italic) node = {node}; + if (emphasis.underline) node = {node}; + if (emphasis.strike) node = {node}; + return {node}; +} + +function renderRichText(value: RichText): ReactNode { + if (typeof value === "string") return value; + return value.map((run, index) => + typeof run === "string" + ? run + : renderEmphasis( + run.text, + { + bold: run.bold === true, + italic: run.italic === true, + underline: run.underline === true, + strike: run.strike === true, + }, + index, + ), + ); +} + +function renderFreeRuns(runs: readonly (string | FreeRun)[]): ReactNode { + return runs.map((run, index) => + typeof run === "string" + ? run + : renderEmphasis( + run.text, + { + bold: run.weight !== undefined && run.weight >= 600, + italic: run.italic === true, + underline: run.underline === true, + strike: run.strike === true, + }, + index, + ), + ); +} + +function renderList(items: readonly ListItem[], ordered: boolean): ReactNode { + const rendered = items.map((item, index) => { + if (typeof item === "string" || Array.isArray(item)) { + return
  • {renderRichText(item)}
  • ; + } + return ( +
  • + {renderRichText(item.text)} + {renderList(item.items, item.ordered ?? ordered)} +
  • + ); + }); + return ordered ?
      {rendered}
    :
      {rendered}
    ; +} + +function SlideBlock({ block }: { block: Block }) { + switch (block.type) { + case "paragraph": + return

    {renderRichText(block.text)}

    ; + case "list": + return renderList(block.items, block.ordered); + default: + return null; + } +} + +function SlideBlocks({ blocks }: { blocks: readonly Block[] }) { + if (blocks.length === 0) return null; + return ( +
    + {blocks.map((block, index) => ( + + ))} +
    + ); +} + +function SlideColumn({ + heading, + blocks, + headingColor, +}: { + heading?: string; + blocks: readonly Block[]; + headingColor: string; +}) { + return ( +
    + {heading === undefined ? null : ( +

    + {heading} +

    + )} + +
    + ); +} + +function FreeText({ elements }: { elements: readonly FreeElement[] }) { + const paragraphs: ReactNode[] = []; + for (const [element, path] of freeElements(elements, [])) { + if (element.type !== "text") continue; + for (const [index, paragraph] of element.paragraphs.entries()) { + paragraphs.push( +

    + {renderFreeRuns(paragraph.runs)} +

    , + ); + } + } + if (paragraphs.length === 0) return null; + return
    {paragraphs}
    ; +} + +function SlideContent({ + slide, + headingColor, +}: { + slide: Slide; + headingColor: string; +}) { + switch (slide.layout) { + case "content": + case "blank": + return ; + case "two-column": + return ( +
    + + +
    + ); + case "comparison": + return ( +
    + + +
    + ); + case "free": + return ; + default: + return null; + } +} + +function SlideCanvas({ + slide, + index, + total, + theme, + pageFooter, +}: { + slide: Slide; + index: number; + total: number; + theme: Theme; + pageFooter?: string; +}) { + const palette = resolveSlidePalette(theme); + const title = "title" in slide ? slide.title : undefined; + const subtitle = "subtitle" in slide ? slide.subtitle : undefined; + const caption = slide.layout === "image" ? slide.caption : undefined; + return ( +
    + + ); +} + +function SlideCard({ + slide, + index, + total, + theme, +}: { + slide: Slide; + index: number; + total: number; + theme: Theme; +}) { + return ( +
    +
    + + {text.slideCount(index + 1, total)} + + {slide.layout} +
    + +
    + ); +} + +/** The package's slides as cards, or the unreadable line when the deck is not a valid slide deck. */ +export function DeckSlides({ deck }: { deck: Record }) { + const readable = useMemo(() => readDeck(deck), [deck]); + if (readable === null) { + return

    {text.unreadable}

    ; + } + return ( +
    + {readable.slides.map((slide, index) => ( + + ))} +
    + ); +} + +/** The package's slides as print sheets, or the unreadable line when the deck is not a valid slide deck. */ +export function DeckPages({ deck }: { deck: Record }) { + const readable = useMemo(() => readDeck(deck), [deck]); + if (readable === null) { + return

    {text.unreadable}

    ; + } + return ( +
    + {readable.slides.map((slide, index) => ( +
    + +
    + ))} +
    + ); +} diff --git a/apps/web/src/pages/workspace/approval-rounds.test.tsx b/apps/web/src/pages/workspace/approval-rounds.test.tsx index a1b30aff..7367506d 100644 --- a/apps/web/src/pages/workspace/approval-rounds.test.tsx +++ b/apps/web/src/pages/workspace/approval-rounds.test.tsx @@ -1,6 +1,7 @@ -import { describe, expect, mock, test } from "bun:test"; +import { beforeAll, describe, expect, mock, test } from "bun:test"; import React, { isValidElement, type ReactNode } from "react"; import { renderToStaticMarkup } from "react-dom/server"; +import { prerender } from "react-dom/static"; import { ThemeProvider } from "@corbits/react-ui"; import { QueryClientProvider } from "@tanstack/react-query"; import type { ApprovalPackageBundle } from "@corbits-builder/schemas/artifact"; @@ -212,6 +213,7 @@ mock.module("../../api/artifacts.ts", () => ({ const { ApprovalRoundsPanel, SharePackageDialog } = await import("./approval-rounds.tsx"); +const { SlidesView } = await import("./approval-package-preview.tsx"); const openRound: ApprovalRound = { roundId: "rnd_1", @@ -274,6 +276,17 @@ const sampleBundle: ApprovalPackageBundle = { ], }, deck: { + theme: { + colors: { + primary: "#1e293b", + secondary: "#334155", + accent: "#2563eb", + background: "#ffffff", + text: "#0f172a", + muted: "#64748b", + }, + fonts: { heading: "Inter", body: "Inter" }, + }, slides: [ { layout: "title", @@ -606,6 +619,11 @@ function createStatefulHarness(renderRoot: () => ReactNode) { } describe("approval rounds panel", () => { + // Loads the lazily split slides once, as a browser would, so synchronous renders draw them. + beforeAll(async () => { + await prerender(); + }); + test("renders open round with reviewer states, Add new reviewer button, per-person Download package buttons, inline feedback triage with Incorporate and Dismiss (no Reply), and disabled settle button while a reviewer is pending", () => { const html = renderToStaticMarkup( { decisionNeeded: "Approve Q3 executive budget", }, deck: { + ...sampleBundle.deck, slides: [ { layout: "title", @@ -687,6 +706,7 @@ describe("approval rounds panel", () => { decisionNeeded: "Approve technical architecture", }, deck: { + ...sampleBundle.deck, slides: [ { layout: "title", diff --git a/apps/web/src/pages/workspace/approval-rounds.tsx b/apps/web/src/pages/workspace/approval-rounds.tsx index ee0376ae..f6e987ae 100644 --- a/apps/web/src/pages/workspace/approval-rounds.tsx +++ b/apps/web/src/pages/workspace/approval-rounds.tsx @@ -891,7 +891,7 @@ function PaneSlideDeckView({
    {bundle === null ? ( -

    {wording.approvalPackage.noSlides}

    +

    {wording.approvalPackage.unreadable}

    ) : ( )} @@ -902,7 +902,9 @@ function PaneSlideDeckView({
    {bundle === null ? ( -

    {wording.approvalPackage.noSlides}

    +

    + {wording.approvalPackage.unreadable} +

    ) : ( )} diff --git a/apps/web/src/pages/workspace/artifact-pane.test.tsx b/apps/web/src/pages/workspace/artifact-pane.test.tsx index 1b25798a..10c01f23 100644 --- a/apps/web/src/pages/workspace/artifact-pane.test.tsx +++ b/apps/web/src/pages/workspace/artifact-pane.test.tsx @@ -1,5 +1,6 @@ -import { describe, expect, test } from "bun:test"; +import { beforeAll, describe, expect, test } from "bun:test"; import { renderToStaticMarkup } from "react-dom/server"; +import { prerender } from "react-dom/static"; import type { ProjectArtifact } from "@corbits-builder/client"; import { ArtifactPane, @@ -8,7 +9,10 @@ import { defaultTab, resolveSelection, } from "./artifact-pane.tsx"; -import { APPROVAL_PACKAGE_MEDIA_TYPE } from "./approval-package-preview.tsx"; +import { + APPROVAL_PACKAGE_MEDIA_TYPE, + SlidesView, +} from "./approval-package-preview.tsx"; import type { DeliverableState } from "./deliverable-block.tsx"; import { writeWorkspaceApprovalsEnabled } from "../../workspace-approvals-setting.ts"; @@ -34,6 +38,7 @@ const document: DeliverableState = { content: "Previous content", filedAt: "2026-10-30T11:00:00.000Z", }, + versions: [], }, }; function render( @@ -55,6 +60,11 @@ function render( } describe("persistent artifact pane", () => { + // Loads the lazily split slides once, as a browser would, so synchronous renders draw them. + beforeAll(async () => { + await prerender(); + }); + test("exists before artifacts without modal or Expand controls", () => { const html = render(null); expect(html).toContain('aria-label="Artifacts"'); @@ -160,6 +170,17 @@ describe("persistent artifact pane", () => { ], }, deck: { + theme: { + colors: { + primary: "#1e293b", + secondary: "#334155", + accent: "#2563eb", + background: "#ffffff", + text: "#0f172a", + muted: "#64748b", + }, + fonts: { heading: "Inter", body: "Inter" }, + }, slides: [ { layout: "title", diff --git a/apps/web/src/pages/workspace/collaboration.ts b/apps/web/src/pages/workspace/collaboration.ts index 178ccde3..35588672 100644 --- a/apps/web/src/pages/workspace/collaboration.ts +++ b/apps/web/src/pages/workspace/collaboration.ts @@ -132,14 +132,14 @@ export function shortDocumentName(kind: string, title: string): string { */ export function approvalQuestion( toolName: string | undefined, - document: string, + document: string | undefined, subject?: string, ): string { const asked = toolName === undefined ? undefined : wording.approval.question[toolName]; if (asked !== undefined) return asked(subject); - if (document !== "" || toolName === undefined) - return wording.approval.document(document); + if (document !== undefined) return wording.approval.document(document); + if (toolName === undefined) return wording.approval.unnamed; const tool = toolName .slice(toolName.lastIndexOf(".") + 1) .replaceAll("_", " "); diff --git a/apps/web/src/pages/workspace/conversation-parts.ts b/apps/web/src/pages/workspace/conversation-parts.ts index 46b9537f..1d89e524 100644 --- a/apps/web/src/pages/workspace/conversation-parts.ts +++ b/apps/web/src/pages/workspace/conversation-parts.ts @@ -14,6 +14,7 @@ import type { } from "@corbits/react-ui"; import { applyRunStreamEvent, + partialOf, type RunStreamEvent, } from "../../api/run-stream.ts"; @@ -124,7 +125,7 @@ export function foldLiveTurn( if (event.type === "inference.start" || event.type === "inference.error") { return { turn: { text: folded.live, parts: [] }, refresh: folded.refresh }; } - const partial = event.data?.partial; + const partial = partialOf(event); const thinking = typeof partial?.thinking === "string" ? partial.thinking diff --git a/apps/web/src/pages/workspace/deliverable-block.tsx b/apps/web/src/pages/workspace/deliverable-block.tsx index d23a0138..a0c02515 100644 --- a/apps/web/src/pages/workspace/deliverable-block.tsx +++ b/apps/web/src/pages/workspace/deliverable-block.tsx @@ -56,7 +56,7 @@ export type LoadedDeliverable = ReadVersion & { sha256: string; before: (ReadVersion & { version: number }) | null; historyError?: string; - versions?: readonly RetainedVersion[]; + versions: readonly RetainedVersion[]; }; export type DeliverableState = @@ -238,7 +238,7 @@ function ReadyDocument({ const compareWith = changesOpen ? (baseline ?? predecessor) : null; const afterText = textFor(viewing); const beforeText = compareWith === null ? null : textFor(compareWith); - const viewed = document.versions?.find((item) => item.version === viewing); + const viewed = document.versions.find((item) => item.version === viewing); const mediaType = viewed?.mediaType ?? document.mediaType; const title = viewed?.title ?? document.title; const kind = mediaKind(mediaType, title); @@ -262,6 +262,20 @@ function ReadyDocument({ return loaded.content; }; + // Reads a version the room has not loaded, then acts on it; a failed read is the block's error line. + const withVersion = async (version: number, use: () => void) => { + setPendingVersion(version); + setReadError(null); + try { + await fetchVersion(version); + use(); + } catch (cause) { + setReadError(cause instanceof Error ? cause.message : String(cause)); + } finally { + setPendingVersion(null); + } + }; + const selectVersion = (version: number) => { if (version === viewing) return; onViewVersion?.(); @@ -270,13 +284,7 @@ function ReadyDocument({ setViewing(version); return; } - setPendingVersion(version); - void fetchVersion(version) - .then(() => setViewing(version)) - .catch((cause: unknown) => { - setReadError(cause instanceof Error ? cause.message : String(cause)); - }) - .finally(() => setPendingVersion(null)); + void withVersion(version, () => setViewing(version)); }; const appliedRequestSeq = useRef(null); @@ -292,17 +300,10 @@ function ReadyDocument({ const comparePrevious = () => { if (predecessor === null) return; - setPendingVersion(predecessor); - setReadError(null); - void fetchVersion(predecessor) - .then(() => { - setBaseline(predecessor); - if (!changesOpen) onToggleChanges(); - }) - .catch((cause: unknown) => { - setReadError(cause instanceof Error ? cause.message : String(cause)); - }) - .finally(() => setPendingVersion(null)); + void withVersion(predecessor, () => { + setBaseline(predecessor); + if (!changesOpen) onToggleChanges(); + }); }; const stopComparing = () => { @@ -381,7 +382,7 @@ function ReadyDocument({ const barGroups = [ approvalGroup, versionGroup({ - versions: document.versions ?? [], + versions: document.versions, viewing, pending: pendingVersion !== null, canCompare: @@ -526,7 +527,7 @@ function ReadyDocument({ item.version)} + packageVersions={document.versions.map((item) => item.version)} onReadPackageVersion={fetchVersion} shareOpen={shareOpen} onShareOpenChange={setShareOpen} diff --git a/apps/web/src/pages/workspace/design-preview.tsx b/apps/web/src/pages/workspace/design-preview.tsx index 28c18b89..9567444e 100644 --- a/apps/web/src/pages/workspace/design-preview.tsx +++ b/apps/web/src/pages/workspace/design-preview.tsx @@ -16,6 +16,8 @@ import { } from "react"; import { useQuery } from "@tanstack/react-query"; import { wording } from "../../config.ts"; +import { DesignBundle } from "@corbits-builder/schemas/artifact"; +import { readJson } from "../../json.ts"; import { Markdown } from "../../markdown.jsx"; import { keys } from "../../queries/keys.ts"; import { readProjectActivity } from "../../api/activity.ts"; @@ -87,14 +89,7 @@ const stateHash = (screen: string, state: string | null) => state === null ? `#/${screen}` : `#/${screen}/${state}`; export function parseBundle(content: string): Record | null { - try { - const parsed = JSON.parse(content) as { files?: unknown }; - return typeof parsed.files === "object" && parsed.files !== null - ? (parsed.files as Record) - : null; - } catch { - return null; - } + return readJson(content, DesignBundle)?.files ?? null; } const SCREEN_PREFIX = "designs/screens/"; diff --git a/apps/web/src/pages/workspace/dispatch-card.tsx b/apps/web/src/pages/workspace/dispatch-card.tsx index 09118dca..dd4b684c 100644 --- a/apps/web/src/pages/workspace/dispatch-card.tsx +++ b/apps/web/src/pages/workspace/dispatch-card.tsx @@ -64,7 +64,8 @@ function parsed(content: unknown): unknown { if (typeof content !== "string") return content; try { return JSON.parse(content) as unknown; - } catch { + } catch (cause) { + if (!(cause instanceof SyntaxError)) throw cause; return content; } } @@ -77,9 +78,10 @@ export function dispatchesOf( const role = ROLE_OF[part.name]; if (role === undefined) return []; const pending = part.status !== "output-available"; - const args = record(part.input) ?? {}; - const instruction = str(args["instruction"]); - const kind = str(args["kind"]); + // A call still streaming its arguments has none to read yet. + const args = record(part.input); + const instruction = str(args?.["instruction"]); + const kind = str(args?.["kind"]); const base = { call: part.toolCallId, tool: part.name, @@ -103,8 +105,9 @@ export function dispatchesOf( refused: str(result.content) ?? text.refusedUnknown, }, ]; - const concepts = Array.isArray(args["concepts"]) - ? args["concepts"].flatMap((item) => { + const named = args?.["concepts"]; + const concepts = Array.isArray(named) + ? named.flatMap((item) => { const label = str(record(item)?.["label"]); return label === undefined ? [] : [label]; }) @@ -168,8 +171,8 @@ export type DispatchRoom = { >; /** The native approvals, pending or as their records settled them. */ readonly approvals: readonly DocumentApproval[]; - /** The runs mid-turn, with the tool calls and words their own inference events carried. */ - readonly activity: readonly AgentActivity[]; + /** The runs mid-turn, with the tool calls and words their own inference events carried; undefined until read. */ + readonly activity: readonly AgentActivity[] | undefined; readonly onOpenDocument?: (kind: string) => void; }; diff --git a/apps/web/src/pages/workspace/document-decision.tsx b/apps/web/src/pages/workspace/document-decision.tsx index 08c5e603..8c3925e5 100644 --- a/apps/web/src/pages/workspace/document-decision.tsx +++ b/apps/web/src/pages/workspace/document-decision.tsx @@ -42,9 +42,10 @@ const FOLDED_INSTRUCTION_ASKS = new Set(["design_app"]); function shortName(approval: DocumentApproval): string | undefined { const { document } = approval; - return document === undefined - ? undefined - : shortDocumentName(document.kind, document.title); + if (document === undefined) return undefined; + const name = shortDocumentName(document.kind, document.title); + // A blank title has no name to ask about, so the card falls back to the tool's wording. + return name === "" ? undefined : name; } // The documents a request_approval ask approves together, when it covers more than one. @@ -62,7 +63,7 @@ function decisionTitle(approval: DocumentApproval, shown: boolean): string { ); const question = approvalQuestion( approval.toolName, - shortName(approval) ?? "", + shortName(approval), approval.subject, ); return shown ? `${question} ${text.openOnRight}` : question; @@ -136,12 +137,11 @@ function settledLine(approval: DocumentApproval): string { .map(({ title, version }) => `${title} v${String(version)}`) .join(", "), ); - const settled = - approval.toolName === undefined - ? undefined - : text.settled[approval.toolName]; + if (approval.toolName === undefined) + return text.approved(text.settledUnnamed); + const settled = text.settled[approval.toolName]; return text.approved( - settled ?? approvalQuestion(approval.toolName, "", approval.subject), + settled ?? approvalQuestion(approval.toolName, undefined, approval.subject), ); } diff --git a/apps/web/src/pages/workspace/empty-project.test.tsx b/apps/web/src/pages/workspace/empty-project.test.tsx index 4d64a05e..31a21cd7 100644 --- a/apps/web/src/pages/workspace/empty-project.test.tsx +++ b/apps/web/src/pages/workspace/empty-project.test.tsx @@ -26,6 +26,7 @@ function ready(title: string, content: string): DeliverableState { version: 1, sha256: sha, before: null, + versions: [], }, }; } diff --git a/apps/web/src/pages/workspace/filed-versions.tsx b/apps/web/src/pages/workspace/filed-versions.tsx index 1208a9fe..4fe141a9 100644 --- a/apps/web/src/pages/workspace/filed-versions.tsx +++ b/apps/web/src/pages/workspace/filed-versions.tsx @@ -13,11 +13,10 @@ export type FiledVersion = { export function useFiledVersions(input: { readonly projectId: string; - /** The room has its first read; versions before it are history, not news. */ - readonly ready: boolean; + /** From the room's first read on; versions in that read are history, not news. */ readonly documents: readonly FiledVersion[]; }): readonly FiledVersion[] { - const { projectId, ready, documents } = input; + const { projectId, documents } = input; const seen = useRef | null>(null); const [filed, setFiled] = useState([]); @@ -27,7 +26,6 @@ export function useFiledVersions(input: { }, [projectId]); useEffect(() => { - if (!ready) return; const known = seen.current; seen.current = new Map(documents.map((item) => [item.kind, item.version])); if (known === null) return; @@ -35,7 +33,7 @@ export function useFiledVersions(input: { .filter((item) => item.version > (known.get(item.kind) ?? 0)) .map(({ kind, title, version }) => ({ kind, title, version })); if (fresh.length > 0) setFiled((current) => [...current, ...fresh]); - }, [ready, documents]); + }, [documents]); return filed; } diff --git a/apps/web/src/pages/workspace/human-fault.test.ts b/apps/web/src/pages/workspace/human-fault.test.ts new file mode 100644 index 00000000..a353b61c --- /dev/null +++ b/apps/web/src/pages/workspace/human-fault.test.ts @@ -0,0 +1,22 @@ +import { expect, test } from "bun:test"; +import { ProductApiError } from "@corbits-builder/client"; +import { humanFault, humanFaultWithCode, responseCode } from "./human-fault.ts"; + +test("a refusal whose body is not JSON has no code", async () => { + const page = new Response("Bad gateway", { status: 502 }); + + expect(await responseCode(new ProductApiError(page))).toBeUndefined(); +}); + +test("a refusal whose body cannot be read still gets the caller's words", async () => { + const dropped = new ReadableStream({ + start(controller) { + controller.error(new TypeError("connection reset")); + }, + }); + const refusal = new ProductApiError(new Response(dropped, { status: 409 })); + + expect(await humanFaultWithCode(refusal, "Not sent.")).toBe( + humanFault(refusal, "Not sent."), + ); +}); diff --git a/apps/web/src/pages/workspace/human-fault.ts b/apps/web/src/pages/workspace/human-fault.ts index 14e63df4..fca94bc1 100644 --- a/apps/web/src/pages/workspace/human-fault.ts +++ b/apps/web/src/pages/workspace/human-fault.ts @@ -5,6 +5,7 @@ * sentence, or the caller's own plain words when those words are already human. */ import { ProductApiError } from "@corbits-builder/client"; +import { refusalBody } from "../../api/transport.ts"; const STATUS_LINE = /^(?:bad request|unauthorized|forbidden|not found|method not allowed|not acceptable|conflict|gone|unprocessable content|too many requests|internal server error|not implemented|bad gateway|service unavailable|gateway timeout)$/i; @@ -62,10 +63,7 @@ export async function responseCode( cause: unknown, ): Promise { if (!(cause instanceof ProductApiError)) return undefined; - const body: unknown = await cause.response - .clone() - .json() - .catch(() => undefined); + const body = await refusalBody(cause); return body !== null && typeof body === "object" && "code" in body && diff --git a/apps/web/src/pages/workspace/index.tsx b/apps/web/src/pages/workspace/index.tsx index c0dcc804..05210787 100644 --- a/apps/web/src/pages/workspace/index.tsx +++ b/apps/web/src/pages/workspace/index.tsx @@ -12,7 +12,13 @@ */ import { wording } from "../../config.ts"; import { useEffect, useMemo, useRef, useState, type ReactNode } from "react"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { + queryOptions, + skipToken, + usePrefetchQuery, + useQuery, + useQueryClient, +} from "@tanstack/react-query"; import { Paperclip, Sparkles } from "lucide-react"; import { Attachment, @@ -36,6 +42,8 @@ import { } from "@corbits/react-ui"; import { heldOnApproval, + projectActivityQuery, + projectRunStatesQuery, useProjectActivity, useProjectRunStates, useRolesAtWork, @@ -57,6 +65,7 @@ import type { InFlightRun } from "@corbits-builder/schemas/artifact"; import { toast } from "sonner"; import { sendMessage } from "../../api/conversation.ts"; import { subscribeWorkflowRun } from "../../api/run-stream.ts"; +import { refusalBody } from "../../api/transport.ts"; import { acceptedMaterials, attachMaterials, @@ -122,6 +131,7 @@ import { reloadProjectDocument, roomDocumentKey, roomSnapshotReady, + type RoomSnapshot, } from "./load-room.ts"; import { RoomFault, RoomSkeleton } from "./room-skeleton.tsx"; import { designDraftFiled, useDesignDraft } from "./design-preview.tsx"; @@ -136,7 +146,11 @@ import { } from "./design-concepts.tsx"; import { useLiveAppPresence } from "./live-app.tsx"; import { useQuestionsLayout } from "../../questions-layout-setting.ts"; -import { NextStepLine, useProjectNextStep } from "./next-step.tsx"; +import { + NextStepLine, + projectNextStepQuery, + useProjectNextStep, +} from "./next-step.tsx"; import { composerPlaceholder } from "./composer-placeholder.ts"; import { Dictated } from "../../dictation.jsx"; import { useShellReply } from "../../flourish.tsx"; @@ -153,7 +167,26 @@ const BIND_POLL_MS = 1_000; const BIND_WAIT_MS = 15_000; // After a run closes, the mirror's commit can trail its last frame. const SETTLE_RELOAD_MS = [1_000, 3_000, 7_000, 15_000]; -const NO_WAITS: readonly Wait[] = []; + +function acceptedMaterialsQuery(projectId: string) { + return queryOptions({ + queryKey: keys.acceptedMaterials.of(projectId), + retry: false, + refetchOnWindowFocus: false, + queryFn: () => acceptedMaterials(projectId), + }); +} + +// Only an active project has a coordinator whose decisions can be open. +function openDecisionsQuery(projectId: string, active: boolean) { + return queryOptions({ + queryKey: keys.openDecisions.of(projectId), + retry: false, + staleTime: 0, + refetchOnWindowFocus: false, + queryFn: active ? () => openDecisions(projectId) : skipToken, + }); +} function documentApprovalsFrom( waits: readonly Wait[], @@ -260,10 +293,7 @@ async function modelRefusal( ): Promise<{ code: string; message: string } | undefined> { if (!(cause instanceof ProductApiError) || cause.response.status !== 409) return undefined; - const body: unknown = await cause.response - .clone() - .json() - .catch(() => undefined); + const body = await refusalBody(cause); return body !== null && typeof body === "object" && "code" in body && @@ -275,14 +305,9 @@ async function modelRefusal( : undefined; } -export function StageWorkspace({ - projectId, - projectTitle, - onChanged, - onOpenSettings, - openingMessage, - listedArchived, -}: { +type ModelNeeded = { code: string; message: string }; + +type StageWorkspaceProps = { /** The open project's id. */ projectId: string; projectTitle: string; @@ -294,11 +319,14 @@ export function StageWorkspace({ * artifact but the first conversation message, sent here once the * coordinator binds. */ openingMessage?: string; -}) { - // The conversation record comes from the room query. Null events means no - // coordinator is bound yet (the hub's 409). Empty arrays are not a stand-in - // for a read that has not happened. - const queryClient = useQueryClient(); +}; + +/** + * Reads the project and its room, and starts its coordinator, while the room shows its loading + * or fault state; the ready room mounts only with a loaded snapshot. + */ +export function StageWorkspace(props: StageWorkspaceProps) { + const { projectId, listedArchived } = props; // An archived project has no coordinator or conversation to read, so the room waits on its lifecycle. const projectRead = useQuery({ queryKey: keys.project.of(projectId), @@ -329,17 +357,117 @@ export function StageWorkspace({ refetchOnWindowFocus: false, queryFn: ({ client }) => loadProjectRoom(projectId, client, !archived), }); - const snapshot = status === undefined ? undefined : room.data; - const events = snapshot?.conversation.events ?? null; - const mirror = snapshot?.conversation.turns ?? []; - const runId = archived ? null : (snapshot?.conversation.runId ?? null); - // Each project is its own tenant beneath the workspace, and its runs live there. - const runTenantId = project?.tenantId; + const [startFault, setStartFault] = useState(null); + // Why the coordinator has no model to run on, which only the person can settle. + const [modelNeeded, setModelNeeded] = useState(null); + // Only a missing provider is settled by connecting one; a profile choice needs Settings. + const providerMissing = modelNeeded?.code === "model_provider_missing"; + const startCoordinator = async () => { + setStartFault(null); + setModelNeeded(null); + try { + await startProjectCoordinator(projectId); + await room.refetch(); + } catch (cause) { + const needed = await modelRefusal(cause); + if (needed !== undefined) return setModelNeeded(needed); + setStartFault( + await humanFaultWithCode(cause, wording.message.coordinatorNotStarted), + ); + } + }; + // Opening an active project starts its coordinator when it is not running; a running one is + // left alone. A project archived while open drops the conversation it can no longer read. + useEffect(() => { + if (status === "active") void startCoordinator(); + else if (status === "archived" && room.data !== undefined) + void room.refetch(); + }, [projectId, status]); + const catalog = useModelCatalog(); + const providerConnected = + catalog.data !== undefined && !needsModelSetup(catalog.data.providers); + // However the provider gets connected (Enable AI, Settings, another tab, the server), the + // shared catalog read sees it and the refused start is tried again. Only on that change: a hub + // that still refuses would otherwise retry on every refusal. + useEffect(() => { + if (providerConnected && providerMissing) void startCoordinator(); + }, [providerConnected]); + // The room's other reads start with it, so the ready room mounts with them in hand. + usePrefetchQuery(projectActivityQuery(projectId)); + usePrefetchQuery(projectRunStatesQuery(projectId)); + usePrefetchQuery(projectNextStepQuery(projectId)); + usePrefetchQuery(acceptedMaterialsQuery(projectId)); + // A prefetch cannot wait on the project's status, so this read observes the decisions instead. + useQuery(openDecisionsQuery(projectId, status === "active")); + useLiveAppPresence(projectId); + + if (status === undefined) { + if (projectRead.isError) + return void projectRead.refetch()} />; + return ; + } + const snapshot = room.data; + if (!roomSnapshotReady(projectId, snapshot)) { + if (room.isError) return void room.refetch()} />; + return ; + } + return ( + + ); +} + +/** The room with its first read in hand: everything here works on a loaded snapshot. */ +function ReadyRoom({ + projectId, + projectTitle, + onChanged, + onOpenSettings, + openingMessage, + status, + runTenantId, + snapshot, + start, + refetchProject, +}: StageWorkspaceProps & { + status: string; + runTenantId: string | undefined; + /** Null events means no coordinator is bound yet (the hub's 409). */ + snapshot: RoomSnapshot; + start: { + fault: string | null; + modelNeeded: ModelNeeded | null; + providerMissing: boolean; + retry: () => Promise; + }; + refetchProject: () => Promise; +}) { + const queryClient = useQueryClient(); + const archived = status === "archived"; + const startFault = start.fault; + const modelNeeded = start.modelNeeded; + const startCoordinator = start.retry; + const providerMissing = start.providerMissing; + const events = snapshot.conversation.events; + const mirror = snapshot.conversation.turns; + const runId = archived ? null : snapshot.conversation.runId; // The coder's filed app source is the resume point for a later coder, not a document to read. - const artifacts = (snapshot?.artifacts ?? []) + const artifacts = snapshot.artifacts .filter((item) => item.kind !== "app") .map(paneArtifact); - const documents = snapshot?.documents ?? {}; + const documents = snapshot.documents; const [liveRun, setLiveRun] = useState(emptyLiveRun); const questionsLayout = useQuestionsLayout(); const [approvalBusy, setApprovalBusy] = useState(false); @@ -379,21 +507,8 @@ export function StageWorkspace({ const [sendFault, setSendFault] = useState(null); // When this session last sent the coordinator a message, until a run ends after it or it fails. const [awaitingSince, setAwaitingSince] = useState(null); - const [startFault, setStartFault] = useState(null); - // Why the coordinator has no model to run on, which only the person can settle. - const [modelNeeded, setModelNeeded] = useState<{ - code: string; - message: string; - } | null>(null); - // Only a missing provider is settled by connecting one; a profile choice needs Settings. - const providerMissing = modelNeeded?.code === "model_provider_missing"; const [enablingAi, setEnablingAi] = useState(false); - const accepted = useQuery({ - queryKey: keys.acceptedMaterials.of(projectId), - retry: false, - refetchOnWindowFocus: false, - queryFn: () => acceptedMaterials(projectId), - }); + const accepted = useQuery(acceptedMaterialsQuery(projectId)); const limits = accepted.data; const [files, setFiles] = useState([]); const [attachFault, setAttachFault] = useState(null); @@ -414,28 +529,31 @@ export function StageWorkspace({ const [caution, setCaution] = useState(null); const [gateDone, setGateDone] = useState(0); // The waits that belong to this project, from the open decisions. - const decisions = useQuery({ - queryKey: keys.openDecisions.of(projectId), - enabled: status === "active", - retry: false, - staleTime: 0, - refetchOnWindowFocus: false, - queryFn: () => openDecisions(projectId), - }); + const decisions = useQuery( + openDecisionsQuery(projectId, status === "active"), + ); + // Once per outage: each failed poll is a new error, and a dismissed toast stays dismissed. useEffect(() => { - if (decisions.error === null) return; + if (!decisions.isError) return; toast.error( humanFault(decisions.error, "Open decisions could not be read."), { id: "waits-read", }, ); - }, [decisions.error]); - const waits = decisions.data ?? NO_WAITS; + }, [decisions.isError]); + // Undefined until read: an unread list is not one with nothing open. + const waits = decisions.data; + const activity = useProjectActivity(projectId); + useEffect(() => { + if (!activity.isError) return; + toast.error(humanFault(activity.error, wording.activity.notRead), { + id: "activity-read", + }); + }, [activity.isError]); const reloadRef = useRef<() => void>(() => {}); useEffect(() => { - if (status === undefined) return; const reloader = createRoomReloader(() => loadProjectRoom(projectId, queryClient, status === "active"), ); @@ -568,14 +686,15 @@ export function StageWorkspace({ // The steps map only grows as the live run commits, which re-renders with a new mirror. [mirror, sent], ); - const mailThreads = snapshot?.conversation.threads; + const mailThreads = snapshot.conversation.threads; const specialistUpdates = useMemo( - () => threadUpdates(mailThreads ?? []), + () => threadUpdates(mailThreads), [mailThreads], ); const latestAssistant = [...mirror].reverse().find((turn) => turn.role === "assistant")?.text ?? ""; - const documentApprovals = documentApprovalsFrom(waits, artifacts); + const documentApprovals = + waits === undefined ? undefined : documentApprovalsFrom(waits, artifacts); const { settled: settledApprovals, settle } = useSettledApprovals( projectId, documentApprovals, @@ -589,16 +708,19 @@ export function StageWorkspace({ (item) => item.approvalId === approval.approvalId, ), ); - const pendingApprovals = pendingFrom(waits); + const pendingApprovals = waits === undefined ? undefined : pendingFrom(waits); + const shownApprovals = + pendingApprovals === undefined + ? settledApprovals + : [...settledApprovals, ...pendingApprovals]; const openApprovalIds = new Set( - pendingApprovals.map((approval) => approval.approvalId), + pendingApprovals?.map((approval) => approval.approvalId), ); + // Until the open decisions are read, no approval is known to hold the coordinator. + const parked = + waits !== undefined && parkedOnApproval(waits, openApprovalIds, runId); const liveTurn = pendingLiveTurn(liveRun, mirror); - const streaming = liveTurnStreaming( - liveRun, - mirror, - parkedOnApproval(waits, openApprovalIds, runId), - ); + const streaming = liveTurnStreaming(liveRun, mirror, parked); const liveText = liveTurn?.text ?? ""; const shownTurns = liveTurn === null @@ -620,8 +742,8 @@ export function StageWorkspace({ : []; // Mid-turn by native state, whoever sent the message: this stream saw the message run start and // not end, or the hub's activity read saw one start after the last end this stream saw. - const agents = useProjectActivity(projectId).data?.agents ?? []; - const coordinatorTurnSince = agents.find( + const agents = activity.data?.agents; + const coordinatorTurnSince = agents?.find( (agent) => agent.role === "coordinator", )?.since; const working = @@ -639,15 +761,17 @@ export function StageWorkspace({ return turn.role === "person" ? turnItemId(turn, index) : found; }, undefined); const [retryingKey, setRetryingKey] = useState(null); - const retryDocument = (item: ProjectArtifact) => { + const retryDocument = async (item: ProjectArtifact) => { const key = documentKey(projectId, item); if (documents[key]?.status !== "error") return; setRetryingKey(key); - void reloadProjectDocument(projectId, key, queryClient, !archived).finally( - () => { - setRetryingKey((current) => (current === key ? null : current)); - }, - ); + try { + await reloadProjectDocument(projectId, key, queryClient, !archived); + } catch (cause) { + toast.error(humanFault(cause, wording.documentRetryFailed)); + } finally { + setRetryingKey((current) => (current === key ? null : current)); + } }; const documentState = ( item: ProjectArtifact, @@ -788,17 +912,19 @@ export function StageWorkspace({ // A park created moments after `inference.done` can already stand on the hub before the next // room reload lands; read once on demand when the cached list shows no open ask. - const readPendingApprovals = async (): Promise => { - if (pendingApprovals.length > 0) return pendingApprovals; + const readPendingApprovals = async (): Promise< + readonly OpenApproval[] | undefined + > => { + if (pendingApprovals !== undefined && pendingApprovals.length > 0) + return pendingApprovals; try { - const fresh = await queryClient.fetchQuery({ - queryKey: keys.openDecisions.of(projectId), - staleTime: 0, - queryFn: () => openDecisions(projectId), - }); + const fresh = await queryClient.fetchQuery( + openDecisionsQuery(projectId, true), + ); return pendingFrom(fresh); - } catch (cause: unknown) { - void cause; + } catch (cause) { + // The cached list is still the best answer; the send that follows reports a hub that is down. + console.warn("Open decisions could not be re-read.", cause); return pendingApprovals; } }; @@ -841,13 +967,12 @@ export function StageWorkspace({ message?: string; }): Promise<"saved" | "settled" | { fault: string }> => { const notSaved = { fault: "That decision was not saved." }; - const cachedWaits = - queryClient.getQueryData( - keys.openDecisions.of(projectId), - ) ?? NO_WAITS; + const cachedWaits = queryClient.getQueryData( + keys.openDecisions.of(projectId), + ); const wait = - waits.find((item) => item.approvalId === decision.approvalId) ?? - cachedWaits.find((item) => item.approvalId === decision.approvalId); + waits?.find((item) => item.approvalId === decision.approvalId) ?? + cachedWaits?.find((item) => item.approvalId === decision.approvalId); const approvalId = wait?.approvalId; const tenantId = wait?.tenantId; if ( @@ -860,10 +985,12 @@ export function StageWorkspace({ setApprovalBusy(true); setApprovalError(null); const decided = - documentApprovals.find((item) => item.approvalId === approvalId) ?? - documentApprovalsFrom(cachedWaits, artifacts).find( - (item) => item.approvalId === approvalId, - ); + documentApprovals?.find((item) => item.approvalId === approvalId) ?? + (cachedWaits === undefined + ? undefined + : documentApprovalsFrom(cachedWaits, artifacts).find( + (item) => item.approvalId === approvalId, + )); try { await decideApproval({ tenantId, @@ -932,7 +1059,7 @@ export function StageWorkspace({ at: new Date().toISOString(), }; }; - const openApproval = pendingApprovals.find( + const openApproval = pendingApprovals?.find( (approval) => approval.packages === undefined, ); // Choosing an approach is the person's yes to the version's open ask; the message names the pick. @@ -941,14 +1068,14 @@ export function StageWorkspace({ version: number, ): Promise => { const currentApprovals = await readPendingApprovals(); - const currentOpen = currentApprovals.find( + const currentOpen = currentApprovals?.find( (approval) => approval.packages === undefined, ); const kind = selected?.kind; const asked = kind === undefined ? undefined - : currentApprovals.find( + : currentApprovals?.find( (approval) => approval.packages === undefined && asksAbout(approval, kind, version), @@ -966,7 +1093,7 @@ export function StageWorkspace({ const deliver = async (body: string, clearComposer = false) => { if (body.trim().length === 0 || sending) return false; const currentApprovals = await readPendingApprovals(); - const currentOpen = currentApprovals.find( + const currentOpen = currentApprovals?.find( (approval) => approval.packages === undefined, ); return deliverWithOpenApproval({ @@ -980,7 +1107,7 @@ export function StageWorkspace({ const sendFiles = async () => { if (sending) return false; const currentApprovals = await readPendingApprovals(); - const currentOpen = currentApprovals.find( + const currentOpen = currentApprovals?.find( (approval) => approval.packages === undefined, ); return deliverWithOpenApproval({ @@ -999,7 +1126,7 @@ export function StageWorkspace({ if (!(await deliver(message))) restoreToComposer(message); }; const typedYes = useTypedYes({ - pending: pendingApprovals.map((approval) => approval.approvalId), + pending: pendingApprovals?.map((approval) => approval.approvalId), onApprove: (approvalId) => void onDecideApproval({ approvalId, choice: "yes" }), onUndo: (message) => void undoTypedYes(message), @@ -1011,7 +1138,11 @@ export function StageWorkspace({ if (files.length > 0) return void sendFiles(); if (shell.answers(value)) return setValue(""); if (typedYes.hold(value)) return setValue(""); - const role = earlierDocumentRole(value, artifacts, rolesAtWork); + // Without a read of who is at work there is no one to warn about. + const role = + rolesAtWork === undefined + ? undefined + : earlierDocumentRole(value, artifacts, rolesAtWork); if (role === undefined || caution === role) return sendNow(); setCaution(role); }; @@ -1021,7 +1152,7 @@ export function StageWorkspace({ setMobilePane("conversation"); setChangesAsked(true); }; - const unbound = snapshot !== undefined && events === null; + const unbound = events === null; const composerDisabled = sending || approvalBusy || providerMissing || unbound; useEffect(() => { @@ -1034,27 +1165,6 @@ export function StageWorkspace({ // stays and reads as the coordinator at work, not as a project starting from nothing. const between = unbound && mirror.length > 0 && startFault === null && modelNeeded === null; - const startCoordinator = async () => { - setStartFault(null); - setModelNeeded(null); - try { - await startProjectCoordinator(projectId); - await room.refetch(); - } catch (cause) { - const needed = await modelRefusal(cause); - if (needed !== undefined) return setModelNeeded(needed); - setStartFault( - await humanFaultWithCode(cause, wording.message.coordinatorNotStarted), - ); - } - }; - // Opening an active project starts its coordinator when it is not running; a running one is - // left alone. A project archived while open drops the conversation it can no longer read. - useEffect(() => { - if (status === "active") void startCoordinator(); - else if (status === "archived" && room.data !== undefined) - void room.refetch(); - }, [projectId, status]); const awaitingBind = status === "active" && unbound && @@ -1069,15 +1179,6 @@ export function StageWorkspace({ clearTimeout(fallback); }; }, [awaitingBind]); - const catalog = useModelCatalog(); - const providerConnected = - catalog.data !== undefined && !needsModelSetup(catalog.data.providers); - // However the provider gets connected (Enable AI, Settings, another tab, the server), the - // shared catalog read sees it and the refused start is tried again. Only on that change: a hub - // that still refuses would otherwise retry on every refusal. - useEffect(() => { - if (providerConnected && providerMissing) void startCoordinator(); - }, [providerConnected]); const [restoring, setRestoring] = useState(false); const [restoreFault, setRestoreFault] = useState(null); const restore = async () => { @@ -1085,7 +1186,7 @@ export function StageWorkspace({ setRestoreFault(null); try { await restoreProject(projectId); - await projectRead.refetch(); + await refetchProject(); onChanged(); } catch (cause) { setRestoreFault( @@ -1095,14 +1196,11 @@ export function StageWorkspace({ setRestoring(false); } }; - const held = waitingOnPerson(waits, openApprovalIds, runId, liveRun.running); + const held = + waits !== undefined && + waitingOnPerson(waits, openApprovalIds, runId, liveRun.running); const awaiting = - awaitingReply( - awaitingSince, - turns.at(-1)?.role, - liveRun.endedAt, - parkedOnApproval(waits, openApprovalIds, runId), - ) && + awaitingReply(awaitingSince, turns.at(-1)?.role, liveRun.endedAt, parked) && !archived && startFault === null && modelNeeded === null; @@ -1120,16 +1218,16 @@ export function StageWorkspace({ projectId, designDraftFiled( artifacts.some((item) => item.kind === "design"), - snapshot?.inFlight, + snapshot.inFlight, ), ); - const inFlight = snapshot?.inFlight ?? []; + const inFlight = snapshot.inFlight; const dispatchRoom: DispatchRoom = { - artifacts: snapshot?.artifacts ?? [], + artifacts: snapshot.artifacts, inFlight, - failed: snapshot?.failed ?? [], + failed: snapshot.failed, seen: dispatchSeen.current, - approvals: [...settledApprovals, ...pendingApprovals], + approvals: shownApprovals, activity: agents, onOpenDocument: (kind) => openDocument( @@ -1144,16 +1242,19 @@ export function StageWorkspace({ for (const dispatch of dispatches) noteDispatch(dispatchRoom, dispatch); // The document a writer or reviewer run works on, from the coordinator's own hand-off call as // the hub's activity read saw it start: the latest such call made before the run started. - for (const tool of agents.find((agent) => agent.role === "coordinator") - ?.tools ?? []) { - const role = HANDOFF_ROLE[tool.name]; - if (role !== undefined && tool.subject !== undefined) - handoffCalls.current.set(`${tool.at}:${tool.name}`, { - role, - kind: tool.subject, - at: Date.parse(tool.at), - }); - } + const coordinatorTools = agents?.find( + (agent) => agent.role === "coordinator", + )?.tools; + if (coordinatorTools !== undefined) + for (const tool of coordinatorTools) { + const role = HANDOFF_ROLE[tool.name]; + if (role !== undefined && tool.subject !== undefined) + handoffCalls.current.set(`${tool.at}:${tool.name}`, { + role, + kind: tool.subject, + at: Date.parse(tool.at), + }); + } const handoffKind = (run: InFlightRun): string | undefined => { const known = runKinds.current.get(run.runId); if (known !== undefined) return known; @@ -1195,8 +1296,9 @@ export function StageWorkspace({ : designConcepts( options, inFlight, - [...settledApprovals, ...pendingApprovals].flatMap( - (approval) => approval.concepts ?? [], + // An ask that names no concepts adds none. + shownApprovals.flatMap((approval) => + approval.concepts === undefined ? [] : approval.concepts, ), (option) => { const state = documentState(option); @@ -1241,10 +1343,7 @@ export function StageWorkspace({ ) || options.length > 0 || inFlight.some((run) => run.concept !== undefined); - useLiveAppPresence(projectId); - const appFiled = (snapshot?.artifacts ?? []).some( - (item) => item.kind === "app", - ); + const appFiled = snapshot.artifacts.some((item) => item.kind === "app"); const firstBuilder = appFiled ? undefined : inFlight.find((run) => run.role === "coder"); @@ -1255,15 +1354,14 @@ export function StageWorkspace({ const userManual = useUserManual( projectId, liveApp !== null, - rolesAtWork.has("coder"), + rolesAtWork?.has("coder") === true, ); // While an approval is open the pane follows the document it approves. const approvalDeliverableId = - pendingApprovals.find((approval) => approval.document !== undefined) + pendingApprovals?.find((approval) => approval.document !== undefined) ?.document?.kind ?? null; const filed = useFiledVersions({ projectId, - ready: roomSnapshotReady(projectId, snapshot), documents: artifacts.map((item) => item.kind === DESIGN_OPTION ? { @@ -1296,13 +1394,6 @@ export function StageWorkspace({ paneDeliverables.find((item) => item.kind === shownId) ?? null; const selectedState = selected === null ? undefined : documentState(selected); - if (projectRead.isError && status === undefined) - return void projectRead.refetch()} />; - if (!roomSnapshotReady(projectId, snapshot)) { - if (room.isError) return void room.refetch()} />; - return ; - } - // Until the first document is filed there is nothing to read beside the conversation, so it // takes the whole width; the document pane arrives with that document. const chatFirst = @@ -1623,7 +1714,7 @@ export function StageWorkspace({ }))} onOpenDocument={openDocument} shownDocument={chatFirst ? null : (selected?.kind ?? null)} - approvals={[...settledApprovals, ...pendingApprovals]} + approvals={shownApprovals} results={liveRun.results} runs={unseenRuns} {...(shownChoice === undefined || archived @@ -1691,7 +1782,7 @@ export function StageWorkspace({ selectedState.retryable ? { onRetry: () => { - if (selected) retryDocument(selected); + if (selected) void retryDocument(selected); }, } : {})} diff --git a/apps/web/src/pages/workspace/load-room.ts b/apps/web/src/pages/workspace/load-room.ts index 9dbf90cc..1f0e7fad 100644 --- a/apps/web/src/pages/workspace/load-room.ts +++ b/apps/web/src/pages/workspace/load-room.ts @@ -269,40 +269,37 @@ export async function loadProjectRoom( ); for (const key of settled.keys()) { if (generation.get(projectId) !== ticket) break; - try { - const owner = JSON.parse(key) as unknown; - if (Array.isArray(owner) && owner[0] === projectId && !live.has(key)) - settled.delete(key); - } catch { - // A key this loader did not write. Leave it. - } + // Every key is a `roomDocumentKey`, written by this loader or restored from the saved cache + // it persisted, so it always parses. + const owner: unknown = JSON.parse(key); + if (Array.isArray(owner) && owner[0] === projectId && !live.has(key)) + settled.delete(key); } + const settleDocument = async (artifact: ProjectArtifact, key: string) => { + let state: DeliverableState; + try { + state = { + status: "ready", + document: await loadDeliverable(projectId, artifact), + }; + } catch (cause) { + const failure = documentReadFailure(cause); + state = { + status: "error", + message: failure.message, + retryable: failure.retryable, + }; + } + if (stale()) return; + settled.set(key, state); + }; await Promise.all( fresh.artifacts.flatMap((artifact) => { const key = roomDocumentKey(projectId, artifact); const have = settled.get(key); if (have !== undefined && have.status !== "loading") return []; - return [ - (async () => { - let state: DeliverableState; - try { - state = { - status: "ready", - document: await loadDeliverable(projectId, artifact), - }; - } catch (cause) { - const failure = documentReadFailure(cause); - state = { - status: "error", - message: failure.message, - retryable: failure.retryable, - }; - } - if (generation.get(projectId) !== ticket) return; - settled.set(key, state); - })(), - ]; + return [settleDocument(artifact, key)]; }), ); @@ -319,7 +316,7 @@ export async function loadProjectRoom( } /** Reads one document again. The last real record stays until the new read finishes. */ -export function reloadProjectDocument( +export async function reloadProjectDocument( projectId: string, docKey: string, client: QueryClient, @@ -327,7 +324,9 @@ export function reloadProjectDocument( ): Promise { settled.delete(docKey); refetchKeys.add(docKey); - return loadProjectRoom(projectId, client, withConversation).finally(() => { + try { + return await loadProjectRoom(projectId, client, withConversation); + } finally { refetchKeys.delete(docKey); - }); + } } diff --git a/apps/web/src/pages/workspace/next-step.tsx b/apps/web/src/pages/workspace/next-step.tsx index d8fcc6e0..7a2d93e9 100644 --- a/apps/web/src/pages/workspace/next-step.tsx +++ b/apps/web/src/pages/workspace/next-step.tsx @@ -2,7 +2,7 @@ * Where the project stands and the person's one next step, as the hub derives them from native * state. The line is pinned above the conversation when the person's preference turns it on. */ -import { useQuery } from "@tanstack/react-query"; +import { queryOptions, useQuery } from "@tanstack/react-query"; import { Button, StatusDot } from "@corbits/react-ui"; import type { ProjectNextAction, @@ -19,16 +19,19 @@ const POLL_MS = 5_000; const NEXT_STEP_LINE = "next_step_line"; const text = wording.nextStep; -export function useProjectNextStep(projectId: string, enabled = true) { - return useQuery({ +export function projectNextStepQuery(projectId: string) { + return queryOptions({ queryKey: keys.projectNextStep.of(projectId), - enabled, queryFn: ({ signal }) => readProjectNextStep(projectId, signal), refetchInterval: POLL_MS, refetchOnWindowFocus: false, }); } +export function useProjectNextStep(projectId: string, enabled = true) { + return useQuery({ ...projectNextStepQuery(projectId), enabled }); +} + function useNextStepLineOn() { const preferences = useQuery({ queryKey: keys.preferences, diff --git a/apps/web/src/pages/workspace/pane-file.tsx b/apps/web/src/pages/workspace/pane-file.tsx index f7d0ca1a..11b4b164 100644 --- a/apps/web/src/pages/workspace/pane-file.tsx +++ b/apps/web/src/pages/workspace/pane-file.tsx @@ -69,11 +69,23 @@ const canShare = (type: string) => files: [new File([], `file.${EXTENSION[type] ?? "bin"}`, { type })], }) === true; -function share(file: File) { - navigator.share({ files: [file] }).catch((cause: unknown) => { +async function share(file: File) { + try { + await navigator.share({ files: [file] }); + } catch (cause) { if (!(cause instanceof DOMException && cause.name === "AbortError")) failed(cause); - }); + } +} + +// Only the build is caught: a failure in what the press then does with the file is not a failed build. +async function builtOrReported(prepared: Prepared): Promise { + try { + return await prepared.built; + } catch (cause) { + failed(cause); + return null; + } } type Built = { file: File; note?: string; stale?: boolean }; @@ -118,17 +130,35 @@ export function useFileGroup(source: PaneFile | null): PaneBarGroup { at: Date.now(), built: file.build(), }; - next.built.then( - (built) => { - next.ready = built; - }, - () => { - if (prepared.current === next) prepared.current = null; - }, - ); + void keepOrForget(next); prepared.current = next; return next; }; + // A failed build is reported by the press that waits on it; this only lets the next press build again. + const keepOrForget = async (held: Prepared) => { + const [outcome] = await Promise.allSettled([held.built]); + if (outcome.status === "fulfilled") held.ready = outcome.value; + else if (prepared.current === held) prepared.current = null; + }; + const download = async (file: PaneFile) => { + const built = await builtOrReported(prepare(file)); + if (built === null) return; + save(built.file); + (built.stale === true ? toast.warning : toast.success)( + built.note ?? wording.paneFile.downloaded(built.file.name), + ); + }; + // The build outlived the press, so sharing needs a press of its own. + const offerShare = async (held: Prepared) => { + const built = await builtOrReported(held); + if (built === null) return; + toast(wording.paneFile.shareReady(built.file.name), { + action: { + label: wording.paneFile.shareNow, + onClick: () => void share(built.file), + }, + }); + }; useEffect(() => { if (source?.prepare === "shown") prepare(source); }); @@ -138,14 +168,7 @@ export function useFileGroup(source: PaneFile | null): PaneBarGroup { id: "download", label: source.download, icon: