Commit Graph
756 Commits
Author SHA1 Message Date
Sergey KozyrenkoandClaude Fable 5 eaf194cb89 fix(docker): allocate free host ports for sandboxes instead of deriving them from the flow id
Primary sandbox host ports were computed as
28000 + (flowID*containerPortsNumber + i) % limitContainerPortsNumber, so any two
flows whose ids are congruent mod (limitContainerPortsNumber/containerPortsNumber)
mapped to the same host ports. The second container then failed to start with
"port is already allocated" while its flow row lingered in "created" — and the
collision also crossed compose stacks sharing one docker daemon (e.g. flow 2 vs
flow 90002 both on 28004/28005).

Reserve free OS ports at bind time and hold the reservations open until docker
takes over, so two concurrent flows can never pick the same port. The agent
prompt now reads the container's actually-bound host ports back from docker
(correct after a restart too), keeping the deterministic set only as a
host-network / inspect-failure fallback. On a container-start failure the flow is
now marked failed instead of left stuck in "created".

Regression test: two flows N and N+period now get distinct free ports.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 15:39:51 +07:00
Sergey KozyrenkoandClaude Fable 5 2d1e677e75 fix(e2e): match the stand-smoke URL by pathname, not a string-built regex
new RegExp(route.replace(/\//g,'\\/')) tripped CodeQL
js/incomplete-sanitization (only slashes escaped). The routes are
constants, so it was a false positive — but the predicate form is
cleaner and asserts the exact pathname.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 06:24:54 +07:00
Sergey KozyrenkoandClaude Fable 5 6426bb9923 fix(e2e): don't disable TLS validation in committed code
schema-compat set NODE_TLS_REJECT_UNAUTHORIZED=0 process-wide (CodeQL
js/disabling-certificate-validation, high). A real stand has a valid
cert; only the local self-signed Tier-2 stack needs it, so the operator
now opts in via their own shell env, never in code.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 06:20:53 +07:00
Sergey KozyrenkoandClaude Fable 5 419c56317a feat(e2e): phase 3 infra — schema-compat, trends, diff-scoping, stand job
Lays the Phase-3 substrate on top of the three tiers.

- schema-compat pre-flight (e2e/tools/schema-compat.mjs): introspects a
  target backend's live GraphQL schema and validates every frontend
  operation against it, so deploy skew (a renamed/removed field) fails
  once, readably, instead of as dozens of red specs. Verified against the
  live Tier-2 backend both ways: 105 operations pass, an injected bogus
  field is caught with the exact location.
- trend aggregation (trend.mjs): turns a run's results.json into one JSONL
  record (p50/p95 spec duration, slowest three, pass/flaky/fail) so slow
  regressions are visible, not just green/red; CI appends it to a
  90-day-retained artifact.
- diff-scoping (affected-routes.ts + affected.ts): maps a diff to the
  manifest routes it touches via each route's owning sources — the
  substrate for selective runs and for scoping the exploratory agent.
  Pure mapping fn, unit-tested (backend-only → none; shared infra → all;
  owned → that route).
- CI: an e2e-stand job (label-gated + a protected Environment whose
  reviewers approve before secrets are exposed; schema-compat runs first),
  a trend step, and an LLM-independent @stand smoke.
- docs: the stand tier, trend/affected tools, and the LLM advisory recipe
  (playwright-mcp + init-agents + guardrails) — the recipe the
  deterministic tiers plug into, not a bespoke bot.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 06:17:59 +07:00
Sergey KozyrenkoandClaude Fable 5 dc66898bc8 feat(e2e): visual snapshots — route×theme matrix in the pinned container
18 baselines (9 manifest routes × light/dark) compared only inside
mcr.microsoft.com/playwright:v<version>-noble, so pixels are identical on
every machine and CI; the wrapper derives the tag from the installed
@playwright/test version, making the pin drift-proof, and a CI guard
fails loudly if the workflow's container tag falls behind.

macOS hosts cannot run the visual project directly (parallel darwin
baselines) nor mount their node_modules into the container (native vite
binaries): the wrapper builds dist on the host and the container serves
it with a dependency-free static server — route mocks intercept API
calls before the network, so no proxy is needed. The xterm canvas is
masked (SwiftShader pixels are driver-dependent). Determinism proven by
back-to-back container runs. The e2e-visual CI job is advisory, never a
required check.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 04:02:29 +07:00
Sergey KozyrenkoandClaude Fable 5 1a4f603216 feat(e2e): tier 2 — real backend run driven by an OpenAI-compatible mock LLM
specs/real/** now exercise the actual agent loop end to end: create flow
-> image/language/title -> tool-call-ID sampling -> subtask_list plan ->
done barrier -> subtask_patch refine -> report_result — with messages
streamed over the real GraphQL websocket and the flow settling in
Waiting. The mock LLM is ~150 lines of Node driven by a deterministic
first-match transcript; the custom provider env seam means zero backend
changes.

The stack is fully isolated from a developer machine:
- own compose project/network/ports (8444/5433), coexists with a dev stack
- --env-file /dev/null so the developer's .env (live keys, DOCKER_HOST,
  config paths) never leaks into the e2e backend
- flow ids seeded from 90001: sandbox containers are named
  pentagi-terminal-<flowId> on the shared docker daemon, so the range
  keeps them clear of dev flows and makes runner cleanup unambiguous
  (down -v cannot remove them — the backend spawns them outside compose)
- pentagi healthcheck via busybox wget (the alpine image has no curl),
  since the base service has none and up --wait returns too early

Auth for live tiers moved to the canonical setup-project + storageState;
the localStorage seed is now mock-tier-only (a forged client session has
no cookie behind it and the first 401 wipes it). CI gains a nightly/
dispatch-only e2e-local job.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 03:54:05 +07:00
Sergey KozyrenkoandClaude Fable 5 c2577777e8 fix(editor): give the backslash/pipe property test the heavy-generative timeout
The suite's generative tests run with timeout 30000 — the new one missed
it and tripped vitest's 5s default on slower CI runners.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 03:11:04 +07:00
Sergey KozyrenkoandClaude Fable 5 6b22e28c3a fix(ci): drop the duplicate Go cache restore and pin token permissions
setup-go's cache:true already caches the build and module caches keyed
on go.sum; the extra actions/cache step restored the same read-only
~/go/pkg/mod on top and failed with "Cannot open: File exists" whenever
both caches hit. Also sets the workflow's GITHUB_TOKEN to contents:read.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 03:06:25 +07:00
Sergey KozyrenkoandClaude Fable 5 6badfb90db fix(editor): keep table cells intact when cell text ends a backslash run with a pipe
marked's cell splitter honors \| only after an odd backslash run and
truncates rows that split into extra columns, so escaping the pipe alone
in text like a\|b produced \\| — a live delimiter that dropped the
trailing cells on the next load. An odd run + pipe has no exact GFM
encoding: the serializer now pads the run by one backslash, trading a
one-character gain inside the cell for structural integrity, byte-stable
from the first save. Covered by direct round-trips and a random \/|
payload property test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 03:02:25 +07:00
Sergey KozyrenkoandClaude Fable 5 5cb0522818 feat(e2e): dashboard, settings, resources coverage + route manifest sweep
Completes the scenario-catalog coverage on the mock tier (48 specs).

- dashboard: analytics/overview tabs (lazy mount), period switch verified
  on the wire, and the per-card degradation path — a failed stats query
  renders "Couldn't load" in its own card while neighbours stay live
  (first use of error-response cassette entries)
- settings: prompts (the largest typed cassette — 15 agent + 12 tool
  prompt configs), providers empty state, create form opens
- resources: REST-seeded file tree, and the mkdir journey proving the
  full chain — REST mutation raises a world flag, the flag releases a
  resourceAdded frame, the subscription cache merge grows the tree
- e2e/routes.ts: route manifest (paths imported from src/lib/routes so
  a rename breaks compile) driving a data-driven NAV sweep over 9
  routes; entries carry owning source dirs for future diff-scoping

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 03:01:59 +07:00
Sergey KozyrenkoandClaude Fable 5 99829bff0b fix(a11y): give the built-in dialog X a name distinct from page Close buttons
The token-reveal dialog (and any dialog with an explicit Close/Cancel)
exposed two controls named "Close" — ambiguous for screen readers and
for role-based locators in strict mode. The shared X is now "Dismiss
dialog".

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 01:58:24 +07:00
Sergey KozyrenkoandClaude Fable 5 b2990538a7 feat(e2e): flow streaming/reconnect/lifecycle, entity CRUD, and cross-cutting specs
Builds on the mock gate: 26 new specs across flows, CRUD, and cross
concerns, all driven by typed cassettes against the production bundle.

- flows: no-duplicate and exact-set-disjoint message streaming across
  concurrent flows (ID sets attached to the report), reconnect that
  reconciles the missed delta exactly once, and the rename/finish/
  favorite/delete lifecycle chained mutation->subscription-frame
- mock engine: streams move to one-driver-broadcast (flowUpdated is
  opened by two providers at once; reconnect re-joins past the cursor,
  delta-only) and frames can wait on world flags
- CRUD: templates, knowledges (required-validation proves no mutation
  fired via the zero-unmatched teardown), api-tokens (inline create,
  one-time secret reveal)
- cross: themes (seeded + toggled), responsive (Pixel 7 touch profile,
  1279/1280 split boundary), a11y with a policy - critical/serious
  floor plus a committed per-route allowlist
- terminal content is asserted through the xterm buffer (the WebGL
  canvas has no DOM text): the hook now exposes the instance on its
  host element
- app fixes surfaced by the sweep: the rows-per-page select had no
  accessible name; the favorite star is useOptimistic and only
  persists via the settingsUserUpdated frame - the spec now asserts
  the real persistence path

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 01:24:18 +07:00
Sergey KozyrenkoandClaude Fable 5 bae81a9b22 chore: ignore Playwright MCP page-snapshot artifacts
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 00:31:26 +07:00
Sergey KozyrenkoandClaude Fable 5 67266c9316 feat(e2e): Playwright scaffold with a hermetic mock gate
The default tier runs the production bundle (vite build + preview)
against a cassette-driven mock of the whole API surface — GraphQL over
HTTP, graphql-transport-ws subscriptions, and REST — so the suite needs
no backend, no keys, and no secrets, and fork PRs can run it.

- mock engine: (operationName, variables) matching with sequenced
  entries and world flags (login flips /info guest->user without
  call-order coupling); unmatched calls answer 501 and fail the test,
  so nothing leaks through the vite preview proxy to a live backend
- ws mock follows the graphql-transport-ws contract the app's client
  needs: immediate ack, nothing before ack, streams stay open, delta
  cursors survive reconnects, drops use retryable close codes
- clock and timezone pinned on the mock tier: formatDate branches on
  isToday/isThisYear, so unpinned cassette dates rot within a day
- cassettes are TS modules typed against the generated GraphQL types,
  so schema/operation drift fails the existing tsc gate at compile time
- CI: fork-safe e2e.yml (read-only token, no secrets, no write steps)
  plus e2e-report.yml posting a sticky PR comment via workflow_run,
  resolving fork PRs by head SHA
- smoke specs (login redirect, form login, authenticated /flows render)
  green on the mock tier; e2e/ wired into tsc, eslint, and prettier

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 00:30:18 +07:00
Sergey KozyrenkoandClaude Opus 4.8 f3972bb9d1 fix(ui): guard error/loading surfaces and the ws reconnect tail
List pages and the flow detail page no longer replace live data with a
full-page error on a failed background refetch — the error/redirect branches
now gate on the absence of data, not on the error alone. On a cold-cache load
failure the flow detail page renders an in-page ErrorState + Retry instead of
silently redirecting to the list, while a genuine not-found still redirects.
Retrying after a failed initial load shows the spinner rather than flashing
the empty state.

Reconnect reconcile: catch the aggregate refetch rejection (the per-query
wrapper doesn't cover it) so a transient failure during the sweep can't
surface as an unhandledrejection, and re-hydrate the REST-backed resources
slot — which the observable-query sweep skips — via a ws:reconnected event.
The flow provider gates its blocking spinner and subscription teardown to the
initial load only, so a background reconcile no longer overlays the page or
bounces the live subscriptions.

Terminal: clear() cancels any in-flight chunked write so its trailing chunks
can't land after the buffer was cleared; the search Clear (X) resets only the
query and keeps the task/subtask filter.

Smaller fixes: role="alert" on ErrorState, a keyboard-operable per-agent Test
control, the correct prompt-validation Alert variant, and dashboard error
props. Shared LoadingState and DashboardError components collapse duplicated
loading/error blocks; terminal write cancellation and the Apollo cache
policies both gain test coverage.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 03:34:46 +07:00
Sergey KozyrenkoandClaude Opus 4.8 b4b640b36b fix(flows): reconcile the cache after a websocket reconnect
The GraphQL subscriptions are delta-only: the server registers a subscriber
for future events and never replays what it published while a client was
disconnected (and it drops events for a disconnected subscriber outright).
Nothing on the client refetched after the socket came back, so a websocket
drop during an active flow — a network blip, a laptop waking, a proxy timeout
— left a permanent hole in the streamed logs/messages/status until the user
manually reloaded the page.

Refetch the active queries on reconnect: graphql-ws hands `wasRetry` to the
`connected` handler, so on a retry (not the initial connect) call
`refetchObservableQueries()`, which re-runs the flow's queries and merges the
full current state back into the cache.

Verified live with a faithful drop (patched WebSocket, real `ws.close()` mid-
flow while the agent kept producing): before the fix the messages created
during the outage stayed missing after reconnect and only a reload recovered
them; with the fix they reappear automatically on reconnect, no reload. (A
CDP "offline" emulation does NOT reproduce this — it buffers the socket rather
than closing it, so use a real close when testing.) 1006 tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 15:47:30 +07:00
Sergey KozyrenkoandClaude Opus 4.8 c2287ad90c test(apollo): cover the assistant-log streaming link
Round out the subscription coverage with createStreamingLink — the piece that
coalesces streamed assistant token updates. Export it (test-only) and drive it
with a controllable source plus a mocked clock.

Locks the three behaviors that make token streaming feel right: append parts
inside the 50ms throttle window are accumulated but not re-emitted (so the UI
isn't hammered), the next emission past the window carries the full running
message rather than the latest delta, and the final non-append part flushes the
accumulated total and clears the per-id cache so the same id can stream again.
Also confirms a non-assistant-log result passes straight through untouched.

1006 tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 14:04:22 +07:00
Sergey KozyrenkoandClaude Opus 4.8 e3d9999c17 test(apollo): cover the subscription cache merge-link
The link that folds live subscription events into the Apollo cache
(updateCacheForSubscription) had no coverage — MockedProvider replaces the
whole link chain, so it can't exercise this. Export it and drive it directly
against a real InMemoryCache configured like production (flow-scoped list
fields keyed by flowId).

Locks the routing rules: *Added appends + de-dups by id, *Created prepends
(newest first — not re-sorted by id), *Deleted removes by id, *Updated merges
an entity's fields in place without reordering (and appends if it's not yet
cached). Also covers the two subtle bits: flowId variant isolation (an event
for one flow leaves another flow's list untouched) and type-tolerant id
de-dup (a numeric subscription id matches a string id from REST hydration).

Also exports createSubscriptionCacheLink for future frame-level tests. Pure
additive exports, no runtime change; app verified still loading. 1003 tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 13:30:15 +07:00
Sergey KozyrenkoandClaude Opus 4.8 c43c05c29b fix(flows): don't bounce off a working flow on a partial load error
The flow detail query runs with errorPolicy:'all', so a failed sibling field
(the flow's log lists are separate, nullable root query fields — terminalLogs,
messageLogs, etc.) surfaces an Apollo error while `flow` itself resolves fine.
Both the redirect effect and the provider's load-error toast keyed off "any
error present", so one flaky log resolver would kick the user back to /flows
and toast "Failed to load flow" over a flow that had actually loaded.

Gate both on the flow genuinely being absent (`!flowData?.flow`) instead of on
the presence of an error. A missing/invalid flow still redirects and toasts
(its non-null `flow` field propagates to a null result); a partial failure now
renders the flow and lets the affected panel show its own empty state.

Reachability confirmed from the schema (sibling log fields are `[X!]`, nullable;
`flow` is `Flow!`, non-null) — end-to-end confirmation folded into the P2-S5
live run. 996 tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 12:10:06 +07:00
Sergey KozyrenkoandClaude Opus 4.8 140578fd7c fix(terminal): stop garbling logs after a search filter is cleared
The terminal wrote incrementally by diffing on array length alone: if the
new logs were at least as long as what had been written, it appended the
tail from the old length onward. That assumes the on-screen prefix never
changes — but the same component is fed filtered subsets, so narrowing a
search then clearing it grew the array back past the filtered length and
appended the full-log tail on top of the still-visible filtered lines,
producing a scrambled, partly-duplicated buffer.

Track the exact lines last rendered and only take the append fast-path when
they are a true prefix of the new array; otherwise clear and rewrite. Pure
streaming stays incremental (no clear); any non-prefix change rewrites.

Verified with a repro test (red before, green after): full -> filtered ->
cleared restores the full set, streaming appends never clear, and a
different filter rewrites. 996 tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 11:55:28 +07:00
Sergey KozyrenkoandClaude Opus 4.8 cf68f3a675 fix(prompts): tell the user when a reset fails instead of only logging it
Resetting a prompt to its default on the prompts list swallowed a failed
delete into console.error — sonner wasn't even imported — so the row stayed
Custom with no hint anything went wrong. Toast it, matching how every other
mutation in the app reports failure.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 03:14:12 +07:00
Sergey KozyrenkoandClaude Opus 4.8 f089f01aff fix(flows): show the load error on the page instead of a stacking toast
The flows list did what the other lists used to: on a failed load the provider
fired toast.error with no id — so each repeated failure while the backend was
down stacked another copy — and the page, never reading the error, fell through
to its "No flows" empty state, indistinguishable from an empty account.

The provider already exposed flowsError; read it on the page and render the
shared ErrorState with a Try again button (refetch, now also exposed), and drop
the toast effect entirely. Flows now matches templates/knowledges/resources.

Verified live: three failed refetches leave zero stacked toasts and one error
state with the button (previously three toasts + a false empty state); clicking
Try again after recovery restores the list. 993 tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 03:02:45 +07:00
Sergey KozyrenkoandClaude Opus 4.8 33191b4c3c feat(ui): add a Try again button to the load-error state
The Empty error state named the failure but left the user stranded — the only
way back was a full browser reload. Give ErrorState an optional onRetry that
renders a "Try again" button, and wire every load-error site to re-run its own
query: templates, knowledges (the active list-or-search query), resources, the
three settings lists, and the two settings detail pages. The templates and
knowledges providers now expose refetch for it; the rest already had one.

Verified live: failing the templates query shows the error with the button,
and clicking it after the backend recovers clears the error and renders the
list — no page reload. 993 tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 02:52:30 +07:00
Sergey KozyrenkoandClaude Opus 4.8 84c55969d5 refactor(ui): toast settings mutation failures instead of a standing banner
A failed delete/create/update on the provider and API-token lists parked a
persistent destructive Alert above the table, which lingered until the next
action and, for tokens, dumped the raw Postgres constraint text into a banner.
Every other mutation in the app reports failure with a toast; these two lists
were the exception.

Toast them too — the create/update handlers already swallowed the error into a
console.error, so this is the first real feedback they give — and drop the
banner, its error state (deleteErrorMessage plus the unread useMutation error
tuples), and now-unused ErrorAlert component.

Verified live: creating a token whose name already exists surfaces "Failed to
create token" as a toast (no second token written), with no standing banner
left behind. 993 tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 02:42:28 +07:00
Sergey KozyrenkoandClaude Opus 4.8 866dac1b61 refactor(ui): render list load errors as a full Empty state, not a thin banner
A failed list query showed a one-line destructive Alert stranded at the top of
an otherwise-empty page — it read as leftover chrome rather than the page's
state. The detail pages and the route error boundary already render a failure
as a centered Empty: a large icon, a title, the message. The lists now match.

Add ErrorState (the shared Empty-error the detail pages were inlining) and use
it for the load errors on templates, knowledges, resources, and the three
settings lists, plus the two detail pages that had inlined the same markup —
eight call sites, one shape.

Resources previously only toasted its load failure from the provider while the
page kept showing "No resources yet"; it now shows the error state like the
others, and the provider drops the toast (and the now-unused sonner import).

The settings mutation-error banners stay on the inline Alert — those sit above
rendered content, where a full Empty would be wrong; they're a separate change.

Verified live: a failed templates query (GraphQL) and a failed resources fetch
(REST) both render the centered Empty error instead of a banner or a false
empty state; the healthy paths are unchanged. 993 tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 02:31:14 +07:00
Sergey KozyrenkoandClaude Opus 4.8 b54c09954c fix(dashboard): show a per-card load error instead of a banner over lying zeros
The banner told the user "something failed" but left every card showing its
value — a down backend rendered Total Cost as $0 and each chart as "No data
for this period", numbers indistinguishable from a genuinely quiet account. And
when a single stat query failed while the other eleven succeeded, the banner
alarmed over an otherwise-healthy dashboard.

Give MetricCard and ChartCard an error state alongside their loading/empty ones
— a muted "—" / "Couldn't load", quiet rather than alarming — and wire each of
the dashboard's queries to its own card and table. A failed query now says so
in its own tile; the rest keep rendering their real data. The top-of-page
banner is gone.

Verified live: failing one period query leaves that chart as "Couldn't load"
while its neighbours draw real bars; a full stats outage fills every card and
table with the quiet error state and no lying zeros; the healthy path is
unchanged. 993 tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 02:11:08 +07:00
Sergey KozyrenkoandClaude Opus 4.8 164597c77a refactor(ui): fold the duplicated load-error alerts onto one component
Five copies of the same destructive Alert — same variant, same AlertCircle,
same title-and-message shape — sat inline across the settings pages, and the
preceding load-error fix had just added four more call sites of it.

Use the shared component at all nine, and rename it to ErrorAlert on the way:
the name it shipped with an hour ago, DataLoadError, was too narrow for two of
the five, which report a failed delete rather than a failed load.

Its `message` now also accepts null, because that is what the delete-error call
sites already had in hand and what the inline JSX happily rendered as nothing.

Left as they are: the prompt-validation dialog swaps variant between success
and error and carries a structured body, and the flow-files notice is a default
Alert — a warning, not an error. Neither is this shape.

No behaviour change. Verified by re-failing the providers and prompts queries
and confirming the same alert still renders and the page still recovers;
993 tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 18:44:15 +07:00
Sergey KozyrenkoandClaude Opus 4.8 cada8ffbe8 fix(ui): tell the user when a page fails to load instead of showing "nothing here"
A backend that was down rendered as a pristine empty account: every dashboard
chart said "No data for this period" and every metric card showed 0, while the
templates and knowledges lists showed their "nothing here yet" empty states —
all indistinguishable from a genuinely empty account, with no toast, no banner
and no other hint that anything had failed.

None of the three pages read `error` from their queries. The templates and
knowledges providers destructured only `{data, loading}` and never exposed it,
and every dashboard query dropped it on the floor. A failed query leaves `data`
undefined, which the empty-state branch cannot tell apart from an empty result.

Expose `error` from the two providers and render a load-error state distinct
from the empty state: a full-page alert on the lists, a banner above the cards
on both dashboard tabs. The settings pages already did exactly this, so the
shared `DataLoadError` gives those call sites one shape instead of four copies.

Templates also gained the loading state it never had — its empty state used to
show while the very first fetch was still in flight.

Verified by failing each query at the network layer and re-running the check
that caught it: templates and knowledges now surface "Error loading ..." rather
than their empty state, and the dashboard shows the banner on both tabs against
a cold cache. Happy path unchanged; 993 tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 18:09:52 +07:00
Sergey KozyrenkoandClaude Opus 4.8 9952f7d6b0 chore(frontend): let vite preview reach the API
The proxy was only declared under `server`, so `vite preview` served the
production build with no route to the backend: every /api/v1 call 404'd and
the built app could not get past the login screen.

That left the production bundle effectively untestable, which is where
chunking — and the bug that made every route download recharts — only ever
shows up; `vite dev` does not chunk at all.

Reuse the dev proxy for preview, on VITE_PORT+100 so it cannot collide with
the dev server. Verified: preview serves on 8100 and /api/v1/info returns 200
through the proxy.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 15:44:45 +07:00
Sergey KozyrenkoandClaude Opus 4.8 6a032c74db chore: ignore the installer's log.json
`cmd/installer/wizard/logger` opens log.json from its init(), so importing the
package is enough to create one in the process's working directory. Running
`go test ./cmd/installer/...` therefore drops a log.json into every package
directory it runs from — four of them were sitting untracked in the tree,
one step away from an accidental `git add -A`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 15:44:43 +07:00
Sergey KozyrenkoandClaude Opus 4.8 fa370eacb4 perf(build): stop every route from downloading recharts and highlight.js
Loading any page — /login included — fetched the `markdown` and `charts`
chunks, ~198KB gzip that the page never used.

Vite 8 bundles with rolldown, where `manualChunks` is a compatibility shim
over code-splitting groups, and a group captures its modules' dependencies
as well as the modules themselves. So `charts` (recharts) swallowed clsx and
`markdown` (react-markdown) swallowed react/jsx-runtime — both of which every
component needs, which dragged the two chunks into the entry graph and onto
index.html's modulepreload list. Returning 'react-vendor' from manualChunks
for those modules changed nothing: the group that captured them as
dependencies won regardless of the order the branches were written in.

Use rolldown's own `codeSplitting.groups` instead and rank the shared modules
above the heavy libraries that depend on them.

/settings/account now fetches 305KB of JS rather than 502KB; /dashboard 378KB
rather than 474KB, still loading `charts` because it draws charts. Verified
against the production build served locally: 10 routes render, console clean,
charts intact, 993 tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 15:06:27 +07:00
Sergey KozyrenkoandClaude Opus 4.8 29300b4df9 fix(ui): let the list empty states centre themselves like the primitive intends
Empty declares `flex-1 … justify-center` (empty.tsx:9), but the list pages wrapped
it in a plain `flex flex-col`, so it collapsed to its natural height and hugged the
header while api-tokens — same primitive, same "nothing yet + CTA" shape — centred
in its `flex-1` wrapper. At 1920 the CTA jumped ~350px between the two pages.

The five wrappers on knowledges, templates and flows (loading + empty states) now
carry flex-1 like the eight that already did. SidebarInset is `min-h-svh flex-1
flex-col`, so the height is there to fill. Measured at 1920: knowledges and
templates now report the same 564 centre / 24px offset as api-tokens.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 12:56:46 +07:00
Sergey KozyrenkoandClaude Opus 4.8 d2489fd2c7 fix(sidebar): restore icon sizing the container cannot supply
The size-4 sweep stripped four icons the sweep had no right to touch, and they
rendered at lucide's default 24px: the avatar's UserIcon and the theme
switcher's Monitor/Sun/Moon in the sidebar user menu.

The sweep assumed every listed container sizes its icons at any depth. Only
Button/Toggle/CommandItem do, via [&_svg]. SidebarMenuButton and DropdownMenuItem
use [&>svg], which reaches a DIRECT child only — and these four sit deeper, inside
an AvatarFallback and inside TabsTrigger, so nothing sized them.

Found by measuring rendered icon width across every route with the menus open,
not by re-reading the selectors: the avatar icon was the only one a closed-menu
pass could see. Verified after: no icon inside a button or menu row renders over
16px except EmptyMedia's, which is meant to be large.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 11:24:25 +07:00
Sergey KozyrenkoandClaude Opus 4.8 9cbda709b6 refactor(ui): finish the Spinner migration, retire the Loader2 alias from JSX
The remaining 20 rendered Loader2 spinners carried a deliberate size or colour, so
the earlier pass left them. They convert cleanly after all: Loader2 is a lucide
alias of LoaderCircle (`LoaderCircle as Loader2` in lucide-react's d.ts) and that
is exactly what Spinner variant="circle" renders, while Circle merges an incoming
className over its built-in animate-spin. So each site keeps its size/colour class
and only drops the now-redundant animate-spin. Every rendered spinner in the app is
the Spinner primitive now.

flow-status-icon and flow-task-status-icon keep Loader2: there it is a value in a
Record<StatusType, { icon: LucideIcon }> map, not a rendered spinner, and Spinner
is a variant wrapper rather than a LucideIcon — it does not fit that slot.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 10:15:35 +07:00
Sergey KozyrenkoandClaude Opus 4.8 d4dabc2783 refactor(ui): adopt the Spinner primitive and drop redundant icon sizing
Two mechanical cleanups over the same files.

<Loader2 className="size-4 animate-spin"> and <Loader2 className="animate-spin">
appeared 28 times across 20 files while Spinner variant="circle" — the same
LoaderCircleIcon with animate-spin already baked in — was the idiom elsewhere.
Loader2 spinners with a deliberate size (size-3/5/6/10/16) or a colour are left
alone: Spinner sets no size of its own and would fall back to lucide's 24px.

Separately, 51 icons carried className="size-4" inside a Button, DropdownMenuItem
or AppHeaderAction, all of which already force [&_svg]:size-4 on descendants, so
the class was a no-op. Only those 51 are stripped — the sites picked by walking
each icon's real JSX ancestors through the TypeScript AST, because an indentation
heuristic mistakes multi-line opening tags for the parent. The 40 icons with no
forcing ancestor (Alert, TabsTrigger, AppHeaderTitle, a resize handle) keep their
size-4: without it they would render at lucide's 24px.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 09:36:20 +07:00
Sergey KozyrenkoandClaude Opus 4.8 5d15be2f4f refactor(flows): build the attachment chip's remove control on Button
The remove X was a raw button. It is a ghost Button now, sized to the chip's own
26px so the whole chip height is a hit target instead of the bare 14px glyph.

Negative margins keep the geometry identical: -my-[5px] and -ml-1 -mr-1.5 shrink
the margin box back to the 14px footprint the raw icon had, and [&_svg]:size-3.5
holds the X at 14px against Button's [&_svg]:size-4 descendant rule, which would
otherwise win on specificity. Measured against the old markup on the real
stylesheet: chip 26px and 107.3px wide, X 14px, X 21px from the chip's right edge
— all unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 09:35:32 +07:00
Sergey KozyrenkoandClaude Opus 4.8 733f0390e9 fix(settings): build the per-agent Test control on the Button primitive
The Test affordance in each agent accordion header was a raw span reimplementing
outline-button chrome by hand (rounded border px-2 py-1 + hover:bg-accent + a
manual disabled style). It now composes Button asChild variant="outline" size="xs"
over that span, so it inherits the same outline chrome as every other action
button.

It stays a span, not a button: AccordionTrigger already renders a button and a
button-in-button is invalid HTML — verified live, zero nested buttons in the DOM.
The disabled styling stays hand-rolled because a span can't be :disabled, and the
onClick keeps its stopPropagation so clicking Test still doesn't toggle the row.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 02:53:39 +07:00
Sergey KozyrenkoandClaude Opus 4.8 1adcbec0f9 refactor(app): unify the header save action on AppHeaderAction with a loading prop
The four detail-page saves rendered two ways: knowledge as AppHeaderAction (a
primary CTA that collapses to an icon on mobile), the other three as a secondary
FormSubmitButton. FormSubmitButton buys nothing here — all three sit in the
header, outside the form's FormProvider, so its useFormContext read returns null
and they already submit via form= + a manual loading prop. Its real value is the
in-<Form> subscription, which its 8 dialog/auth consumers keep using untouched.

AppHeaderAction gains an optional loading (icon->spinner + disable); the three
header saves become AppHeaderAction like knowledge, so all four are now the same
primary CTA that collapses to an icon-only button (aria-label preserved) below
md. type="submit" is explicit because Button defaults type to "button". Verified
at 480px: label hidden, 32px icon button, aria-label "Create"; at 1440: primary
fill, label shown.

Researched the alternatives first: React 19 useFormStatus can't read a
form=-associated button (react.dev; facebook/react#27980) and targets native
form actions not RHF; and a Radix Slot asChild compose throws on FormSubmitButton's
two-child array and can't inject the spinner past child-wins prop merging.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 02:50:53 +07:00
Sergey KozyrenkoandClaude Opus 4.8 672851ef32 fix(templates,flows): label the template title, align cosmetics across detail forms
Template title was the only single-line field with neither a FormLabel nor a
FormMessage, so "Title is required" could never surface and the field was
unlabeled while knowledge/provider label theirs. It now has both — verified live:
submitting empty shows "Title is required".

Also: descriptive placeholders on the template title and editor (matching the
sentence-style placeholders elsewhere), the Rename dropdown icon to size-4 (its
siblings are size-4), and the new-flow intro to the shared block (h2 + gap-2
instead of h1 + mt-2) so every create/edit intro is identical.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 02:37:59 +07:00
Sergey KozyrenkoandClaude Opus 4.8 0585d01bad fix(settings): match the provider combobox triggers to the select chrome
The provider Type combobox rendered as a Button outline — opaque bg-background,
a ChevronsUpDown icon and font-medium text — while every select on the knowledge
form is a transparent-bg SelectTrigger with a single ChevronDown. Side by side
the Type field read as a different control.

The combobox trigger now carries bg-transparent, px-3, font-normal and a
ChevronDown size-4, matching SelectTrigger; the model combobox's addon chevron
follows suit. Measured against the canon at 1440: transparent bg, chevron-down
16px opacity .5, h-9, px-3, weight 400 — identical.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 02:33:50 +07:00
Sergey KozyrenkoandClaude Opus 4.8 b1f77fa7dc fix(settings,templates): contain popover scroll, show the variable count on desktop
The variables and presets popovers scrolled the page once their own scroll hit
the end — overscroll-behavior defaulted to auto and a non-modal popover doesn't
lock the body. overscroll-contain keeps the momentum inside. Reproduced at
375x640: wheeling past the popover's end moved the body 0 -> 204px; with contain
it stays at 0.

The prompt variables card now shows the variable count on desktop too, matching
the preset-count badge the template card already carries.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 00:44:24 +07:00
Sergey KozyrenkoandClaude Opus 4.8 679438451f feat(templates): collapse the presets panel into a popover on mobile
Below the split the presets card was a 599px block that sat under the fold, past
the editor — on the new-template form the editor is empty and the presets are how
you fill it, so the primary tool was hidden behind a scroll.

The panel now mirrors the prompt variables one: on desktop it stays the card in
the split; below 1280 its header becomes a full-width secondary trigger with the
preset count, opening a non-modal popover that holds the same preset list. The
list is shared by both wrappers; only the wrapper differs. In the stack the
trigger takes the presets' desktop slot — after the title, before the editor.

Applying a preset from the popover closes it (the popover is controlled), then
either fills the form or raises the existing replace-confirm dialog when the form
already has content — verified live at 390: fill, replace-confirm, and expanding a
preset preview inside the popover all work, and the desktop split is unchanged.

presetsList drops its useMemo to take an onApplied callback; typing does not
re-render the form (FormField isolates it), so the 11 collapsibles are not on the
keystroke path — confirmed by zero slow input events while typing on desktop with
all of them mounted.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 00:11:08 +07:00
Sergey KozyrenkoandClaude Opus 4.8 a38e4d503c fix(settings): restore keyboard access to the variables popover
Below the split, preventing the popover's open-autofocus left focus on the
trigger while the content sat in a body portal, so the first Tab landed in the
editor and the second dismissed the popover: the chips could not be reached by
keyboard at all. Before the popover existed they were inline tabbable badges, so
this was a WCAG 2.1.1 regression.

Both autofocus handlers are gone. They were written to protect the editor caret,
but ProseMirror keeps its selection across DOM blur, so they were never needed —
verified live at 390: opening now focuses the first chip, Escape returns focus to
the trigger, and clicking a variable still inserts at the caret the user left,
with the editor focused and the popover closed.

Also stop the shared chip cloud painting bg-background over the popover: dark
--background matches the page behind it, which flattened the popover to a
borderline. The two-tone well is the desktop card's business, so the fill moved
onto its wrapper, where it still contrasts bg-card.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 23:02:24 +07:00
Sergey KozyrenkoandClaude Opus 4.8 d6af153689 style(frontend): restore prettier formatting on the Empty-migration pages
`pnpm run prettier` is a required CI step and it exits 1 on these five files at
HEAD while passing on them at 001c6c0, so the branch is currently red. Formatting
only — no logic touched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 22:53:01 +07:00
Sergey KozyrenkoandClaude Opus 4.8 6f21d90904 style(settings): outline the variables count badge and size it to 20px
The badge default height is 22px — text-xs' 16px line box, py-0.5 and a 1px
border — which crowds the 32px trigger. h-5 pins it to 20px; border-box leaves
18px of content for the 16px line box, so nothing clips.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 22:21:46 +07:00
Sergey KozyrenkoandClaude Opus 4.8 cbfad0853c fix(settings): contrast the variables count badge, scope the tab overrides to the split
The count badge sat on a bg-secondary button, where every neutral surface token
is within 1.08–1.23 contrast of it: secondary painted the pill invisible and
outline drew its border in --border, at 1.18. Only --primary separates (2.76), so
the badge is default.

The tab overrides exist because the desktop panel sits on its own surface. Below
the split they fought the shadcn defaults, so they are xl:-scoped now — the same
1280 the layout branches on. Stacked widths get bg-muted with a bg-background
active tab again; 1280+ keeps bg-background with a bg-card active tab.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 22:05:06 +07:00
Sergey KozyrenkoandClaude Opus 4.8 fae61b2607 feat(settings): show the variables count on the narrow-width trigger
The trigger is a compact secondary button carrying just its label and, on the
right, the variable count — same count-badge shape the sheet and template
headers already use.

The badge is outline, not secondary: a secondary badge paints bg-secondary onto
a bg-secondary button, which renders the count as bare text.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 21:57:37 +07:00
Sergey KozyrenkoandClaude Opus 4.8 4c1ee3177e fix(settings): make the variables header itself the narrow-width trigger
The header and its hint were rendered twice below the split breakpoint: once as
the trigger label and again at the top of the popover it opened.

The header belongs to the wrapper, not to the shared part. On desktop it stays
the card header; on narrow widths it becomes the trigger, and the popover now
carries only the chips. Title and hint move to constants so the two wrappers
cannot drift apart.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 21:51:27 +07:00
Sergey KozyrenkoandClaude Opus 4.8 f89eacb990 refactor(settings): give Badge asChild, let the variables panel own its breakpoint
Three follow-ups to the variables palette.

The chips composed badgeVariants onto a raw <button>, going around the primitive,
because Badge renders a div. Badge now accepts asChild through the same Slot
switch button.tsx already uses, so a chip is <Badge asChild><button>: the badge
keeps owning the styling, the button keeps native semantics, and the hand-rolled
role/tabIndex/Enter-Space handler stays gone. className has to stay on Badge —
Slot concatenates classes, so only cn() resolves font-normal against
badgeVariants' font-semibold.

Variables reads useBreakpoint itself rather than taking isDesktop from the page.
Every other consumer here does that, sidebar.tsx included, and it keeps the panel
droppable into the next page that needs one.

The narrow-width trigger is a full-width labelled button now. It carries no
chevron and no count badge: those, not the width, are what make a control read as
a value select.

Also size the popover by --radix-popover-trigger-width, the way autocomplete.tsx
does. It was 100vw wide and spilled across the sidebar between 768 and 1280.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 21:40:32 +07:00
Sergey KozyrenkoandClaude Opus 4.8 fed1a020c6 feat(settings): collapse the prompt variables panel into a popover on mobile
In the stacked layout the "Available variables" cloud sat between the tabs and
the editor, pushing ~530px of chips ahead of the content it annotates. The panel
now keeps its slot and its desktop rendering, and below the split breakpoint the
same cloud moves behind a { } trigger, leaving the editor at the top.

The cloud itself is shared by both wrappers; only the wrapper differs (card vs
non-modal popover). The popover must not trap focus: insert and cycle act on the
editor's stored selection, and cycling only helps if its highlight stays visible.

Chips are real buttons now — they were divs with role=button and a hand-rolled
Enter/Space handler that the native element provides for free.

Verified live at 500px: opening keeps the caret, clicking a used variable selects
the next occurrence in view, the popover closes as the editor takes focus back,
and the desktop split is unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 21:08:46 +07:00