Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .agents/skills/databuddy-internal/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ Keep additions **minimal**: one bullet, a new `rg` hint, or a routing note—eno
- Never use production/customer data as tests, fixtures, snapshots, examples, or copied output. Tests must use placeholders/mocks only (example.com, example IDs). If production ClickHouse is queried for investigation, summarize anonymized aggregates and do not paste customer domains, client IDs, emails, or other identifiers into code or responses.
- `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"`.
- 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`.
- `apps/api`: Elysia API on port `3001`
- `apps/slack`: Slack agent adapter; Slack installs must resolve through org-scoped DB integration records, not a single env bot token/default website. Agent calls must use an encrypted per-integration Databuddy API key secret as a normal bearer token, never a global internal secret.
Expand Down
2 changes: 1 addition & 1 deletion apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
"@orpc/openapi": "^1.14.0",
"@orpc/server": "^1.14.0",
"@orpc/zod": "^1.14.0",
"ai": "^6.0.154",
"ai": "^6.0.188",
"autumn-js": "catalog:",
"bullmq": "^5.66.5",
"dayjs": "^1.11.19",
Expand Down
1 change: 1 addition & 0 deletions apps/api/src/routes/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -804,6 +804,7 @@ export const agent = new Elysia({ prefix: "/v1/agent" })
const config = createAgentConfig(
{
userId,
organizationId: organizationId ?? undefined,
websiteId: body.websiteId,
websiteDomain: domain,
timezone,
Expand Down
97 changes: 91 additions & 6 deletions apps/dashboard/app/(main)/insights/_components/insight-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import {
changePercentChipClassName,
formatSignedChangePercent,
} from "@/lib/insight-signal-key";
import type { Insight, InsightType } from "@/lib/insight-types";
import type { Insight, InsightAction, InsightType } from "@/lib/insight-types";
import { cn } from "@/lib/utils";
import {
ArrowRightIcon,
Expand Down Expand Up @@ -375,6 +375,52 @@ function InsightCardPanel({
);
}

const ACTION_ICONS: Record<InsightAction["type"], ReactNode> = {
fix_goal: <BugIcon className="size-3" weight="duotone" />,
create_funnel: <ChartLineUpIcon className="size-3" weight="duotone" />,
add_custom_event: <LightningIcon className="size-3" weight="fill" />,
create_annotation: (
<LightbulbFilamentIcon className="size-3" weight="duotone" />
),
update_config: <GaugeIcon className="size-3" weight="duotone" />,
add_tracking: <LightningIcon className="size-3" weight="fill" />,
investigate_further: <ArrowRightIcon className="size-3" weight="fill" />,
code_fix: <RocketIcon className="size-3" weight="duotone" />,
};

function InsightActionPill({ action }: { action: InsightAction }) {
const handleClick = async () => {
if (
(action.type === "code_fix" || action.type === "investigate_further") &&
action.params.prompt
) {
try {
await navigator.clipboard.writeText(action.params.prompt);
toast.success(
action.type === "code_fix"
? "Copied to clipboard -- paste in Cursor or Claude Code"
: "Copied investigation prompt"
);
} catch {
toast.error("Could not copy to clipboard");
}
return;
}
toast.info(`${action.label}`);
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
};

return (
<button
className="inline-flex items-center gap-1 rounded-md border border-border/60 bg-background px-2 py-1 text-foreground/80 text-xs transition-colors hover:bg-accent hover:text-foreground"
onClick={handleClick}
type="button"
>
{ACTION_ICONS[action.type]}
{action.label}
</button>
);
}

function InsightCopy({ view }: { view: InsightCardViewModel }) {
return (
<>
Expand All @@ -387,6 +433,38 @@ function InsightCopy({ view }: { view: InsightCardViewModel }) {
</p>
</section>

{view.rootCause && (
<section className="space-y-1.5">
<p className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
Root cause
</p>
<p className="text-pretty text-[13px] text-foreground/85 leading-relaxed">
{view.rootCause}
</p>
</section>
)}

{view.investigationEvidence.length > 0 && (
<section className="space-y-1.5">
<p className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
Evidence
</p>
<ul className="space-y-1">
{view.investigationEvidence.map((e, i) => (
<li
className="flex gap-2 text-[13px] text-muted-foreground leading-relaxed"
key={`ev-${i}`}
>
<span className="shrink-0 text-muted-foreground/50">
&bull;
</span>
<span>{e.description}</span>
</li>
))}
</ul>
</section>
)}

{view.nextStep && (
<section className="space-y-1.5 rounded-lg border border-border/60 bg-accent/40 p-3">
<div className="flex items-center gap-2">
Expand All @@ -401,23 +479,30 @@ function InsightCopy({ view }: { view: InsightCardViewModel }) {
<p className="text-pretty pl-6 text-foreground/85 text-xs leading-relaxed">
{view.nextStep}
</p>
{view.actions.length > 0 && (
<div className="flex flex-wrap gap-1.5 pt-1.5 pl-6">
{view.actions.map((action, i) => (
<InsightActionPill action={action} key={`action-${i}`} />
))}
</div>
)}
</section>
)}
</>
);
}

function InsightEvidence({ view }: { view: InsightCardViewModel }) {
if (view.evidence.length === 0) {
function InsightMetricsSection({ view }: { view: InsightCardViewModel }) {
if (view.metrics.length === 0) {
return null;
}

return (
<section className="space-y-2">
<p className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
Evidence
Metrics
</p>
<InsightMetrics metrics={view.evidence} />
<InsightMetrics metrics={view.metrics} />
</section>
);
}
Expand Down Expand Up @@ -645,7 +730,7 @@ export function InsightCard({

<InsightCardPanel expanded={expanded}>
<InsightCopy view={view} />
{!isCompact && <InsightEvidence view={view} />}
{!isCompact && <InsightMetricsSection view={view} />}
<InsightCardActions
comparisonWindow={comparisonWindow}
feedbackVote={feedbackVote}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ describe("insight card view model", () => {
expect(view.headline).toBe("Interactions got slower");
expect(view.metaLabel).toBe("Marketing");
expect(view.primaryActionLabel).toBe("Review speed");
expect(view.evidence[0]?.label).toBe("Interaction delay");
expect(view.metrics[0]?.label).toBe("Interaction delay");
});

it("falls back to domain and default action when needed", () => {
Expand All @@ -41,4 +41,28 @@ describe("insight card view model", () => {
expect(view.metaLabel).toBe("databuddy.cc");
expect(view.primaryActionLabel).toBe("Open analytics");
});

it("keeps investigation evidence separate from metric evidence", () => {
const view = toInsightCardViewModel({
...baseInsight,
rootCause: "The homepage script bundle delayed hydration.",
evidence: [
{
description: "LCP moved after the new checkout banner shipped.",
type: "deploy_correlation",
},
],
});

expect(view.rootCause).toBe(
"The homepage script bundle delayed hydration."
);
expect(view.investigationEvidence).toEqual([
{
description: "LCP moved after the new checkout banner shipped.",
type: "deploy_correlation",
},
]);
expect(view.metrics[0]?.label).toBe("Interaction delay");
});
});
18 changes: 15 additions & 3 deletions apps/dashboard/app/(main)/insights/lib/insight-card-view-model.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import type { Insight, InsightMetric, InsightType } from "@/lib/insight-types";
import type {
Insight,
InsightAction,
InsightEvidence,
InsightMetric,
InsightType,
} from "@/lib/insight-types";

const DEFAULT_PRIMARY_ACTION_LABEL = "Open analytics";

Expand All @@ -25,22 +31,28 @@ const PRIMARY_ACTION_LABELS: Partial<Record<InsightType, string>> = {
};

export interface InsightCardViewModel {
evidence: InsightMetric[];
actions: InsightAction[];
headline: string;
investigationEvidence: InsightEvidence[];
metaLabel: string;
metrics: InsightMetric[];
nextStep: string;
primaryActionLabel: string;
rootCause: string | null;
whyItMatters: string;
}

export function toInsightCardViewModel(insight: Insight): InsightCardViewModel {
return {
evidence: insight.metrics ?? [],
actions: insight.actions ?? [],
headline: insight.title,
investigationEvidence: insight.evidence ?? [],
metaLabel: insight.websiteName ?? insight.websiteDomain,
metrics: insight.metrics ?? [],
nextStep: insight.suggestion,
primaryActionLabel:
PRIMARY_ACTION_LABELS[insight.type] ?? DEFAULT_PRIMARY_ACTION_LABEL,
rootCause: insight.rootCause ?? null,
whyItMatters: insight.description,
};
}
Loading
Loading