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
e658176
feat(web): add insights-visual flag and pure models for the loop and …
mthines Oct 21, 2026
ba9f735
feat(web): render the visual insights page behind insights-visual
mthines Oct 21, 2026
918d3d5
feat(web): stories, empty states and lint cleanup for the visual insi…
mthines Oct 21, 2026
8977a71
fix(web): lay the recall flow out at its measured width, plus a simpl…
mthines Oct 21, 2026
591cd45
docs: list the insights-visual flag and its files
mthines Oct 21, 2026
96dce3c
fix(web): address review findings on the visual insights page
mthines Oct 21, 2026
6e64825
feat(web): readable swimlanes and recall flow, neutral deltas, bounde…
mthines Oct 21, 2026
9c4652e
address review comment: only a capped current window marks Learn/Reca…
mthines Oct 21, 2026
b367fcc
address review comment: emit a typed analytics event from the recall …
mthines Oct 21, 2026
736e1cf
chore: simplify pass (mechanical refactors)
mthines Oct 21, 2026
ef316a0
address review comment: pluralise the Promote stage's specialist caption
mthines Oct 21, 2026
dab3b38
address review comment: keep a run that starts at the window's end in…
mthines Oct 21, 2026
638ce58
address review comment: document insights.recall_flow_view_changed an…
mthines Oct 21, 2026
e9ab8ec
address review comment: stop the recall flow middle-column label budg…
mthines Oct 21, 2026
332d493
chore(web): update Storybook visual baselines
github-actions[bot] Oct 21, 2026
05bb828
Merge origin/main into feat/insights-visual-v1
mthines Oct 21, 2026
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
6 changes: 3 additions & 3 deletions docs/feature-flags.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,9 @@ it can never ship a flag that silently resolves to `undefined`.

