diff --git a/.agents/skills/databuddy-internal/SKILL.md b/.agents/skills/databuddy-internal/SKILL.md index f9d4b677fa..b3d72f6abd 100644 --- a/.agents/skills/databuddy-internal/SKILL.md +++ b/.agents/skills/databuddy-internal/SKILL.md @@ -26,6 +26,8 @@ Keep additions **minimal**: one bullet, a new `rg` hint, or a routing note—eno - `apps/dashboard`: Next.js app on port `3000` (per-website **agent** chat: `@ai-sdk/react` `useChat` via `contexts/chat-context.tsx` — not the separate `chat-sdk` package; overlapping sends while streaming are queued client-side to mirror a “queue latest” strategy.) - Dashboard Playwright webServer commands run under CI PATH from setup-bun; avoid `bash -lc` because login shells can drop Bun from PATH. Build dist-only workspace packages such as `@databuddy/sdk` and `@databuddy/devtools` before starting the API/dashboard. Client `NEXT_PUBLIC_*` flags must use direct env access so Next can inline them. `readBooleanEnv` only treats the literal string `"true"` as enabled, so CI E2E booleans must use `"true"`/`"false"`, not `"1"`/`"0"`. - Dashboard pages that call `useSearchParams()` must keep that call inside a Suspense-rendered child; run the production dashboard build after adding URL-driven onboarding state. +- Business context loads through `OrganizationProvider` before its page query and generation-access check; keep all three states on the same page/card geometry and test their transitions with delayed requests on desktop and mobile. +- Playwright launched through Bun still uses Node; use portable Promise constructors for deferred E2E requests because CI may not support `Promise.withResolvers`. - The local `.env` sets `NODE_ENV=development`; override it with `NODE_ENV=production` when verifying `next build`, or root-error prerender behavior can produce a misleading local failure. - Local E2E dashboard smokes that need `/api/test/e2e/*` should start the API/dashboard directly (or through Playwright's webServer command), not via `bun run dev:dashboard`; Turbo runs in strict env mode and drops `DATABUDDY_E2E_MODE`/`DATABUDDY_E2E_TEST_KEY` unless they are added to `turbo.json` `globalEnv`. - Dashboard Playwright public/demo analytics specs call API `/v1/query` anonymously from the browser; keep `DATABUDDY_E2E_MODE` query behavior isolated from production rate limits so CI retries do not exhaust `anon:unknown`. diff --git a/apps/dashboard/app/(main)/organizations/components/business-context-content.tsx b/apps/dashboard/app/(main)/organizations/components/business-context-content.tsx new file mode 100644 index 0000000000..a11b64aea0 --- /dev/null +++ b/apps/dashboard/app/(main)/organizations/components/business-context-content.tsx @@ -0,0 +1,152 @@ +"use client"; + +import type { + BusinessBrief, + BusinessContextEdit, +} from "@databuddy/shared/organization-business-context"; +import { cn, dayjs } from "@databuddy/ui"; +import { ArrowSquareOutIcon, FileTextIcon } from "@databuddy/ui/icons"; +import { Streamdown } from "streamdown"; + +export function BusinessContextMarkdown({ + content, + streaming = false, +}: { + content: string; + streaming?: boolean; +}) { + return ( + (alt ? {alt} : null) }} + > + {content} + + ); +} + +export function BusinessContextVersion({ + label, + value, + sources, + className, +}: { + label: string; + className?: string; + value: BusinessContextEdit; + sources: BusinessBrief["sources"]; +}) { + return ( +
+

{label}

+ {value.content.trim() ? ( + + ) : ( +

No business brief.

+ )} + {Object.values(value.teamContext ?? {}).some(Boolean) && ( +
+ {[ + ["Current priority", value.teamContext?.priority], + ["Success definition", value.teamContext?.successDefinition], + ["Exclusions", value.teamContext?.exclusions], + ].map(([title, text]) => + text ? ( +
+
{title}
+
+ {text} +
+
+ ) : null + )} +
+ )} + {Boolean(value.measurementPlans?.length) && ( +
+

Activation and return

+ {value.measurementPlans?.map((plan) => ( +
+

{plan.name || "Unnamed outcome"}

+

{plan.domain}

+

+ {plan.activationEvent} →{" "} + {plan.returnEvent} within {plan.horizonDays} days +

+ {plan.namespace && ( +

+ Namespace: {plan.namespace} +

+ )} +
+ ))} +
+ )} +
+

Sources for this version

+ +
+
+ ); +} + +export function BusinessContextSources({ + sources, +}: { + sources: BusinessBrief["sources"]; +}) { + const links = sources.filter( + ({ url }) => url.startsWith("https://") || url.startsWith("http://") + ); + if (!links.length) { + return ( +

+ No public sources attached. Your team can write or correct the brief + directly. +

+ ); + } + return ( + + ); +} 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 4c11f34919..5670c63ff2 100644 --- a/apps/dashboard/app/(main)/organizations/components/business-context-editor.tsx +++ b/apps/dashboard/app/(main)/organizations/components/business-context-editor.tsx @@ -3,31 +3,49 @@ import { BUSINESS_CONTEXT_LIMIT, BUSINESS_CONTEXT_TEAM_FIELD_LIMIT, - type BusinessBrief, type BusinessContextEdit, type BusinessTeamContext, type OrganizationBusinessProfile, type BusinessContextSettings, businessContextIsGenerating, formatBusinessTeamContext, - formatBusinessMeasurementPlans, businessMeasurementPlansSchema, + businessContextSourceUrlsSchema, + businessContextSourceBelongsToSite, } from "@databuddy/shared/organization-business-context"; -import { Button, Card, Field, Textarea, dayjs } from "@databuddy/ui"; -import { Accordion, Dialog, DropdownMenu } from "@databuddy/ui/client"; -import { diffWordsWithSpace } from "diff"; import { - ArrowSquareOutIcon, + Button, + Card, + Field, + Textarea, + SegmentedControl, + Skeleton, + Spinner, + Badge, + dayjs, +} from "@databuddy/ui"; +import { DropdownMenu, Tabs } from "@databuddy/ui/client"; +import { CaretDownIcon, - FileTextIcon, FloppyDiskIcon, WandSparkleIcon, } from "@databuddy/ui/icons"; import { useEffect, useRef, useState } from "react"; +import Link from "next/link"; import { TopBar } from "@/components/layout/top-bar"; import { getUserFacingErrorMessage } from "@/lib/user-facing-error"; +import { + BusinessContextBriefHeader, + BusinessContextResearchCard, + BusinessContextSourceInput, +} from "./business-context-layout"; import { useBusinessContextDraft } from "./use-business-context-draft"; import { MeasurementPlanEditor } from "./measurement-plan-editor"; +import { + BusinessContextMarkdown, + BusinessContextSources, + BusinessContextVersion, +} from "./business-context-content"; const emptyTeamContext: BusinessTeamContext = { priority: "", @@ -38,22 +56,21 @@ const teamFields = [ { key: "priority", label: "Current priority", - placeholder: "What business outcome matters most right now?", + description: "The customer outcome your team is focused on improving.", }, { key: "successDefinition", label: "How you define success", - placeholder: - "Which events or actions mean a customer has reached that outcome?", + description: + "What does a successful customer actually do? Include relevant event names if you know them.", }, { key: "exclusions", label: "Things to exclude or account for", - placeholder: - "Internal traffic, test accounts, seasonality, or other constraints.", + description: + "Internal testing, seasonal demand, known incidents, or other context.", }, ] as const; - type Review = | { kind: "generation" | "conflict" } | { @@ -61,108 +78,27 @@ type Review = profile: OrganizationBusinessProfile; baseRevision: number; }; - interface BusinessContextEditorProps { + access?: { + status: + | "allowed" + | "credits-required" + | "unavailable" + | "not-configured" + | "read-only"; + message: string; + action: "generate" | "billing" | "retry" | "contact-admin"; + }; + accessPending: boolean; onCancel: (generationId: string) => Promise; - onGenerate: (websiteId: string) => Promise; + onGenerate: (websiteId: string, sourceUrls: string[]) => Promise; + onRefreshAccess: () => void; 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"] }) { - const links = sources.filter( - ({ url }) => url.startsWith("https://") || url.startsWith("http://") - ); - if (!links.length) { - return null; - } - return ( - - - Sources - Pages used to generate this brief - - - {links.map(({ url, title }) => { - const page = new URL(url); - return ( - - -
-

- {title || page.hostname} -

-

- {page.hostname} - {page.pathname === "/" ? "" : page.pathname} -

-
- -
- ); - })} -
-
- ); -} - export function BusinessContextEditor({ settings, onGenerate, @@ -170,6 +106,9 @@ export function BusinessContextEditor({ onCancel, onRestore, storageKey, + access, + accessPending, + onRefreshAccess, }: BusinessContextEditorProps) { const { profile, generation, canEdit, websites } = settings; const { @@ -185,8 +124,13 @@ export function BusinessContextEditor({ const [error, setError] = useState(); const [notice, setNotice] = useState(""); const [settledGenerationId, setSettledGenerationId] = useState(); + const [reviewVersion, setReviewVersion] = useState("proposed"); const [review, setReview] = useState(null); const editorRef = useRef(null); + const [view, setView] = useState("preview"); + const [sourceText, setSourceText] = useState(""); + const pageRef = useRef(null); + const reviewWasOpen = useRef(false); const reviewTitleRef = useRef(null); const savingRef = useRef(false); const revision = profile?.revision ?? 0; @@ -262,43 +206,74 @@ export function BusinessContextEditor({ ? (pendingDraft?.draft?.content ?? "") : (reviewedProfile?.content ?? ""); const reviewTeam = - review?.kind === "generation" ? teamContext : reviewedProfile?.teamContext; + review?.kind === "generation" ? undefined : reviewedProfile?.teamContext; const reviewPlans = review?.kind === "generation" ? measurementPlans : reviewedProfile?.measurementPlans; + const sourceResult = businessContextSourceUrlsSchema.safeParse( + sourceText + .split("\n") + .map((value) => value.trim()) + .filter(Boolean) + ); + const invalidSource = + sourceResult.success && + selectedWebsite && + sourceResult.data.some( + (url) => !businessContextSourceBelongsToSite(url, selectedWebsite.domain) + ); + const sourceError = sourceResult.success + ? invalidSource + ? "Use pages on the selected website or its subdomains, such as your docs site." + : undefined + : "Add up to six valid public page URLs, one per line."; + const canGenerate = + canEdit && + !!selectedWebsite && + access?.status === "allowed" && + !accessPending && + !generating && + !isSaving && + !sourceError; + const reviewTitle = + review?.kind === "history" + ? `Review version ${review.profile.revision}` + : review?.kind === "generation" + ? "Review AI draft" + : "Review saved changes"; + const liveText = generation?.progress?.content ?? ""; + const displayedDraft = readyGeneration?.draft?.content ?? liveText; + const hasGeneratedContent = generating || !!readyGeneration; + useEffect(() => { - if ( - !(ready && canEdit) || - draft || - isSaving || - !readyGeneration?.draft || - readyGeneration.baseRevision !== revision - ) { - return; + if (review) { + reviewWasOpen.current = true; + reviewTitleRef.current?.focus(); + } else if (reviewWasOpen.current) { + const target = + view === "edit" + ? editorRef.current + : pageRef.current?.querySelector( + '[role="tab"][aria-selected="true"]' + ); + if (target) { + reviewWasOpen.current = false; + target.focus(); + } } - // A result may arrive between keystrokes. Only an untouched editor can adopt it automatically. - const generatedDraft = readyGeneration.draft; - setDraft({ - content: generatedDraft.content, - revision, - generationId: readyGeneration.id, - teamContext: profile?.teamContext, - measurementPlans: profile?.measurementPlans, - }); - }, [ - ready, - canEdit, - draft, - isSaving, - readyGeneration, - revision, - profile?.teamContext, - profile?.measurementPlans, - setDraft, - ]); - + }, [review, view]); + useEffect(() => { + if (view === "edit") { + editorRef.current?.focus(); + } + }, [view]); + useEffect(() => { + if (view === "draft" && !hasGeneratedContent) { + setView("preview"); + } + }, [view, hasGeneratedContent]); async function change( action: () => Promise, message: string, @@ -363,14 +338,23 @@ export function BusinessContextEditor({ } async function generate() { - if (!(canEdit && selectedWebsite) || generating || isSaving) { + if ( + !(canEdit && selectedWebsite) || + generating || + isSaving || + accessPending || + access?.status !== "allowed" || + !sourceResult.success || + invalidSource + ) { return; } setIsRequesting(true); setError(undefined); setNotice(""); try { - await onGenerate(selectedWebsite.id); + await onGenerate(selectedWebsite.id, sourceResult.data); + setView("draft"); } catch (cause) { setError( getUserFacingErrorMessage( @@ -380,209 +364,436 @@ export function BusinessContextEditor({ ); } finally { setIsRequesting(false); + onRefreshAccess(); } } return ( -
- {canEdit && (dirty || draft) && ( +
+ {canEdit && ( + - {dirty && ( - - )} )} - - - Business context - - What you do and who you serve. Applies to every website in this - organization. - - - - {canEdit && ( -
- {websites.length > 1 ? ( - - +
+ + + + {dirty ? "Unsaved changes" : profile ? "Saved" : "Not set up"} + + + {review ? ( +
+
+

+ {reviewTitle} +

+

+ {review.kind === "generation" + ? "Compare the proposed brief with your current work. Using the draft keeps your team priorities and event definitions; save when you are ready." + : review.kind === "history" + ? "Restoring this version replaces the brief, team context, and event definitions. Your current saved version stays in history." + : "Someone saved a newer version. Your edits are still here. Choose which version to continue with."} +

+ {review.kind === "generation" && + pendingDraft?.baseRevision !== revision && ( +

+ This draft predates the latest saved version. Check any + recent corrections before using it. +

+ )} +
+
+ +
+
+ + +
+
+ + {review.kind === "generation" ? ( + <> + + + ) : review.kind === "history" ? ( + + ) : ( + <> + + + + )} +
+
+ ) : ( + setView(String(value))} + > +
+ + Preview + {canEdit && Edit} + {hasGeneratedContent && ( + AI draft + )} + + {Boolean(settings.history?.length) && ( + + } + > + History + + + + + + Previous saved versions + + {settings.history?.toReversed().map((previous) => ( + + setReview({ + kind: "history", + profile: previous, + baseRevision: revision, + }) + } + > + Version {previous.revision} ·{" "} + {dayjs(previous.updatedAt).format( + "MMM D, h:mm A" + )} + + ))} + + + + )} +
+ + {content.trim() ? ( + + ) : ( +
+
+

+ Give your analytics some context +

+

+ Describe what you offer, who uses it, and how they get + value. Databuddy can also read your website to prepare + a draft. +

+
+ {canEdit ? ( + + ) : ( +

+ An organization admin can add your business context. +

+ )} +
+ )} +
+ {canEdit && ( + + + Business brief + + Describe your product, customers, and business model. + Markdown is supported. + +