diff --git a/.agents/skills/databuddy-internal/SKILL.md b/.agents/skills/databuddy-internal/SKILL.md index 75cf3be523..0d63c6b03e 100644 --- a/.agents/skills/databuddy-internal/SKILL.md +++ b/.agents/skills/databuddy-internal/SKILL.md @@ -151,7 +151,7 @@ Read [codebase-map.md](./references/codebase-map.md) when you need deeper routin - Integration catalog logos: use filled Simple Icons SVG path data (or equivalent filled brand SVG), store the path on each item as `iconPath`, render it through a shared logo tile with `bg-secondary/60`, `border-border/70`, `text-foreground`, and `fill="currentColor"`, then use brand color only as a small accent bar (`accent` or `accentClassName: "bg-foreground/70"` for black/near-black brands). Avoid raw brand-black icons or mixed line/filled icon sets that disappear in dark mode. - Organization integrations settings should stay list-first and operational: coming-soon integrations are static rows, Slack is the only expandable row for now, and connected integrations need obvious lifecycle controls such as uninstall/disconnect in the row details. - MCP setup UI should mirror the governed write metadata in `packages/ai/src/ai/mcp/tools.ts`: default to `read:data`, then expose explicit action bundles for workspace actions, feature flags, and short links with their required scopes and confirmation behavior. -- Dashboard UI must use `apps/dashboard/components/ds` primitives exactly; feature code must not use raw form/control elements (`button`, `input`, `select`, `textarea`, native dialogs), Base UI/Radix primitives, or ad hoc styled controls directly. If a variant is missing, add or extend the DS component first. For menu-style folder/status/filter/sort/action pickers, use `components/ds/dropdown-menu.tsx`; use `Select` only when the established pattern is explicitly a select/combobox. Read `apps/dashboard/components/ds/README.md` before creating new dashboard UI. +- Dashboard primitives now live in `packages/ui/src/components` and are consumed through `@databuddy/ui` (`@databuddy/ui/client` for client-only components); the old `apps/dashboard/components/ds/README.md` path no longer exists. Read the corresponding shared component implementation before extending UI. Dashboard UI must use these shared primitives exactly; feature code must not use raw form/control elements (`button`, `input`, `select`, `textarea`, native dialogs), Base UI/Radix primitives, or ad hoc styled controls directly. If a variant is missing, add or extend the DS component first. For menu-style folder/status/filter/sort/action pickers, use `components/ds/dropdown-menu.tsx`; use `Select` only when the established pattern is explicitly a select/combobox. Inspect the shared component APIs in `packages/ui/src/components` before creating new dashboard UI. - `DropdownMenu.GroupLabel` must be rendered inside `DropdownMenu.Group`; Base UI throws `MenuGroupRootContext is missing` when labels are placed directly under `DropdownMenu.Content`. - Traffic Trends chart annotations should use a chart-adjacent annotation rail for dense data; avoid in-plot labels, tall lines, or floating dots that compete with the chart tooltip/data layer. - Flags list rows (`app/(main)/websites/[id]/flags/_components/flags-list.tsx`) are clickable containers with nested controls; mark nested controls with `data-row-interactive="true"` and have the row ignore those targets instead of relying on broad cell-level `stopPropagation`. diff --git a/.agents/skills/frontend-design/SKILL.md b/.agents/skills/frontend-design/SKILL.md index 368d86131b..929acfba71 100644 --- a/.agents/skills/frontend-design/SKILL.md +++ b/.agents/skills/frontend-design/SKILL.md @@ -10,7 +10,7 @@ The user provides frontend requirements: a component, page, application, or inte ## Repository UI Guardrail -When working inside the Databuddy dashboard (`apps/dashboard`), the existing design system is mandatory and overrides the generic creative guidance below. Read `apps/dashboard/components/ds/README.md` and build feature UI from `apps/dashboard/components/ds` primitives exactly. Do not use raw form/control elements (`button`, `input`, `select`, `textarea`, native dialogs), Base UI/Radix primitives, or one-off styled controls in feature components. If the needed API or variant is missing, add or extend the DS primitive first. +When working inside the Databuddy dashboard (`apps/dashboard`), the existing design system is mandatory and overrides the generic creative guidance below. Build feature UI from `@databuddy/ui` primitives and inspect their APIs in `packages/ui/src/components`. The former `apps/dashboard/components/ds/README.md` path no longer exists. Do not use raw form/control elements (`button`, `input`, `select`, `textarea`, native dialogs), Base UI/Radix primitives, or one-off styled controls in feature components. If the needed API or variant is missing, add or extend the DS primitive first. For Databuddy dashboard pickers, use `DropdownMenu` for menu-style folder/status/filter/sort/action choices. Use `Select` only when the established pattern is truly a select/combobox. Product dashboard surfaces should stay consistent, dense, and operational rather than exploratory or marketing-styled. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fa29658d8d..7e65ed7a3e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -181,6 +181,13 @@ jobs: CLICKHOUSE_URL: http://default:@localhost:8123 NODE_ENV: test run: bun run test + - name: Business context integration + env: + NODE_ENV: test + BUSINESS_CONTEXT_INTEGRATION_TESTS: "true" + run: | + bun test packages/services/src/organization-business-context.integration.test.ts packages/services/src/organization-business-context-hardening.integration.test.ts + bun test packages/auth/src/organization-metadata.integration.test.ts - name: Insights integration env: NODE_ENV: test diff --git a/apps/dashboard/app/(main)/organizations/components/business-context-editor.tsx b/apps/dashboard/app/(main)/organizations/components/business-context-editor.tsx new file mode 100644 index 0000000000..18e9670b2c --- /dev/null +++ b/apps/dashboard/app/(main)/organizations/components/business-context-editor.tsx @@ -0,0 +1,501 @@ +"use client"; + +import { + BUSINESS_CONTEXT_LIMIT, + type BusinessBrief, + type BusinessContextSettings, + businessContextIsGenerating, +} from "@databuddy/shared/organization-business-context"; +import { Button, Field, Textarea, dayjs } from "@databuddy/ui"; +import { Dialog, DropdownMenu } from "@databuddy/ui/client"; +import { CaretDownIcon, WandSparkleIcon } from "@databuddy/ui/icons"; +import { useEffect, useRef, useState } from "react"; + +interface EditableBrief { + content: string; + generationId?: string; + revision: number; +} + +interface BusinessContextEditorProps { + onGenerate: (websiteId: string) => Promise; + onSave: (draft: { + content: string; + revision: number; + generationId?: string; + }) => Promise; + settings: BusinessContextSettings; +} + +function Sources({ sources }: { sources: BusinessBrief["sources"] }) { + const links = sources.filter( + ({ url }) => url.startsWith("https://") || url.startsWith("http://") + ); + if (!links.length) { + return null; + } + return ( +
+ Sources + {links.map(({ url, title }) => ( + + {title || new URL(url).hostname} + + ))} +
+ ); +} + +export function BusinessContextEditor({ + settings, + onGenerate, + onSave, +}: BusinessContextEditorProps) { + const { profile, generation, canEdit, websites } = settings; + const [draft, setDraft] = useState(null); + const [dismissedGenerationId, setDismissedGenerationId] = useState(); + 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 editorRef = useRef(null); + const revision = profile?.revision ?? 0; + const content = draft?.content ?? profile?.content ?? ""; + const generationWebsite = websites.find( + (site) => + site.id === generation?.websiteId && site.domain === generation.domain + ); + const draftGeneration = [generation, ...(settings.previousDrafts ?? [])].find( + (item) => + item?.status === "ready" && + item.id === draft?.generationId && + websites.some( + (site) => site.id === item.websiteId && site.domain === item.domain + ) + ); + const dirty = + draft !== null && + (content.trim() !== (profile?.content ?? "") || Boolean(draftGeneration)); + const conflict = dirty && draft.revision !== revision; + const activeGeneration = businessContextIsGenerating(settings); + const generating = isRequesting || activeGeneration; + const readyGeneration = + generation?.status === "ready" && + generationWebsite && + generation.draft && + generation.id !== dismissedGenerationId + ? generation + : null; + const pendingDraft = + readyGeneration && readyGeneration.id !== draft?.generationId + ? readyGeneration + : null; + const selectedWebsite = + websites.find( + (site) => + site.id === (activeGeneration ? generation?.websiteId : websiteId) + ) ?? + websites.find((site) => site.id === profile?.sourceWebsiteId) ?? + websites[0]; + const tooLong = content.trim().length > BUSINESS_CONTEXT_LIMIT; + const saveDisabled = + !(canEdit && dirty) || conflict || tooLong || isSaving || review !== null; + + useEffect(() => { + if (!canEdit) { + setDraft(null); + setReview(null); + return; + } + if ( + isSaving || + !readyGeneration?.draft || + readyGeneration.baseRevision !== revision + ) { + return; + } + // 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); + } + + async function save() { + if (saveDisabled || !draft) { + return; + } + setIsSaving(true); + 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"); + } catch (cause) { + setError( + cause instanceof Error + ? cause.message + : "Couldn't save the brief. Your edits are still here." + ); + } finally { + setIsSaving(false); + } + } + + async function generate() { + if (!(canEdit && selectedWebsite) || generating || isSaving) { + return; + } + setIsRequesting(true); + setError(undefined); + setNotice(""); + try { + await onGenerate(selectedWebsite.id); + } catch (cause) { + setError( + cause instanceof Error + ? cause.message + : "Couldn't start a draft. Please try again." + ); + } finally { + setIsRequesting(false); + } + } + + return ( +
+
+

+ Your business, in your words +

+

+ Give your agent the context behind your numbers: what you sell, who + you serve, and what success looks like. +

+
+ {canEdit && ( +
+ {websites.length > 1 ? ( + + + } + aria-label={`Source website: ${selectedWebsite?.name || selectedWebsite?.domain}`} + > + + {selectedWebsite?.name || selectedWebsite?.domain} + + + + + + + Generate from website + + + {websites.map((site) => ( + + {site.name || site.domain} + + ))} + + + + + ) : ( +

+ {selectedWebsite + ? `From ${selectedWebsite.domain}` + : "Add a website to generate a brief, or write your own below."} +

+ )} + {selectedWebsite && ( + + )} +
+ )} +
+ {generating && ( +

+ {canEdit + ? "Reading your website and preparing a draft. You can keep writing." + : "An updated brief is being prepared."} +

+ )} + {pendingDraft && canEdit && ( +
+

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

+ +
+ )} + {generation?.status === "failed" && canEdit && ( +

+ {generation.error || + "AI couldn't finish this draft. Try generating again, or keep editing."} +

+ )} + {notice &&

{notice}

} +
+ + Business brief + + {canEdit + ? "Edit anything. Your saved brief stays in use until you save changes." + : "This is the context your agent uses. Organization admins can update it."} + +