> **Every `example-*` key in this document is illustrative, not a real flag.**
> Grep for one and you will find only this file. The registry holds exactly the
> flags that gate live behaviour — today three rollout gates, all plain booleans:
> `retention-policies`, `lore-explorer-instruments` and
> `lore-explorer-duplicate-clusters`. It has also held entries since removed: the
> flags that gate live behaviour — today four rollout gates, all plain booleans:
> `retention-policies`, `lore-explorer-instruments`,
> `lore-explorer-duplicate-clusters` and `insights-visual`. It has also held entries since removed: the
> rollout gate for the `/insights` route (deleted once that page became
> unconditional — see docs/decisions.md) and five demonstration entries (an
> A/B experiment, two unread toggles, and one each to exercise the `string`
Expand Down
1 change: 1 addition & 0 deletions docs/key-files.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ else. Grouped loosely by subsystem in migration/PR order.
| `supabase/functions/memories/handlers/clusters.ts` | `GET /memories/clusters` — near-duplicate clusters for the Explorer's Duplicate Clusters panel. **Two-phase by design** (the `GET /relevant` precedent): Postgres SELECTs the newest `CANDIDATE_LIMIT` visible rows, TypeScript does the set-relative work, so there is no plpgsql clustering and no migration. Read-only: it never merges, edits or deletes. The response echoes `candidate_limit` beside `candidates` so a caller can detect a saturated window (`candidates === candidate_limit`) instead of hardcoding the cap — the panel's footnote and the "run `lorekit dedupe` for the whole store" pointer both hang off that. Takes the reject-only `parseScopeFilter` (a scope filter IS the question here, and `memories.scope` is stored verbatim), pinned per handler by `scope-filter-validation.spec.ts`. REST-only (`telemetry-vocabulary.ts`), reporting as `memory.clusters` |
| `packages/web/src/lib/duplicate-clusters-view.ts` | The pure half of Duplicate Clusters: stable `clusterId`/`memberLabel` identities, `findCluster` (resolve the held selection by VALUE so a refetch cannot silently swap what you are reading), `sizeLabel`, the three honest labels `similarityLabel` ("linked at …", never "every pair is at least"), `recurrenceLabel` (a partial class match is labelled partial) and `clustersSummary` + `windowSaturated` (an empty result over a FULL window is not a clean bill of health), and `lessonEntryFromClusterMember` (turns a cluster member into the `LessonEntry` shape `seedOptimisticLesson` writes into the shared `['lesson-by-ref', scope, key]` cache, the mechanism behind showing cluster members immediately on selection). Co-located `.spec.ts` |
| `packages/web/src/components/lore/DuplicateClustersPanel.tsx` (+ `.on.tsx`, `.off.tsx`) and `DuplicateClustersSidebarPanel.tsx` (+ `.on.tsx`, `.off.tsx`) | Two independent `lore-explorer-duplicate-clusters` gates — one per render site (trigger, sidebar) — and the repo's LIVE example of the copy-and-suffix convention (`packages/feature-flags/CLAUDE.md`; the previous example was deleted for gating nothing). Each resolver dispatches on the variant KEY — never the resolved value — and its `default` arm lands on `off`, so a stale override cookie or a renamed variant fails closed on a surface that issues a quadratic-in-the-worst-case read. Two properties worth preserving on both: the `off` arm is a whole component returning `null` (retiring the flag is "delete these files", a complete instruction), and the ARM is separate from the IMPLEMENTATION (`.on.tsx` renders `DuplicateClusters.tsx` / `DuplicateClustersSidebar.tsx`, which own the behaviour, stories and pixel baselines) — so the flag's blast radius stays small files and `LoreExplorer.tsx` carries no flag read at all |
| `packages/web/src/components/insights/InsightsView.tsx` (+ `.on.tsx`, `.off.tsx`) and `components/insights/visual/` | The `insights-visual` resolver (copy-and-suffix, `default` lands on `off`) and the visual `/insights` page it gates. `off` renders the original `InsightsPage` unchanged; `on` renders `InsightsVisualPage`: ONE page-wide window, the Learn → Recall → Promote → Prune strip, the Pulse timeline (written / chosen / bulk as small multiples on a shared axis — never stacked, chosen would be a sliver), the recall flow (client → tool → scope type, calls in every band) and runs as a weekday × hour rhythm plus swimlanes. Pure models with specs: `lib/insights-loop.ts` (stage figures, pulse buckets, the `max_rows` truncation check), `lib/flow-layout.ts` (hand-rolled layered layout, one scale for every column), `lib/recall-flow.ts`, `lib/runs-rhythm.ts` (runs, not hourly activity, because hourly rows per scope pass PostgREST's 1000-row cap silently). Data: `lib/queries/insights-loop.ts` — existing routes only, current + preceding window |
| `packages/web/src/components/lore/DuplicateClusters.tsx` | The compact TRIGGER — a single-row toolbar button, not the panel itself (see `docs/decisions.md`: an Explorer PANEL entry point, not an instrument — a grouping over lesson bodies is not a `?filters=` dimension). Opens COLLAPSED and the disclosure (`PREFERENCE_KEYS.explorerClustersOpen`, lifted to `LoreExplorer`) IS the React Query `enabled` flag it shares with `DuplicateClustersSidebar`, so a folded trigger issues no clustering request at all — the load-bearing part, pinned by the `FoldedMeansNotFetched` interaction test. Only renders the summary badge once opened at least once this session |
| `packages/web/src/components/lore/DuplicateClustersSidebar.tsx` | The non-modal SIDEBAR the trigger reveals — a flex sibling of the Explorer's results column (never `position:fixed`, no backdrop), so a reader keeps clicking lesson rows while it is open. One `role="radiogroup"` of clusters; picking one hands the whole `DuplicateCluster` back to `LoreExplorer` BY VALUE (re-picking the held one clears the selection), which is what swaps the Explorer's own results list to that cluster's members (`renderResults`) instead of a nested detail pane — opening a member is the same click as opening any other lesson. Read-only, and its footnote says so plus which question a saturated window answered |
| `packages/web/src/lib/explorer-instruments.ts` | The pure model behind the Explorer's instrument panel, and the place the rule lives: **an instrument is a filter INPUT, not a view** — every selection it makes is written to the same `?filters=` / `?range=` state the menu and the date picker write, so the list stays the single output. The test that keeps analytics on `/insights` rather than creeping in here: *can you click it and end up holding lore?* `MATRIX_AXES` is derived from `FILTER_FIELDS` (minus `pr`, whose per-value cardinality makes a grid of one-cell columns), so a dimension the bar can filter by is exactly one the matrix can pivot on. `buildMatrixGrid` decides every visible property of the grid — which values appear, in what order, how many were dropped; `heatStep` roots the ratio because memory counts are heavily skewed and a linear ramp paints the whole tail the same near-black; `dayIndexAt` and `brushRange` are the whole of a pointer gesture's correctness |
Expand Down
6 changes: 4 additions & 2 deletions docs/otel.md
Original file line number Diff line number Diff line change
Expand Up @@ -1046,14 +1046,16 @@ Three of these are load-bearing in a way a name does not convey:

| Event | Emitted when | Attributes |
|-------|--------------|------------|
| `insights.range_changed` | One of the page's two independent windows moved | `lorekit.insights.range.section` (`agent-activity`/`scope-consumption`), `.preset`, `.span_days` (optional) |
| `insights.range_changed` | An Insights window moved (`page` = the visual page's one window) | `lorekit.insights.range.section` (`agent-activity`/`scope-consumption`/`page`), `.preset`, `.span_days` (optional) |
| `insights.utility_quadrant_selected` | A utility quadrant was opened or closed | `lorekit.insights.utility.quadrant`, `.selected` |
| `insights.utility_prompt_copied` | A grooming prompt reached the clipboard | `lorekit.insights.utility.quadrant`, `.entry_count`, `.succeeded` |
| `insights.scope_opened` | A row handed off to the Explorer | `lorekit.insights.scope.type`, `.source` |
| `insights.recall_flow_view_changed` | The visual page's recall flow swapped between chart and table | `lorekit.insights.recall_flow.view` (`chart`/`table`) |

`insights.range_changed` names its SECTION because the page deliberately carries
two independent windows (see `InsightsPage.tsx`); a bare preset could not say
which one a reader moved. `insights.scope_opened` is the only evidence that the
which one a reader moved. The flag-gated visual page (`insights-visual`) has a
single window, reported as `page`. `insights.scope_opened` is the only evidence that the
leaderboard's and the grid's "pick a row to open it in the Explorer" promise is
ever taken.

Expand Down
18 changes: 18 additions & 0 deletions packages/feature-flags/generated/flags.manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,24 @@
"lore-explorer",
"rollout"
]
},
{
"key": "insights-visual",
"description": "Insights — the visual first version of `/insights`: ONE page-wide window, the Learn → Recall → Promote → Prune loop strip, the Pulse timeline (lessons written and records read on a shared time axis, chosen vs bulk), the recall flow (client → tool → scope type), and runs drawn as a weekday × hour rhythm plus swimlanes — with the existing 'earning its place' grid, runs list and operational health kept below. Gates the whole page body through the `InsightsView` resolver; with it off `/insights` renders exactly as before. Reads only routes that already ship (`/memories/activity`, `/read-activity`, `/usage`, `/usage/runs`, `/utility`), so there is no backend prerequisite and flipping it is a pure UI decision.",
"type": "boolean",
"variants": {
"off": false,
"on": true
},
"defaultVariant": "off",
"experiment": null,
"owner": "@lorekit/web",
"tags": [
"dashboard",
"web",
"insights",
"rollout"
]
}
]
}
7 changes: 5 additions & 2 deletions packages/feature-flags/src/generated/flags.generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,15 @@
export type FlagKey =
| 'retention-policies'
| 'lore-explorer-instruments'
| 'lore-explorer-duplicate-clusters';
| 'lore-explorer-duplicate-clusters'
| 'insights-visual';

