From 4573c4938505480712e0f25342aebe72268645da Mon Sep 17 00:00:00 2001 From: iza <59828082+izadoesdev@users.noreply.github.com> Date: Wed, 9 Sep 2026 00:11:12 +0300 Subject: [PATCH 1/6] fix(insights): bound business context generation and stop stale work (#765) --- .../src/organization-business-context.test.ts | 272 +++++++++++++++++- .../src/organization-business-context.ts | 83 +++++- 2 files changed, 331 insertions(+), 24 deletions(-) diff --git a/apps/insights/src/organization-business-context.test.ts b/apps/insights/src/organization-business-context.test.ts index a0293ae230..80f7262906 100644 --- a/apps/insights/src/organization-business-context.test.ts +++ b/apps/insights/src/organization-business-context.test.ts @@ -34,6 +34,29 @@ afterEach(() => { else process.env.AUTUMN_SECRET_KEY = secret; }); +function clock() { + const started = Date.now(); + let elapsed = 0; + const timers: { at: number; controller: AbortController }[] = []; + spyOn(Date, "now").mockImplementation(() => started + elapsed); + spyOn(performance, "now").mockImplementation(() => elapsed); + spyOn(AbortSignal, "timeout").mockImplementation((ms) => { + const controller = new AbortController(); + timers.push({ at: elapsed + ms, controller }); + return controller.signal; + }); + return (ms: number) => { + elapsed += ms; + for (const timer of timers) { + if (timer.at <= elapsed) { + timer.controller.abort( + new DOMException("Test deadline", "TimeoutError") + ); + } + } + }; +} + function fixture( outputs: unknown[] = [ { paths: ["/pricing"] }, @@ -157,12 +180,12 @@ function fixture( const errors = spyOn(logs, "captureInsightsError").mockImplementation( () => {} ); - spyOn(logs, "emitInsightsEvent").mockImplementation(() => {}); + const events = spyOn(logs, "emitInsightsEvent").mockImplementation(() => {}); setAiRequestLoggerProvider(logs.getActiveInsightsLog); + const logger = logs.createInsightsEventLog({ test: true }); const run = () => - logs.withInsightsLogContext( - logs.createInsightsEventLog({ test: true }), - () => generateOrganizationBusinessContext(input) + logs.withInsightsLogContext(logger, () => + generateOrganizationBusinessContext(input) ); return { get state() { @@ -183,6 +206,8 @@ function fixture( billed, model, errors, + events, + logger, }; } @@ -225,6 +250,26 @@ describe("organization business context worker", () => { expect(JSON.stringify(synthesis)).toContain("savedContext"); expect(JSON.stringify(synthesis)).toContain(manual); expect(f.bill).toHaveBeenCalledTimes(2); + expect(f.logger.getContext().ai).toMatchObject({ + calls: 2, + inputTokens: 400, + outputTokens: 200, + totalTokens: 600, + }); + expect( + f.events.mock.calls + .filter( + ([, event]) => event === "organization_business_context.model_call" + ) + .map(([, , fields]) => ({ + phase: fields?.phase, + input: fields?.input_tokens, + output: fields?.output_tokens, + })) + ).toEqual([ + { phase: "selection", input: 200, output: 100 }, + { phase: "synthesis", input: 200, output: 100 }, + ]); expect( new Set(f.bill.mock.calls.map(([call]) => call.idempotencyKey)).size ).toBe(2); @@ -324,6 +369,11 @@ describe("organization business context worker", () => { expect(f.state.generation?.status).toBe("failed"); expect(f.state.profile?.content).toBe(manual); expect(f.bill).toHaveBeenCalledTimes(2); + expect(f.logger.getContext().ai).toMatchObject({ + calls: 2, + inputTokens: 400, + outputTokens: 200, + }); }); it.each([ @@ -331,6 +381,7 @@ describe("organization business context worker", () => { "expired", "ready", "failed", + "cancelled", ])("does no work for %s generation", async (condition) => { const f = fixture(); const generation = f.state.generation; @@ -342,12 +393,101 @@ describe("organization business context worker", () => { ).toISOString(); if (condition === "ready" || condition === "failed") generation.status = condition; + if (condition === "cancelled") f.state.generation = null; await f.run(); expect(f.site).not.toHaveBeenCalled(); expect(f.mark).not.toHaveBeenCalled(); expect(f.model.doGenerateCalls).toHaveLength(0); }); + it.each([ + "saved", + "cancelled", + "superseded", + "failed", + "ready", + ] as const)("stops after selection is %s, accounting for the consumed call", async (condition) => { + const f = fixture(); + const generate = f.model.doGenerate; + f.model.doGenerate = async (options) => { + const result = await generate(options); + if (!f.state.profile || !f.state.generation) + throw new Error("Missing fixture"); + if (condition === "saved") { + f.state.profile = { + ...f.state.profile, + content: "New team correction", + revision: 4, + }; + f.state.generation = null; + } + if (condition === "cancelled") f.state.generation = null; + if (condition === "superseded" && f.state.generation) + f.state.generation = { ...f.state.generation, id: "new-generation" }; + if ( + (condition === "failed" || condition === "ready") && + f.state.generation + ) + f.state.generation.status = condition; + return result; + }; + await f.run(); + expect(f.model.doGenerateCalls).toHaveLength(1); + expect(f.bill).toHaveBeenCalledTimes(1); + expect(f.read.mock.calls.map(([call]) => call.path)).toEqual(["/"]); + expect(f.mark).toHaveBeenCalledTimes(1); + expect(f.errors).not.toHaveBeenCalled(); + expect(f.logger.getContext().ai).toMatchObject({ + calls: 1, + inputTokens: 200, + outputTokens: 100, + }); + expect(f.state.profile?.content).toBe( + condition === "saved" ? "New team correction" : manual + ); + }); + + it.each([ + "credits", + "homepage", + "discovery", + "selected-page", + ] as const)("checks cancellation after %s before starting more reads or model calls", async (phase) => { + const f = fixture(); + if (phase === "credits") { + f.credits.mockImplementation(async () => { + f.state.generation = null; + return true; + }); + } + if (phase === "discovery") { + f.search.mockImplementation(async () => { + f.state.generation = null; + return { success: true, results: [] }; + }); + } + const read = f.read.getMockImplementation(); + if (!read) throw new Error("Missing page fixture"); + f.read.mockImplementation(async (...args) => { + const result = await read(...args); + if ( + (phase === "homepage" && args[0].path === "/") || + (phase === "selected-page" && args[0].path === "/pricing") + ) + f.state.generation = null; + return result; + }); + await f.run(); + expect(f.model.doGenerateCalls).toHaveLength( + phase === "selected-page" ? 1 : 0 + ); + expect(f.bill).toHaveBeenCalledTimes(phase === "selected-page" ? 1 : 0); + if (phase === "credits") expect(f.read).not.toHaveBeenCalled(); + if (phase === "homepage") expect(f.search).not.toHaveBeenCalled(); + expect(f.state.generation).toBeNull(); + expect(f.errors).not.toHaveBeenCalled(); + }); + it("does not read a deleted, transferred, or renamed source website", async () => { const f = fixture(); f.site.mockResolvedValue(undefined); @@ -473,13 +613,18 @@ describe("organization business context worker", () => { expect(f.model.doGenerateCalls).toHaveLength(0); }); - it("bounds a hanging source read and preserves saved content on timeout", async () => { + it.each([ + 0, 170_000, + ])("bounds a hanging source read with %i ms queue age", async (age) => { const f = fixture(); - const timeout = AbortSignal.timeout; - spyOn(AbortSignal, "timeout").mockImplementation((ms) => - timeout(ms === 115_000 ? 5 : ms) - ); - f.read.mockImplementation(() => new Promise(() => {})); + const advance = clock(); + if (!f.state.generation) throw new Error("Missing fixture generation"); + f.state.generation.requestedAt = new Date(Date.now() - age).toISOString(); + f.read.mockImplementation(({ abortSignal }) => { + advance(age ? 5000 : 115_000); + expect(abortSignal?.aborted).toBe(true); + return new Promise(() => {}); + }); await f.run(); expect(f.state.generation?.status).toBe("failed"); expect(f.state.generation?.error).toContain("too long"); @@ -487,6 +632,113 @@ describe("organization business context worker", () => { expect(f.model.doGenerateCalls).toHaveLength(0); }); + it("does not start work when only the persistence reserve remains", async () => { + const f = fixture(); + clock(); + if (!f.state.generation) throw new Error("Missing fixture generation"); + f.state.generation.requestedAt = new Date( + Date.now() - 175_000 + ).toISOString(); + await f.run(); + expect(f.state.generation?.status).toBe("failed"); + expect(f.state.generation?.error).toContain("too long"); + expect(f.site).not.toHaveBeenCalled(); + expect(f.read).not.toHaveBeenCalled(); + expect(f.bill).not.toHaveBeenCalled(); + }); + + it("stops before synthesis when selection uses the remaining request budget", async () => { + const f = fixture(); + const advance = clock(); + if (!f.state.generation) throw new Error("Missing fixture generation"); + f.state.generation.requestedAt = new Date( + Date.now() - 170_000 + ).toISOString(); + f.bill.mockImplementation(async (call) => { + advance(5000); + return f.billed(call); + }); + await f.run(); + expect(f.model.doGenerateCalls).toHaveLength(1); + expect(f.bill).toHaveBeenCalledTimes(1); + expect(f.read.mock.calls.map(([call]) => call.path)).toEqual(["/"]); + expect(f.state.generation?.status).toBe("failed"); + expect(f.state.generation?.error).toContain("too long"); + expect(f.state.generation?.draft).toBeNull(); + expect(f.logger.getContext().ai).toMatchObject({ + calls: 1, + inputTokens: 200, + outputTokens: 100, + }); + }); + + it("bounds the model by the request deadline after queue and source reads", async () => { + const f = fixture(); + const advance = clock(); + if (!f.state.generation) throw new Error("Missing fixture generation"); + f.state.generation.requestedAt = new Date( + Date.now() - 170_000 + ).toISOString(); + const read = f.read.getMockImplementation(); + if (!read) throw new Error("Missing page fixture"); + f.read.mockImplementation(async (...args) => { + advance(2000); + return await read(...args); + }); + const generate = f.model.doGenerate; + f.model.doGenerate = async (options) => { + const result = await generate(options); + advance(2999); + expect(options.abortSignal?.aborted).toBe(false); + advance(1); + expect(options.abortSignal?.aborted).toBe(true); + options.abortSignal?.throwIfAborted(); + return result; + }; + await f.run(); + expect(f.model.doGenerateCalls).toHaveLength(1); + expect(f.state.generation?.status).toBe("failed"); + expect(f.state.generation?.error).toContain("too long"); + expect(f.bill).not.toHaveBeenCalled(); + }); + + it("finishes consumed-call billing and persists within the reserve before request expiry", async () => { + const f = fixture(); + const advance = clock(); + if (!f.state.generation) throw new Error("Missing fixture generation"); + f.state.generation.requestedAt = new Date( + Date.now() - 170_000 + ).toISOString(); + const expiry = Date.now() + 10_000; + const generate = f.model.doGenerate; + f.model.doGenerate = async (options) => { + advance(2000); + expect(options.abortSignal?.aborted).toBe(false); + return await generate(options); + }; + f.bill.mockImplementation(async (call) => { + if (f.model.doGenerateCalls.length === 2) advance(4000); + return f.billed(call); + }); + const mark = f.mark.getMockImplementation(); + if (!mark) throw new Error("Missing persistence fixture"); + f.mark.mockImplementation(async (change) => { + if (change.status === "ready") { + expect(Date.now()).toBe(expiry - 2000); + advance(1000); + } + return await mark(change); + }); + await f.run(); + expect(f.model.doGenerateCalls).toHaveLength(2); + expect(f.model.doGenerateCalls[1]?.abortSignal?.aborted).toBe(true); + expect(f.bill).toHaveBeenCalledTimes(2); + expect(f.state.generation?.status).toBe("ready"); + expect(Date.now()).toBeLessThan(expiry); + expect(f.state.profile?.content).toBe(manual); + expect(f.errors).not.toHaveBeenCalled(); + }); + it("preserves saved manual text and the internal cause when the model fails", async () => { const f = fixture(); const failure = new Error("Synthetic model provider failure"); diff --git a/apps/insights/src/organization-business-context.ts b/apps/insights/src/organization-business-context.ts index af2792821b..c55e2445c4 100644 --- a/apps/insights/src/organization-business-context.ts +++ b/apps/insights/src/organization-business-context.ts @@ -86,8 +86,18 @@ export async function generateOrganizationBusinessContext( ): Promise { const input = generationSchema.parse(payload); const started = performance.now(); - // Reserve five seconds to persist a friendly failure within the 120s job budget. - const signal = AbortSignal.timeout(115_000); + let deadline = Date.now() + 120_000; + const remaining = (reserve = 0) => + Math.max( + 0, + Math.floor( + Math.min( + deadline - Date.now(), + 120_000 - (performance.now() - started) + ) - reserve + ) + ); + let signal = AbortSignal.timeout(115_000); const fields = { organization_id: input.organizationId, generation_id: input.generationId, @@ -110,6 +120,35 @@ export async function generateOrganizationBusinessContext( ) { return; } + deadline = Math.min( + deadline, + Date.parse(generation.requestedAt) + BUSINESS_CONTEXT_GENERATION_TIMEOUT + ); + // Queue age counts against the same deadline as the service. Reserve five + // seconds for consumed-call billing and persistence, including a friendly failure. + signal = AbortSignal.any([signal, AbortSignal.timeout(remaining(5000))]); + const settlement = AbortSignal.timeout(remaining()); + const available = () => { + signal.throwIfAborted(); + const ms = remaining(5000); + if (ms <= 0) { + throw new DOMException("Generation deadline reached", "TimeoutError"); + } + return ms; + }; + const current = async () => { + available(); + const latest = await bounded( + readOrganizationBusinessContext(input.organizationId), + signal + ); + available(); + return ( + latest.generation?.id === input.generationId && + latest.generation.status === "running" + ); + }; + available(); const site = await bounded( db.query.websites.findFirst({ where: { @@ -190,7 +229,7 @@ export async function generateOrganizationBusinessContext( usage, idempotencyKey, }), - signal + settlement ); if (logger.getContext().agent_usage_billing_error) { throw new Error("Business context usage billing failed", { @@ -233,10 +272,16 @@ export async function generateOrganizationBusinessContext( }); return result; }; + if (!(await current())) { + return; + } const home = await read("/"); if (!home) { throw new Error("No readable business homepage"); } + if (!(await current())) { + return; + } // Reuse the existing same-site search tool; discovery snippets are never evidence. const search = createScrapeTools().search_website; if (!search.execute) { @@ -297,13 +342,14 @@ export async function generateOrganizationBusinessContext( // AI SDK telemetry callbacks swallow thrown errors; check billing explicitly // after each call, before another read or making the draft available. let billingFailure: Error | undefined; + const model = getAILogger().wrap(createModelFromId(MODEL)); const options = (phase: string) => { const key = `org-business-context:${input.generationId}:${phase}:${randomUUID()}`; return { - model: getAILogger().wrap(createModelFromId(MODEL)), + model, maxRetries: 0, abortSignal: signal, - timeout: { totalMs: 45_000 }, + timeout: { totalMs: Math.min(45_000, available()) }, onStepFinish: async (step: { usage: LanguageModelUsage }) => { emitInsightsEvent( "info", @@ -325,6 +371,9 @@ export async function generateOrganizationBusinessContext( }; }; if (paths.length) { + if (!(await current())) { + return; + } const selected = await bounded( generateText({ ...options("selection"), @@ -339,11 +388,14 @@ export async function generateOrganizationBusinessContext( paths, }), }), - signal + settlement ); if (billingFailure) { throw billingFailure; } + if (!(await current())) { + return; + } const chosen = z.array(z.enum(paths)).max(6).parse(selected.output.paths); const results = await Promise.all([...new Set(chosen)].map(read)); pages = [ @@ -368,6 +420,9 @@ export async function generateOrganizationBusinessContext( .min(1) .max(7), }); + if (!(await current())) { + return; + } const compiled = await bounded( generateText({ ...options("synthesis"), @@ -394,7 +449,7 @@ export async function generateOrganizationBusinessContext( })), }), }), - signal + settlement ); if (billingFailure) { throw billingFailure; @@ -409,10 +464,9 @@ export async function generateOrganizationBusinessContext( title: page.title ?? page.finalUrl, })), }); - signal.throwIfAborted(); const ready = await bounded( markBusinessContextGeneration({ ...input, status: "ready", draft }), - signal + settlement ); if ( ready.generation?.id !== input.generationId || @@ -431,16 +485,17 @@ export async function generateOrganizationBusinessContext( "organization_business_context.generation_failed", fields ); - const remaining = Math.max(1, 120_000 - (performance.now() - started)); await bounded( markBusinessContextGeneration({ ...input, status: "failed", - error: signal.aborted - ? "Generation took too long. Try again; your saved context is unchanged." - : failure, + error: + signal.aborted || + (error instanceof Error && error.name === "TimeoutError") + ? "Generation took too long. Try again; your saved context is unchanged." + : failure, }), - AbortSignal.timeout(Math.floor(remaining)) + AbortSignal.timeout(Math.max(1, remaining())) ); } } From 656051cb7ecdf29d6a66aa7a117b45862b94c0e4 Mon Sep 17 00:00:00 2001 From: iza <59828082+izadoesdev@users.noreply.github.com> Date: Wed, 9 Sep 2026 00:35:05 +0300 Subject: [PATCH 2/6] feat(dashboard): recover business context edits and preserve team priorities (#766) * feat(dashboard): recover business context edits and preserve team priorities * fix(dashboard): protect newer context drafts from late saves --- .../components/business-context-editor.tsx | 463 ++++++++++++++---- .../components/business-context-settings.tsx | 53 +- .../components/use-business-context-draft.ts | 107 ++++ apps/dashboard/package.json | 1 + .../regressions/business-context.spec.ts | 162 ++++-- apps/insights/src/agent.ts | 2 +- apps/insights/src/business-aware-selection.ts | 1 + apps/insights/src/business-context.test.ts | 28 ++ apps/insights/src/business-context.ts | 23 +- .../src/organization-business-context.test.ts | 2 +- .../src/organization-business-context.ts | 2 +- bun.lock | 1 + packages/ai/src/lib/business-context.ts | 16 +- .../rpc/src/middleware/audit-mutation.test.ts | 3 + packages/rpc/src/middleware/audit-mutation.ts | 8 +- .../rpc/src/routers/business-context.test.ts | 60 ++- packages/rpc/src/routers/business-context.ts | 186 ++++--- ...ness-context-hardening.integration.test.ts | 10 +- ...ation-business-context.integration.test.ts | 56 ++- .../src/organization-business-context.ts | 107 +++- packages/shared/src/audit.ts | 10 + .../src/organization-business-context.ts | 39 +- 22 files changed, 1106 insertions(+), 234 deletions(-) create mode 100644 apps/dashboard/app/(main)/organizations/components/use-business-context-draft.ts diff --git a/apps/dashboard/app/(main)/organizations/components/business-context-editor.tsx b/apps/dashboard/app/(main)/organizations/components/business-context-editor.tsx index 7563416057..b0c94417a2 100644 --- a/apps/dashboard/app/(main)/organizations/components/business-context-editor.tsx +++ b/apps/dashboard/app/(main)/organizations/components/business-context-editor.tsx @@ -2,12 +2,18 @@ import { BUSINESS_CONTEXT_LIMIT, + BUSINESS_CONTEXT_TEAM_FIELD_LIMIT, type BusinessBrief, + type BusinessContextEdit, + type BusinessTeamContext, + type OrganizationBusinessProfile, type BusinessContextSettings, businessContextIsGenerating, + formatBusinessTeamContext, } from "@databuddy/shared/organization-business-context"; import { Button, Card, Field, Textarea, dayjs } from "@databuddy/ui"; -import { Dialog, DropdownMenu } from "@databuddy/ui/client"; +import { Accordion, Dialog, DropdownMenu } from "@databuddy/ui/client"; +import { diffWordsWithSpace } from "diff"; import { ArrowSquareOutIcon, CaretDownIcon, @@ -17,21 +23,96 @@ import { } from "@databuddy/ui/icons"; import { useEffect, useRef, useState } from "react"; import { TopBar } from "@/components/layout/top-bar"; +import { getUserFacingErrorMessage } from "@/lib/user-facing-error"; +import { useBusinessContextDraft } from "./use-business-context-draft"; -interface EditableBrief { - content: string; - generationId?: string; - revision: number; -} +const emptyTeamContext: BusinessTeamContext = { + priority: "", + successDefinition: "", + exclusions: "", +}; +const teamFields = [ + { + key: "priority", + label: "Current priority", + placeholder: "What business outcome matters most right now?", + }, + { + key: "successDefinition", + label: "How you define success", + placeholder: + "Which events or actions mean a customer has reached that outcome?", + }, + { + key: "exclusions", + label: "Things to exclude or account for", + placeholder: + "Internal traffic, test accounts, seasonality, or other constraints.", + }, +] as const; + +type Review = + | { kind: "generation" | "conflict" } + | { + kind: "history"; + profile: OrganizationBusinessProfile; + baseRevision: number; + }; interface BusinessContextEditorProps { + onCancel: (generationId: string) => Promise; onGenerate: (websiteId: string) => Promise; - onSave: (draft: { - content: string; - revision: number; - generationId?: string; - }) => Promise; + onRestore: (restoreRevision: number, revision: number) => Promise; + onSave: (draft: BusinessContextEdit) => Promise; settings: BusinessContextSettings; + storageKey: string; +} + +function BriefChanges({ before, after }: { before: string; after: string }) { + const changes = diffWordsWithSpace(before, after, { timeout: 50 }); + if (!changes) { + return ( +
+
+

Current text

+

{before || "Empty"}

+
+
+

Selected version

+

{after || "Empty"}

+
+
+ ); + } + let offset = 0; + return ( +
+

+ Additions are underlined. Removed text is struck through. +

+

+ {changes.map((change) => { + const key = `${offset}:${change.added ? "add" : change.removed ? "remove" : "keep"}`; + offset += change.value.length; + if (change.added) { + return ( + + {change.value} + + ); + } + if (change.removed) { + return ( + + {change.value} + + ); + } + return {change.value}; + })} +

+
+ ); } function Sources({ sources }: { sources: BusinessBrief["sources"] }) { @@ -83,20 +164,32 @@ export function BusinessContextEditor({ settings, onGenerate, onSave, + onCancel, + onRestore, + storageKey, }: BusinessContextEditorProps) { const { profile, generation, canEdit, websites } = settings; - const [draft, setDraft] = useState(null); - const [dismissedGenerationId, setDismissedGenerationId] = useState(); + const { + draft, + updateDraft: setDraft, + clearDraft: clearSubmittedDraft, + ready, + recoverable, + } = useBusinessContextDraft(storageKey, canEdit); const [websiteId, setWebsiteId] = useState(); const [isSaving, setIsSaving] = useState(false); const [isRequesting, setIsRequesting] = useState(false); const [error, setError] = useState(); const [notice, setNotice] = useState(""); - const [review, setReview] = useState<"generation" | "conflict" | null>(null); + const [settledGenerationId, setSettledGenerationId] = useState(); + const [review, setReview] = useState(null); const editorRef = useRef(null); + const reviewTitleRef = useRef(null); const savingRef = useRef(false); const revision = profile?.revision ?? 0; const content = draft?.content ?? profile?.content ?? ""; + const teamContext = + draft?.teamContext ?? profile?.teamContext ?? emptyTeamContext; const generationWebsite = websites.find( (site) => site.id === generation?.websiteId && site.domain === generation.domain @@ -111,15 +204,18 @@ export function BusinessContextEditor({ ); const dirty = draft !== null && - (content.trim() !== (profile?.content ?? "") || Boolean(draftGeneration)); + (content.trim() !== (profile?.content ?? "") || + Boolean(draftGeneration) || + formatBusinessTeamContext(teamContext) !== + formatBusinessTeamContext(profile?.teamContext)); const conflict = dirty && draft.revision !== revision; const activeGeneration = businessContextIsGenerating(settings); const generating = isRequesting || activeGeneration; const readyGeneration = generation?.status === "ready" && + generation.id !== settledGenerationId && generationWebsite && - generation.draft && - generation.id !== dismissedGenerationId + generation.draft ? generation : null; const pendingDraft = @@ -134,16 +230,28 @@ export function BusinessContextEditor({ websites.find((site) => site.id === profile?.sourceWebsiteId) ?? websites[0]; const tooLong = content.trim().length > BUSINESS_CONTEXT_LIMIT; + const teamTooLong = Object.values(teamContext).some( + (value) => value.trim().length > BUSINESS_CONTEXT_TEAM_FIELD_LIMIT + ); const saveDisabled = - !(canEdit && dirty) || conflict || tooLong || isSaving || review !== null; + !(ready && canEdit && dirty) || + conflict || + tooLong || + teamTooLong || + isSaving || + review !== null; + const reviewedProfile = review?.kind === "history" ? review.profile : profile; + const reviewText = + review?.kind === "generation" + ? (pendingDraft?.draft?.content ?? "") + : (reviewedProfile?.content ?? ""); + const reviewTeam = + review?.kind === "generation" ? teamContext : reviewedProfile?.teamContext; useEffect(() => { - if (!canEdit) { - setDraft(null); - setReview(null); - return; - } if ( + !(ready && canEdit) || + draft || isSaving || !readyGeneration?.draft || readyGeneration.baseRevision !== revision @@ -152,26 +260,29 @@ export function BusinessContextEditor({ } // A result may arrive between keystrokes. Only an untouched editor can adopt it automatically. const generatedDraft = readyGeneration.draft; - setDraft( - (current) => - current ?? { - content: generatedDraft.content, - revision, - generationId: readyGeneration.id, - } - ); - }, [canEdit, isSaving, readyGeneration, revision]); - - function discard() { - setDismissedGenerationId(generation?.id); - setDraft(null); - setError(undefined); - setNotice(""); - setReview(null); - } + setDraft({ + content: generatedDraft.content, + revision, + generationId: readyGeneration.id, + teamContext: profile?.teamContext, + }); + }, [ + ready, + canEdit, + draft, + isSaving, + readyGeneration, + revision, + profile?.teamContext, + setDraft, + ]); - async function save() { - if (saveDisabled || !draft || savingRef.current) { + async function change( + action: () => Promise, + message: string, + clearDraft = true + ) { + if (savingRef.current || !canEdit) { return; } savingRef.current = true; @@ -179,19 +290,21 @@ export function BusinessContextEditor({ setError(undefined); setNotice(""); try { - await onSave({ - content: content.trim(), - revision: draft.revision, - ...(draft.generationId ? { generationId: draft.generationId } : {}), - }); - setDismissedGenerationId(generation?.id); - setDraft(null); - setNotice("Changes saved"); + // The query cache notifies React asynchronously. Block the old result + // while its successful cancellation/save response reaches this render. + await action(); + setSettledGenerationId(generation?.id); + if (clearDraft) { + clearSubmittedDraft(draft); + } + setNotice(message); + setReview(null); } catch (cause) { setError( - cause instanceof Error - ? cause.message - : "Couldn't save the brief. Your edits are still here." + getUserFacingErrorMessage( + cause, + "Couldn't save this change. Your edits are still here." + ) ); } finally { savingRef.current = false; @@ -199,6 +312,33 @@ export function BusinessContextEditor({ } } + function discard() { + return change(async () => { + if (generation) { + await onCancel(generation.id); + } + if (draftGeneration && draftGeneration.id !== generation?.id) { + await onCancel(draftGeneration.id); + } + }, ""); + } + + async function save() { + if (saveDisabled || !draft || savingRef.current) { + return; + } + await change( + () => + onSave({ + content: content.trim(), + revision: draft.revision, + teamContext, + ...(draftGeneration ? { generationId: draftGeneration.id } : {}), + }), + "Changes saved" + ); + } + async function generate() { if (!(canEdit && selectedWebsite) || generating || isSaving) { return; @@ -210,9 +350,10 @@ export function BusinessContextEditor({ await onGenerate(selectedWebsite.id); } catch (cause) { setError( - cause instanceof Error - ? cause.message - : "Couldn't start a draft. Please try again." + getUserFacingErrorMessage( + cause, + "Couldn't start a draft. Please try again." + ) ); } finally { setIsRequesting(false); @@ -261,7 +402,8 @@ export function BusinessContextEditor({ Business context - What you do, who you serve, and what matters to your business + What you do and who you serve. Applies to every website in this + organization. @@ -324,6 +466,22 @@ export function BusinessContextEditor({ : "Generate with AI"} )} + {activeGeneration && generation && ( + + )} )}
{canEdit - ? "Reading your website and preparing a draft. You can keep writing." + ? generation?.status === "queued" + ? "Waiting to start. You can keep writing." + : "Reading your website and preparing a draft. You can keep writing. Saving ends this generation." : "An updated brief is being prepared."}

)} @@ -342,7 +502,7 @@ export function BusinessContextEditor({

An AI draft is ready. Your current text has been kept.