/** Flag key -> its evaluated value type. */
export interface FlagValueMap {
'retention-policies': boolean;
'lore-explorer-instruments': boolean;
'lore-explorer-duplicate-clusters': boolean;
'insights-visual': boolean;
}

/** The value type `evaluateFlag(key, ...)` resolves to for a given key. */
Expand All @@ -24,5 +26,6 @@ export type FlagValue<K extends FlagKey> = FlagValueMap[K];
export const FLAG_KEYS: readonly FlagKey[] = [
"retention-policies",
"lore-explorer-instruments",
"lore-explorer-duplicate-clusters"
"lore-explorer-duplicate-clusters",
"insights-visual"
];
10 changes: 10 additions & 0 deletions packages/feature-flags/src/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,16 @@ const REGISTRY_INPUT = [
owner: '@lorekit/web',
tags: ['dashboard', 'web', 'lore-explorer', 'rollout'],
},
{
key: 'insights-visual',
description:
"Insights — the visual first version of `/insights`: ONE page-wide window, the Learn → Recall → Promote → Prune loop strip, the Pulse timeline (lessons written and records read on a shared time axis, chosen vs bulk), the recall flow (client → tool → scope type), and runs drawn as a weekday × hour rhythm plus swimlanes — with the existing 'earning its place' grid, runs list and operational health kept below. Gates the whole page body through the `InsightsView` resolver; with it off `/insights` renders exactly as before. Reads only routes that already ship (`/memories/activity`, `/read-activity`, `/usage`, `/usage/runs`, `/utility`), so there is no backend prerequisite and flipping it is a pure UI decision.",
type: 'boolean',
variants: { off: false, on: true },
defaultVariant: 'off',
owner: '@lorekit/web',
tags: ['dashboard', 'web', 'insights', 'rollout'],
},
] satisfies unknown[];

function parseRegistry(input: unknown): readonly FlagDefinition[] {
Expand Down
9 changes: 7 additions & 2 deletions packages/web/src/app/(dashboard)/insights/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { Metadata } from 'next';
import { Suspense } from 'react';
import { InsightsPage, InsightsPageSkeleton } from '@/components/insights/InsightsPage';
import { InsightsPageSkeleton } from '@/components/insights/InsightsPage';
import { InsightsView } from '@/components/insights/InsightsView';

export const metadata: Metadata = { title: 'Insights' };

Expand All @@ -13,12 +14,16 @@ export const metadata: Metadata = { title: 'Insights' };
*
* Suspense is required, not decorative: InsightsPage's RunsList/queries read
* client-side state that opts the subtree out of static rendering.
*
* `InsightsView` is the `insights-visual` flag resolver: off renders the
* original `InsightsPage`, on renders the visual page. One skeleton serves both
* arms — they share the same outer layout.
*/
export default function Page() {
return (
<div className="flex max-w-page flex-col gap-6">
<Suspense fallback={<InsightsPageSkeleton />}>
<InsightsPage />
<InsightsView />
</Suspense>
</div>
);
Expand Down
28 changes: 22 additions & 6 deletions packages/web/src/components/dashboard/StatCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,16 +56,32 @@ export function UnitTag({ label }: { label: string }) {
* assistive tech — the same two-node pattern `AnimatedNumber` uses, for the same
* reason. Below the threshold there is no twin, so nothing changes for the
* overwhelming majority of chips.
*
* ## `tone="neutral"` when up is not "good"
*
* Green-for-up says more is better. For a figure where neither direction is a
* verdict — more pruning, more reads — `neutral` keeps the arrow and the sign
* (direction stays readable without colour) and drops the green/red judgment.
*/
export function TrendChip({ changePct, title }: { changePct: number; title: string }) {
export function TrendChip({
changePct,
title,
tone = 'semantic',
}: {
changePct: number;
title: string;
tone?: 'semantic' | 'neutral';
}) {
const dir = changePct > 0 ? 'up' : changePct < 0 ? 'down' : 'flat';
const Icon = dir === 'up' ? TrendingUp : dir === 'down' ? TrendingDown : Minus;
const color =
dir === 'up'
? 'text-[var(--color-success)]'
: dir === 'down'
? 'text-[var(--color-error)]'
: 'text-[var(--color-content-tertiary)]';
tone === 'neutral' && dir !== 'flat'
? 'text-[var(--color-content-secondary)]'
: dir === 'up'
? 'text-[var(--color-success)]'
: dir === 'down'
? 'text-[var(--color-error)]'
: 'text-[var(--color-content-tertiary)]';

const abbreviated = isPercentDeltaAbbreviated(changePct);
const exact = `${changePct > 0 ? '+' : ''}${changePct}%`;
Expand Down
14 changes: 14 additions & 0 deletions packages/web/src/components/insights/InsightsView.off.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
'use client';

/**
* The `off` arm of `insights-visual` — the original Insights page, unchanged.
*
* A whole component rather than an inlined `<InsightsPage />` in the resolver,
* so retiring the flag is "delete this file" — a complete instruction.
*/

import { InsightsPage } from './InsightsPage';

export function InsightsViewOff() {
return <InsightsPage />;
}
15 changes: 15 additions & 0 deletions packages/web/src/components/insights/InsightsView.on.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
'use client';

/**
* The `on` arm of `insights-visual` — the visual page.
*
* Deliberately thin: the behaviour, stories and pixel baselines live in
* `visual/InsightsVisualPage.tsx`, so retiring the flag never touches the
* component under test.
*/

import { InsightsVisualPage } from './visual/InsightsVisualPage';

export function InsightsViewOn() {
return <InsightsVisualPage />;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import type { Meta, StoryObj } from '@storybook/react';
import { expect, waitFor, within } from 'storybook/test';
import { InsightsView } from './InsightsView';
import { FROZEN_NOW } from '@/mocks/memories';
import { withQueryClient, withFrozenClock, withFlagVariants } from '@/mocks/decorators';
import { visualHandlers } from './visual/insights-visual.fixtures';

/**
* The FLAG gate on the `/insights` page body — `InsightsView` is the
* copy-and-suffix resolver for `insights-visual`.
*
* What is being guarded: the flag ships OFF, so the off arm must be the
* original page, untouched, and an unknown variant (a stale override cookie,
* a renamed variant) must land on it too. Each arm is identified by a control
* only it renders: the original page's "Agent activity time range" picker, and
* the visual page's "The learning loop" strip.
*/
const meta: Meta<typeof InsightsView> = {
title: 'Pages/Insights/InsightsView/Tests',
component: InsightsView,
tags: ['test'],
parameters: {
chromatic: { disableSnapshot: true },
layout: 'fullscreen',
// A superset of both arms' endpoints, so either page renders against data.
msw: { handlers: visualHandlers() },
nextjs: { appDirectory: true },
},
decorators: [withFrozenClock(FROZEN_NOW), withQueryClient],
};

export default meta;
type Story = StoryObj<typeof InsightsView>;

async function expectOriginalPage(canvasElement: HTMLElement) {
const canvas = within(canvasElement);
await waitFor(() => expect(canvas.getByRole('radiogroup', { name: 'Agent activity time range' })).toBeInTheDocument());
await expect(canvas.queryByRole('region', { name: 'The learning loop' })).toBeNull();
await expect(canvas.queryByRole('radiogroup', { name: 'Insights time range' })).toBeNull();
}

export const OffRendersTheOriginalPage: Story = {
decorators: [withFlagVariants({ 'insights-visual': 'off' })],
play: async ({ canvasElement }) => expectOriginalPage(canvasElement),
};

export const OnRendersTheVisualPage: Story = {
decorators: [withFlagVariants({ 'insights-visual': 'on' })],
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await waitFor(() => expect(canvas.getByRole('region', { name: 'The learning loop' })).toBeInTheDocument());
await expect(canvas.getByRole('radiogroup', { name: 'Insights time range' })).toBeInTheDocument();
await expect(canvas.queryByRole('radiogroup', { name: 'Agent activity time range' })).toBeNull();
},
};

export const UnknownVariantFallsBackToTheOriginalPage: Story = {
decorators: [withFlagVariants({ 'insights-visual': 'bogus' })],
play: async ({ canvasElement }) => expectOriginalPage(canvasElement),
};
35 changes: 35 additions & 0 deletions packages/web/src/components/insights/InsightsView.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
'use client';

/**
* The flag resolver for the `/insights` page body (`insights-visual`).
*
* The route imports only this file, so `page.tsx` carries no flag read and
* nothing to untangle when the rollout ends — the copy-and-suffix convention
* (`packages/feature-flags/CLAUDE.md`, live example
* `components/lore/DuplicateClustersPanel{,.on,.off}.tsx`). Each arm is a whole
* standalone component; this dispatches on the variant KEY, never the value.
*
* Retiring the flag: promote `.on`'s body here (or re-export
* `InsightsVisualPage`), delete `.off`, delete this `switch`, drop the registry
* entry. The original `InsightsPage` can go too once `InsightsPageSkeleton`
* (still imported by `page.tsx` as the Suspense fallback) has moved out of it.
*
* `default` is OFF: an unknown variant (a stale override cookie, a renamed
* variant) must land on the page every reader already knows, matching the
* registry's `defaultVariant: 'off'` so the two cannot disagree.
*/

import { useFeatureFlagVariant } from '@/components/providers/FeatureFlagsProvider';
import { InsightsViewOff } from './InsightsView.off';
import { InsightsViewOn } from './InsightsView.on';

export function InsightsView() {
const variant = useFeatureFlagVariant('insights-visual');
switch (variant) {
case 'on':
return <InsightsViewOn />;
case 'off':
default:
return <InsightsViewOff />;
}
}
Loading
Loading