[feat] Mobile observability surface - #5963
Conversation
WP6. Traces and sessions on /m, project-wide, built entirely from the packages WP0-WP5 extracted. There is no mobile-only rendering of a trace: TracesList is the packaged list shell over the packaged trace row, so a change to how a span reads lands on both surfaces at once. The range control is the same ObservabilityRangePicker desktop renders, not a mobile sort sheet. The original plan called for one; the chrome conversion landed the shared control first precisely so this screen would not need it. That is the whole ordering argument, and this is where it pays. Sessions take the other path, deliberately. An observability session has no non-table rendering anywhere to extract, so mobile stacks the WP3 cells in a layout it owns rather than inventing a shared row for one caller. The cells stay the single source of formatting. This is option (a) of the plan's open design question; (b) remains a design ask and nothing here forecloses it. No scope binding: the seam's defaults are already project-wide with no workflow context, so binding would only re-state them. Deliberately out of scope on v1, each an explicit non-regression rather than a silent drop, and desktop keeps all of it: CSV export, bulk delete, add to testset, add to queue, column visibility and resize, custom date range.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. 🗂️ Base branches to auto review (1)
Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Repository YAML (base), Organization UI (inherited) Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
📝 WalkthroughSummary by CodeRabbit
WalkthroughAdds mobile observability screens with trace and session tabs. Centralizes observability tables, export, and deletion workflows. Replaces the Ant Design virtual table path with a TanStack-based implementation. Adds tests, Storybook coverage, shared date-time imports, and navigation wiring. ChangesObservability platform
Virtual table engine
Shared package alignment
Estimated code review effort: 5 (Critical) | ~120 minutes Sequence Diagram(s)sequenceDiagram
participant MobileUser
participant ObservabilityScreen
participant ObservabilityTracesTable
participant useObservability
participant DeleteTraceModal
MobileUser->>ObservabilityScreen: open observability route
ObservabilityScreen->>ObservabilityTracesTable: render traces tab
ObservabilityTracesTable->>useObservability: load traces and pagination
MobileUser->>ObservabilityScreen: select traces and request deletion
ObservabilityScreen->>DeleteTraceModal: open with selected trace IDs
DeleteTraceModal->>useObservability: delete traces and refresh data
DeleteTraceModal-->>ObservabilityScreen: close modal and clear selection
✨ Finishing Touches 💡 2📝 Generate docstrings 💡
🛠️ Fix failing CI checks 💡
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Railway Preview Environment
|
The shipping table always renders `virtual`, and in that mode antd emits .ant-table-tbody-virtual-holder INSTEAD of .ant-table-body. The stamp only looked for the latter, so avt-body was absent from every table in the app while the unit tests passed against a fake keyed to the same wrong selector. Found in the browser: six of the seven hooks were on the DOM and that one was not. The stamp gets its own selector rather than widening ANTD_SELECTOR.body, because useScrollContainer reads that key and has always fallen through to the container. Widening it there would change scroll detection, which is not what this fixes. The test now models the virtual table, and keeps a case for the non-virtual one so both paths stay covered.
/m crashed on load: "dayjs(...).utc is not a function", thrown from controls.ts while evaluating DEFAULT_SORT. state/index.ts did call dayjs.extend(utc), but controls.ts computes DEFAULT_SORT at module-evaluation time, so whether .utc() exists came down to which module the bundler evaluated first. Desktop happened to win that race; mobile did not, and the screen died before it rendered. Every file now imports dayjs from @agenta/shared/utils/dateTime, which extends utc and customParseFormat at its own module scope. The plugin is therefore guaranteed present by the time any consumer runs, and the ordering-dependent extend in state/index.ts is gone. Found by loading the mobile observability screen, which is the first consumer of this package outside oss.
I built and QA'd this screen phone-only. That is wrong: /m replaces web/oss and web/ee, so every screen has to hold up at desktop widths without drifting from the app it replaces. At 1600px it rendered as two stacked rows against 1450px of dead space, with no nav rail and no page title, because it never wrapped itself in AppShell the way every other screen here does. It now follows the same shape as SessionListScreen: PageTitle, AppShell for the persistent rail at lg+, ScreenScaffold, and the NavDrawer hamburger only below lg. The body runs the full content width rather than a centred column, because the desktop table runs edge to edge and a centred measure reads as a different page. The bigger drift was hand-rolled chrome. I had written a tabs+range header while ObservabilityToolbar already existed in the package, so mobile silently lost search, Root/LLM/All, realtime and auto-refresh. It renders the shared toolbar now. Export and delete stay hidden by omitting their handlers, which is how that component already expresses a capability the host does not offer — the v1 scope is a prop, not a fork. That exposed a real bug in the toolbar: its first row never wrapped, so on a 390px viewport the range picker and auto-refresh sat off the right edge at x=390. Wrapping is correct at any width and changes nothing where there is room, so it belongs in the component rather than in a mobile override. Still drifting, and out of reach here: the desktop renders a 10-column table where this renders stacked rows. Closing that needs the table itself off antd (§8 step 3), which is why the plan scheduled it after this.
The render leaf. `<Table virtual>` was the last antd component in the package and the reason /m could not show the same table the desktop shows, so /m got a stacked list that drifted from the app it is meant to replace. VirtualTable is plain table DOM plus row windowing: - a fixed row height and a scroll offset pick the visible slice, so only that slice plus an overscan mounts. Uniform rows make this arithmetic, which is why no windowing library is pulled in. - the header is its own table above the scroller, sharing a colgroup with the body so columns cannot drift, and scrolled in step horizontally. - pinned columns are position:sticky at a computed per-column offset. - it emits the same class hooks and data-column-key attributes the package's own hooks query, which is what the step-4 work made possible. getObservabilityColumns moves to @agenta/observability-ui. It turned out to be portable already: one type import and one relative import, both of which the package now owns. That is the ColumnDef seam paying off. Both OSS call sites point at the package. /m renders those columns on VirtualTable at lg+, and keeps the stacked rows below lg where a ten-column grid does not fit. Same columns, same cells, no antd in the bundle. 13 unit tests cover the windowing and the sticky offsets: the slice at rest, mid-scroll, both clamps, the no-height case, and pinned columns stacking from each edge. Those are the parts that fail silently — a wrong slice is blank rows mid-scroll, a wrong offset is overlapping columns.
The app this was built for has three rows of data, so the behaviours that only
appear at scale or in a corner were never exercised. These stories cover them
without needing a seeded project:
- Windowing: 10,000 rows, to check the mounted row count tracks the viewport
rather than the dataset. This is the part I could not verify in the app.
- StickyColumns: two left-pinned, one right-pinned, with a wide filler forcing
horizontal scroll.
- RowSelection: the leading checkbox column pinning left, select-all, per-row.
- MergedCells: the {props, children} render shape with colSpan 3 and colSpan 0,
which was coded and typed but had never rendered.
- RowInteraction: row click against a cell button that stops propagation.
- Empty, Basic.
- AntdComparison: the same columns and rows through <Table virtual> directly
above ours, so geometry and behaviour read side by side against the thing
being replaced.
Stories that hold state are components rather than render arrows; hooks in a
story arrow break rules-of-hooks.
Also fixes domain/InfiniteVirtualTable.stories.tsx, which still typed its
columns as antd ColumnsType. That was fallout from the ColumnDef seam that
nothing caught, because the storybook workspace is not in the apps' typecheck
path.
…and-rolled code
You asked about TanStack Table, and measuring made the case better than my
reasoning had. I had hand-rolled the windowing arithmetic while
@tanstack/react-virtual was ALREADY a direct dependency of this package, and I
had written a ColumnDef type that duplicates TanStack's own.
The bigger point is where the weight sits. The rendering I wrote is ~300 lines.
The model logic already in this package is ~1,500:
useSmartResizableColumns 500 useColumnVisibility 286
useExpandableRows 284 useResizableColumns 221
useColumnVisibilityControls 98 useColumnDomRefs 79
useTableRowSelection 56 useScopedColumnVisibility 27
rc-table would have replaced the 300 and left the 1,500. TanStack Table is
headless — it ships no markup — so it replaces the 1,500 and leaves the markup,
which is the half that has to emit OUR avt-* contract and which no library can
do for us.
So: TanStack Table owns columns, visibility, sizing and selection; TanStack
Virtual owns windowing; this package owns the DOM.
tanstackColumns.ts is the whole migration cost. ColumnDef stays the shape all
82 call sites write — dataIndex / title / render — and the crossing happens in
one file, exactly as toAntdColumns did for antd. Swapping the engine must not
reach the callers, and it does not.
Virtual measures rows rather than assuming a fixed height, which removes the
uniform-row constraint that forced rowHeight={128} on /m.
Pinned to react-table v8: pnpm resolved ^9 by default, which is a rewritten API
(createCoreRowModel/ReactTable), and v8 is the mature one.
The tests move with the code. The windowing arithmetic they covered is
TanStack's problem now; what needs pinning is the adapter, so they cover
identity, plain and path accessors, a render-only column, width mapping, the
meta round-trip and group recursion.
…y found Storybook has been dying for the whole session inside webpack's FileSystemInfo._resolveContextTimestamp, hashing a context entry with no timestampHash. Bisecting the stories glob settled what it was not: with ZERO stories, and again with no addons, no docgen and no @/oss alias, a bare config still crashed. No story was ever involved. It was webpack 5.106.2. Four attempts to move it failed because pnpm 11 reads overrides from pnpm-workspace.yaml, not package.json — which is why every install answered "Already up to date". Retargeting the override there to 5.109.2 fixes it; the preview built first try. Storybook then paid for itself within a minute. The 10,000-row story mounted all 10,000: the scroller had style.height 420px but clientHeight 230000, because flex-1 beats an inline height. "flex: 1 1 0%" hands main-size calculation to the flex algorithm, which ignores "height", so the body grew to content and TanStack Virtual saw an unbounded viewport. flex-1 now applies only when no explicit height is given. 10,000 rows mount 27, and 35 at scrollTop 200000 starting from index 4175. That bug could not have been caught anywhere else: /m has three traces, so it never exceeds a viewport, and no typecheck or unit test can see a layout interaction between a Tailwind class and an inline style. All seven stories verified: windowing, sticky offsets (ID at 0, Name stacked at 200, Cost pinned right), selection wiring, merged cells (40 cells minus the 2 dropped by colSpan 0 = 38), row-click vs cell-button propagation, empty, and the antd comparison.
The new table had no `loadMore`. The observability traces list on /m was open-coding bottom-detection in its own `onScroll`, which meant every future consumer would have had to do the same, and the RAF-throttled hook already sitting next door went unused. VirtualTable now takes `loadMore` + `scrollThreshold` (default 300px) and routes them through the existing `useInfiniteScroll`, matching InfiniteVirtualTable's prop names so the eventual swap is a rename-free move. The handler is only invoked when `loadMore` is passed, so tables without it keep the exact scroll path they had. Verified in Storybook against a paging story: one scroll to the bottom loads page 2, eight scrolls reach the 200-row cap in 8 pages with no re-entrant fetches, and windowing holds throughout (27 rows mounted of 200). The Windowing story is unchanged: 35 mounted at scrollTop 200000, first row span-04175, same as before. Also uses the ROW_HEIGHT constant TracesTable declared but ignored, and reformats one pre-existing prettier failure in ChatScreen that blocked lint.
…eaks Second step of making VirtualTable a drop-in for InfiniteVirtualTable. antd owns selection internally and hands you callbacks; TanStack keeps it in a `Record<rowId, boolean>` the host controls. Same information, different shape, so `useVirtualTableRowSelection` converts between them rather than asking the existing call sites to change how they pass selection. The mapping is exact because VirtualTable's `getRowId` already stringifies `rowKey`, so a RowSelectionState key IS `String(rowKey(record))`. Covered: `selectedRowKeys`/`onChange` (which gets both keys and the matching records), `getCheckboxProps` disabling rows, `columnWidth`, `columnTitle`, `renderCell` (receiving the default control as `originNode`), `selectOnRowClick`, and `type: "radio"`. Disabled rows are filtered on every path, not just the one that renders them, so select-all and row-click can't sneak one in. Radio needs a RadioGroup ancestor and the group can't span rows here, so each row owns a one-item group and exclusivity comes from our state instead. Also fixes a missing `key` on the three conditional leading-column elements. React was warning on every table with a selection column; it predates this change and showed up because the new stories exercise that path. QA'd in Storybook: select-all picks 24 of 30 with the 6 disabled rows excluded and hands onChange 24 records; unchecking one flips the header to indeterminate; row-click toggles, and does nothing on a disabled row; radio replaces its selection instead of adding (row-1 then row-4, one checked throughout) and has no select-all header. Zero key warnings afterwards, verified per-story on a fresh mount.
theme.generated.css was already out of sync with the committed palette, which failed mobile's tokens:check and so blocked pnpm lint-fix. Regenerated with pnpm --filter @agenta/mobile generate:tokens; no palette change.
v8 was a reflex, not a decision: pnpm resolved v9, the API didn't match what I was writing, and I pinned back to ^8.21.3 instead of looking. v9.1.2 is the `latest` tag, not a prerelease. Two properties make it the right engine for this component specifically. v9 registers features as opt-in modules rather than shipping the set, so the bundle carries only what we use: this table exists so /m can replace web/oss and web/ee without antd, and unregistered features are weight it never carries. And v9 is built on TanStack Store with a `Subscribe` component for subscribing to slices of table state, where v8 re-renders the whole table on any state change. For a virtualized table holding selection and per-column sizing, that is the difference that matters. Nothing constrained the choice: @agenta/ui is the only package importing it, across three files, all written this week. Doing it now also means the column sizing work lands once, against v9, rather than being written on v8 and ported. What changed: useReactTable → useTable, with an explicit `features` object getCoreRowModel() → dropped; the core row model comes from core features VisibilityState → ColumnVisibilityState ColumnDef<T, V> → ColumnDef<TFeatures, T, V> The registered set lives in tableFeatures.ts so the column adapter and the table share one definition rather than importing each other. Re-QA'd every story against the v8 numbers; all identical. Basic 12 rows / 6 headers / 72 cells. Windowing 27 mounted of 10,000, and 35 at scrollTop 200000 starting at span-04175. Sticky offsets left 0px / 200px and right 0px. Merged cells 38 of 40, two dropped by colSpan 0. Selection: select-all takes 24 of 30 with the 6 disabled rows excluded and hands onChange 24 records, unchecking one flips the header to indeterminate. Radio replaces rather than adds, one checked throughout. Infinite loading reaches 200 rows over 8 pages with 27 mounted. Console clean.
Groundwork for moving column sizing onto TanStack. I had claimed the sizing migration would delete ~720 lines across the two resize hooks; checking that number showed useResizableColumns (221 lines) was simply dead — no importer, no barrel export, no test — and had nothing to do with TanStack at all. Sweeping the rest of the folder the same way found four more with no reference anywhere in packages, oss, ee, mobile or storybook, and no barrel export, so they are unreachable from outside the package too: useResizableColumns 221 useColumnDomRefs 79 useContainerSize 58 useTableHeaderHeight 55 useScopedColumnVisibility 27 ResizableTitle stays: useSmartResizableColumns still uses it and it is public via the barrel. A second pass found nothing newly orphaned by these removals. The real sizing work is smaller than I said and is still ahead: TanStack replaces the drag mechanics and clamping, while useSmartResizableColumns is a space-distribution algorithm (classify fixed/maxWidth/flexible, share the remainder, hold total >= containerWidth) that v9 has no equivalent for and that gets ported rather than deleted. @agenta/ui and @agenta/oss typecheck; lint green across all 25 tasks.
Step 2 of the sizing migration. The drag handle is a plain span bound to `header.getResizeHandler()` — no react-resizable, no antd. Widths live in `columnSizing`, so the host owns them and can persist them, and `minSize` (from the column's `minWidth`) does the clamping TanStack already knows how to do. `enableColumnResizing` is opt-in and `columnResizeMode` defaults to "onChange"; tables that don't ask for resizing render no handles and keep the DOM they had. Handles carry `avt-resize-handle` and `data-resize-handle="<columnId>"` so they are addressable from the same class-hook contract as the rest of the table. Verified by driving real mouse events in Storybook, not by writing widths: dragging the first handle +120 takes ID from 200 to 320, and both the header cell and the body cell report 320, which is the invariant that matters since header and body are separate tables. Dragging a middle column +60 takes Span type from 140 to 200. Dragging -800 clamps the render to 40. Handles are absent on every story that doesn't opt in. One behaviour worth knowing before the distribution algorithm consumes this: on an over-drag the persisted `columnSizing` holds the raw value (0 in the clamp test) while `getSize()` returns the clamped 40. Anything reading widths must go through `getSize()` rather than the state, and anything persisting the state can store a sub-minimum number. Known issue, not fixed here: since the v9 move, VirtualTable emits one React "unique key prop" warning per mount, including the Empty story with no rows, so it is in the header/colgroup scaffolding. It is dev-only and nothing renders wrong. I could not reproduce it in isolation — a faithful standalone repro of the same colgroup and thead, built from real v9 header groups with the exact column shape, stays silent under both SSR and client StrictMode renders. Other stories in the same Storybook do not warn, so it is specific to this component.
Step 3, and the part TanStack could not do for us. Its column sizing is per-column sizes plus a resize handler, with no notion of filling available space, so the space-sharing rules from useSmartResizableColumns were ported rather than deleted. distributeColumnWidths is now a pure function producing a ColumnSizingState, and VirtualTable applies it behind an opt-in `autoLayout` that measures its own container. The rules are the old ones on purpose, because changing them moves every table: pinned columns and capped columns are reserved first, whatever is left is shared among the rest in proportion to declared width, a drag always wins and opts a capped column out of its cap, and widths stay integers so the header colgroup and the body cannot round apart and drift. The invariant is that the total is never LESS than the container: when space runs short, columns keep their declared width and the table scrolls sideways instead of being squeezed. The width/minWidth defaults (200, and min(150, width)) are carried over verbatim, including the rule that a column narrower than the floor keeps its own smaller floor so it stays draggable. maxWidth is still read off the column rather than from ColumnDef, which is where it has always lived. Being pure, it is unit-tested rather than only clicked: 16 tests over proportional sharing, exact fill, integer output, the capped and pinned and selection-column reservations, all four drag interactions, and the edges (no columns, zero container, a leading column wider than the container). This adds vitest to the package, mirroring agenta-chat's setup. Confirmed in the browser that the live layout matches the tested rules, at four container widths: 1200 gives 120/705/235/140, 900 gives 120/480/160/140, and 700 gives 120/330/110/140, each landing exactly on the container. At 500 the columns stop shrinking, total 660, and the body scrolls.
Deleting the antd Table branch left the scroll and header lookups pointing at `.ant-table-*`, which the rendered DOM no longer contains. They resolved to null and fell through silently — the scroll container quietly became the wrong element rather than erroring. `DOM_SELECTOR` pairs each hook as `avt-*` first with the antd selector behind it, so a host still mounting an antd table through the legacy column adapter keeps working.
Six issues raised on #5954-#5958, each verified against the code first. `getNodeById` walked `Object.values(node)`, which visits every property rather than just `children`. Span metadata carries `span_id` too — an annotation span's `invocationIds` points at a different span — so a single-node lookup could return that bag instead of the span. The regression test covers exactly that shape; it passes on an array input either way, which is why the first version of it caught nothing. Both annotation queries scoped their request by `projectId` but not their cache key, so a project switch with the same links reused the previous project's annotations. The drawer store had inherited the same mistake when it stopped calling the oss wrapper that resolved the project internally. Session token and cost totals used `||` down their fallback chains, so a real incremental total of 0 fell through to the cumulative one and double-counted a span that reported no new tokens. `getOperator` can return undefined for an operator in the union but missing from OPERATORS; dereferencing `hidesValue` threw during validation and took the dialog's render with it. Plus two small ones: the docs link opened without `noopener`, and a secondary-only empty state rendered an orphaned "Or" separator.
… theme-safe Two findings from the #5958 and #5957 reviews. The date picker's calendar was reachable but not usable without a mouse: every day carried `tabIndex={-1}` and no key handler existed, so a keyboard user could open the popover and go no further. It also declared `role="grid"` while rendering one flat list of cells with no rows. It now uses a roving tabindex — one day in the tab order at a time — with arrows moving by day and by week, Home/End along the week, PageUp/PageDown by month, and Enter or Space to select. Stepping past either edge pages the view so arrows never dead-end at a boundary. The flat cell list is chunked into `role="row"` weeks, which is what makes the row-wise moves mean anything. `spanTypeStyles` mapped its colours to `--ant-*`, which antd's ConfigProvider emits at runtime. On an antd-free host those resolve to nothing, so the span chips lost their background and text colour entirely on /m. Every value is now a generated `--ag-*` token: the `preset-*` hue pairs already exist in palette.ts with their own dark values, so no new tokens were needed. The keyboard tests fail against the previous implementation — all four — which is the only reason to trust them.
…each other The review found the plan and kickoff pairs contradicting themselves in ways that would mislead whoever ran them next. D1 was presented as an open blocking choice in both documents while the code had already resolved it: the evaluator label is an injected prop (`EvaluatorMetricsCell` takes `displayName`) and `useEvaluatorReference` stays in the app. Recorded as resolved; D2 stays open, which is accurate. The shim policy was stated both ways — "leave thin re-export shims at every old path" against the correction that lint bans value re-exports from `@agenta/*` in oss and ee. The ban is the real constraint, so both documents now say rewrite the call sites and delete the old module. The rest are smaller but the same kind: `spanTypeStyles` was assigned to WP2 in one table and WP3 in another; a validation block `cd`-ed without returning, so every later command in it ran from the wrong directory; the antd gate and test commands carried a `web/` prefix inside a block that had already entered `web`; the DateRangePicker brief told the agent to add a direct dayjs dependency while the shipped component takes it through `@agenta/shared/utils/dateTime`; and `FilterTagsInput` appeared in the export handoff with no track owning it. Both chrome documents also embedded an absolute worktree path and a hard-coded branch. They now use repository-relative paths and tell the session to confirm its own worktree and branch.
…ches Review findings from #5915. `ConnectDrawer` kept its own `invalidateConnections` that hit only ["tools","connections"] and ["tools","catalog"], while the shared hook in @agenta/entities also invalidates ["triggers","connections"] — with a comment saying why: gateway_connections rows back the triggers list. So connecting a tool through the drawer left triggers stale. The shared helper is exported now and the drawer calls it, rather than keeping a copy that drifts. `assertHostQueryClient` had tests; `getHostQueryClient()` — the accessor every package write actually goes through — had none. Two tests cover it reading `queryClientAtom` and following a late swap, which is the property that makes hydrating after mount work instead of pinning the first client. The JSDoc on the assertion still described the pre-migration failure mode (writes dying on the singleton). Package writes follow the atom now, so the real failure is a host that installs one client and hydrates another: two caches, reads from one, writes to the other. The mobile comment said the same thing slightly inverted and is now precise about install-without-hydrate. The handoff embedded a branch, a worktree and an expected dirty status as preconditions. Recorded as historical instead. Not changed: the review also claimed `importNames: ["queryClient"]` misses `import * as ns`. It does not — a probe file lints as "* import is invalid because 'queryClient' ... is restricted" under ESLint 9. Three further findings (the empty-cache evidence, the "verified live" claim, the mobile comment's stale call count) were already handled in 079bc20.
…y once
The phone layout and the tablet layout were two sibling containers, one `md:hidden`
and the other `hidden md:block`. Both are only CSS-hidden, so neither unmounts, and
`{chat}` and `{pane}` each mounted twice — two conversation engines running at every
viewport.
Both widths now drive the SAME SplitPane, so each half exists once and a rotation
across the md breakpoint no longer remounts the conversation mid-stream. Expressing
"one pane takes the full width" needed a `paneGrow` prop on SplitPane: the driven
pane is `grow-0` with a controlled flex-basis, so it cannot fill on its own.
`sorted` was built as UTC and then had its designator stripped
(`toISOString().split(".")[0]`). `fetchDashboardAnalytics` reparses that string with
`dayjs()`, which reads a bare timestamp as LOCAL time, so every preset window was
skewed by the viewer's offset. The new test fails against the old code with
"expected 180 to be less than 2" — 180 minutes is exactly UTC+3.
Three call sites computed the same string by hand; they now go through
`resolveRangePreset` / `toRangeInstant`. "all time" resolved to an empty string that
the same fetch throws on, so it gets a real epoch start, which also retires the
sentinel the UI package kept as a workaround.
Also: `loading` no longer reads `isPending`, which is true forever for a disabled
query and pinned every usage card to its skeleton before a project resolved. And
four bucket fields nothing produces or reads are gone, including the `enviornment`
misspelling that had no way to be right.
…vents to reload The "system" theme listener responded to an OS change by setting the mode to "system" — the value it already held. React bails out of an equal write, so nothing recomputed and the page only flipped on reload. The OS preference is now its own state, which also removes a `matchMedia` read from the render path. DataTable passed `onReload` straight to `onClick`, so a handler like SWR's `mutate` received the MouseEvent as its first argument and wrote it into the cache as the new data. It is now called with no arguments. Clickable rows also take focus and answer Enter/Space. They looked like buttons and behaved like buttons for a mouse only.
Both sections deleted (and revoked) outright. The `confirm` seam they needed already existed and was already supplied by both hosts for the connection sections — those two just never took it. Wired on the desktop (AlertPopup) and on /m (the confirm sheet). The confirm type had been copied into four files and drifted: one typed `onOk` as `() => Promise<void>`, rejecting a synchronous handler there and nowhere else. It now has one definition. Also in the tools surface: the OAuth popup poll outlived the modal (untracked interval, no unmount cleanup), a failed create was swallowed with only a spinner reset, a rejected refresh left the row silently unchanged, and `confirm` was missing from two dependency arrays, so a host that changed it kept the first one forever.
The agent path renders the shared `AgentConfigHeader`, which owns the commit button itself. It was passed neither `appId` nor the two callbacks, so an agent commit skipped this app's out-of-band caches (the variant registry and the evaluator tables) and its onboarding event, leaving those lists stale. The adapter moved out of the OSS button wrapper into `useCommitHostAdapter`, so the wrapper and the header share one definition rather than a second copy. The commit button's dirty guard also only covered its own button: a host trigger passed as `children` was cloned with `onClick` alone, so it could open the modal on a clean revision. And two theme fixes in the same components, both of which rendered a fixed light value in dark mode: a hardcoded white header background and a raw cyan `--ag-c-*` literal.
… clears The per-agent waiting counts were derived from a list query that keeps the previous key's pages (`placeholderData: keepPreviousData`) and holds them once the query goes disabled. When the last waiting session cleared, the id set emptied, the query switched off, and the old pages kept every badge lit — permanently. Also in the sessions surface: the list card's "View all" dropped the agent it was scoped to and landed on the project-wide list; the filter sheet's Clear cleared the agent even on an agent-scoped page, silently widening it past the page it belongs to; a long-press timer outlived a tab closed under the finger; the status chips claimed to be a `<nav>`; and the Filters button had no hover or focus state. Build/Chat mode is persisted now — which of the two you work in is a standing preference, not something to relitigate on every reload.
…ion fails Both mobile composers stashed the task, cleared the staged files, and then awaited `router.push`. A push that rejects left the attachments gone, the task stashed for a route that never mounted (so it replays the next time that session id opens), and no error anywhere. They now navigate first, drop the stash on failure, and clear only once the destination is committed to. The create-agent action does the same and releases its latch, which otherwise stayed engaged for the rest of the mount. `LiveConversation`'s pending-task guard was a bare flag on a component that survives a session switch, so the next session's stashed task was swallowed. It holds the session it fired for. The composer also guards against concurrent sends — Enter, the send button and a completing voice take could all fire while an upload was in flight. In the shared home composer: a selected agent that leaves the list no longer sticks (the trigger went blank and the task went to an agent that is gone), a rejected `onStart` reaches the host instead of becoming an unhandled rejection, and the create-agent latch is module-scoped, as its own comment already claimed. `AgentNameInline` drops its two antd imports, and its rename pen becomes a real button — the only other way in was a double-click.
… client Three axios modules migrated to the generated resource clients, with accessors added for users, keys and webhooks. The profile response was cast straight to `User`; it is validated with zod now, which is what Fern's `unknown` return demands. The 401-as-signed-out path reads Fern's `statusCode` rather than an axios `response.status`. The keys and webhooks routes take scope parameters the spec does not declare, so `workspace_id` / `project_id` ride along as `queryParams` — dropping them would have silently listed and minted keys against the wrong workspace. Fern types the webhook list and count fields as optional while the local types promise them, so the boundary defaults them. That mismatch was real, not a typing nuisance: a consumer mapping over `subscriptions` would have hit undefined.
Editing a webhook subscription sent `event_types: [first]`, so a subscription created on the desktop or through the API lost every event type but one the moment it was saved here. The sheet holds the full list now and narrows it only when you actively pick a different event, and says so when it is holding types it cannot show. The rename field could not be cleared: it rendered `value || project.project_name`, so emptying it put the old name straight back, and Save then sent the name you had just deleted. The sheet owns its draft and seeds it on open. The secret-reveal Copy button reported success unconditionally — `navigator.clipboard` is undefined outside a secure context and the optional chain swallowed it. On a secret the backend shows exactly once. It now reports the failure and tells you to copy by hand. Billing checkout opened its window after an await, which mobile browsers block: the portal navigates this tab, and the desktop pricing modal opens its tab inside the click and fills in the URL when it arrives. Also: `?tab=tools` rendered the tools section even where the env gate disables it; a confirmation sheet survived a tab switch and would have acted on the section you left; a deep-linked tab flashed Preferences before the router was ready; the usage bar warned "at limit" whenever the limit was zero or unknown; a plan with no base amount rendered "$undefined /month"; and MembersTab's `canWrite` was renamed to what it actually tests, which is that we know the workspace, not that you may write to it.
128 review comments across 18 PRs, written before any were actioned so the work could be scoped, then updated with what was fixed, what turned out not to be a defect, and what is knowingly partial. Every referenced file still exists, which is a weak signal: all three Criticals were verified and they split three ways — one real double-mount, one already fixed, one an artifact of how the stack is ordered.
The analytics probe narrows the timezone claim. The backend echoes the same UTC instant for the bare and the Z-suffixed input, so the queried window was always right and the designator carries no wire risk. The damage was the client-side reparse: 1620 minutes instead of 1440 for the 24-hour preset, which picked 60-minute buckets and week-scale tick labels.
…ode claim The double-mount is confirmed by counting mounted nodes with the fix and with SessionWorkspace reverted: two composers and two Lexical editors before, one each after. The webhook event-type loss and the rename field are confirmed against real data. One claim was wrong. AgentConfigHeader's `--ag-c-FFFFFF` does not render white in dark mode; the compat shim is theme-aware and resolves to the same #141414 as the token that replaced it. That edit is hygiene, not a fix. The cyan icon literal beside it IS theme-blind, so that half stands.
Same defect the mobile session workspace had. `identity`, `meta` and `body` are element variables shared by a phone shell (`lg:hidden`) and a desktop shell (`hidden lg:flex`). Both stay in the DOM, so React mounted each of them twice: the host's markdown renderer parsed the whole AGENTS.md twice on every render, and the Use button existed twice at every width. Confirmed in the browser on /m — the template detail rendered two "Use" buttons before the fix, one after. Only the matching shell is built now, chosen by the same `useMediaQuery` seam the session workspace uses. The hook sits above the `!template` early return, since a hook cannot follow one.
`pnpm run format` and `turbo run lint` were failing on this branch before any of the
recent work — CI's last green-checked commit predates it. Nine files were unformatted
(three `eslint.config.mjs`, the token-generation script, and several test files), and
the package test suites carried import-order and `type`-vs-`interface` violations the
lint rules reject.
The count looks alarming locally ("2643 files") only because the check globs the whole
tree and picks up `mobile/.next` build output, which does not exist on a fresh
checkout. The real set is the nine above.
…phaned tests
`turbo run lint` and the web unit layer both failed on this branch before any recent
work. Three separate causes, all from earlier carve waves rather than one bug.
The trace-drawer extraction left 40 pure re-export modules in `oss/src` pointing at
`@agenta/observability{,-ui}`. OSS lint bans exactly that (`no-restricted-syntax`:
consumers must import from the source package for tree-shaking). Every importer now
names the package directly and the shims are gone; the handful that imported a
shim's `default` take the package's named export instead, since the packages have no
default.
Five OSS test files were orphaned when their subjects moved into `@agenta/chat`:
`attachments.test.ts` and `toolFormat.test.ts` test modules that no longer exist here
and are already covered in the package, so they are deleted. `partToolName` moved
too, so its block goes with it. `ApprovalDock`'s two suites now take
`getPendingApprovals` from `@agenta/chat/model`, and their mocks of
`@agenta/entities/workflow` and `jotai` were missing members the package code reaches
for — the jotai mock is partial now, so it stops going stale every time the dock
composes one more package hook.
The entity-import smoke test used `await import()` inside each case, putting a cold
transform of a very large module graph inside the per-test timeout: fine alone (~22s),
past 60s under the full suite. The imports are static now, so the transform happens
during collection where no per-test timeout applies.
Verified: `pnpm run lint` exits 0 across the workspace, OSS and EE `tsc` are clean,
and the web unit layer passes.
The mobile image hand-listed the six packages it thought it linked, and that list
fell behind the real closure: mobile now needs 22 workspace packages, 15 of which
were never copied. `@agenta/chat` imports `@agenta/entity-ui/tool-permission`,
entity-ui was not on the list, and the build died with
error TS2307: Cannot find module '@agenta/entity-ui/tool-permission'
on a subpath that resolves fine in every other environment. Both architectures have
failed on this since the packages grew past the list.
It now uses the same `COPY --parents packages/*/package.json` + `COPY packages/`
pair the oss and ee images already use. Turbo still builds only what
`--filter=@agenta/mobile` needs; the difference is that nothing has to remember to
update a list when a package gains a dependency.
Fourteen review findings against the stack runbooks, mostly commands that fail or lie when run. Three TypeScript gates counted `error TS` lines inside a command substitution. That takes the pipeline's status from `grep` rather than `pnpm`, so a compiler that crashed, OOM'd or never ran reported a count of zero and passed. All three now run `tsc` directly under `set -euo pipefail`. `execute-stacked-prs.md` selected commits as "the newest 50" one paragraph after saying the commits are not contiguous — a positional slice both picks up other people's commits and drops session ones. It selects by range now. Its lane creation used `git checkout -b`, which fails outright on the 29 lane branches its own Phase 1 finds; `-B` resets them instead. Phase 5 told operators to fix a PR base with `gh pr edit`, which the same document's trap list records as broken on this repo — it uses the `gh api -X PATCH` route now. And the stash-isolation section never mentioned that `git stash -u` keeps untracked files in a third parent, so restoring from `<stash>` alone silently drops every new file. `restack-onto-112.md` and `plan.md` are marked historical and point at the runbook that records what was actually done: their figures (88 commits, no lane branches, no PRs, 17 mobile type errors) were all overtaken. `restack` also advised amending a lower lane to add an upper lane's delta, which rewrites a commit every lane above already builds on; it says make a new commit on the upper lane. One `pkg/settings-spine` SHA had a space in the middle of it (`675003 3`). `plan-settings-nav-takeover.md` told the implementer to drop the `NavDrawer` header in takeover mode. Below `lg` the sidebar IS that drawer, so following it leaves a phone with no way to open the settings navigation — the takeover would hide what it took over. The shipped `SettingsScreen.tsx` keeps the row, and the plan now says why.
`SsoProvidersSection` tested `flags.is_enabled !== false` and `flags.is_valid !== false`.
The backend's enable-SSO gate is `(provider.flags or {}).get("is_active") and
.get("is_valid")` — a different flag for the first, and the opposite default for both:
an omitted flag means NOT active there and meant active here. A provider the backend
refuses to enable SSO with was rendered as enabled and verified.
The access tab returned `null` for loading, for a failed query and for an
organization with no flags alike, so a slow or failed request was a blank tab with
nothing to read and nothing to press. It has the three designed states now, in the
`states/` folder the other mobile features use.
The flag save never finished its lifecycle: the success tick was set and never
cleared, so it sat on the last-saved row for the rest of the session, and a failed
save was swallowed entirely — the switch flipped back on refetch and that was the
only signal. It clears after three seconds and surfaces the failure.
Smaller ones: a domain whose `created_at` will not parse rendered "Invalid Date" and,
because NaN compares false against everything, never read as expired either; the
tooltip that carries the only explanation of what each toggle does hung off a `<span>`,
so it was mouse-only; and `DataTable` rendered its trailing actions column whenever
`actions` was passed, leaving a dead 48px column on every read-only host. (`RowActions`
already returned null for an empty menu, so there was no stray trigger — only the
column.)
…ess cast The create dialog reset its form only in `onCancel`, so closing it by actually creating a project left the name and the make-default switch sitting there for the next open. Both dialogs reset in `afterClose` now, which fires however the modal closed. `NamedSecretTable` handed its delete dialog `selectedSecret as unknown as LlmProvider`. `NamedSecretRow extends LlmProvider`, so the value was already assignable — the double assertion bought nothing and would have hidden a genuine mismatch if the two types ever drifted apart.
`useApiKeys` fetched with `useEffect` + `useState` + a manual promise chain, which the frontend conventions rule out for data fetching. It showed: every mount refetched, each surface showing keys kept its own copy of the list, `remove` spliced the row out of local state rather than refetching, and a failed list was swallowed into `console.error`. It reads `apiKeysQueryAtomFamily` now — keyed by workspace AND project, because the request is scoped by both, so switching either re-keys the query on its own. Mutations invalidate through `getHostQueryClient()` rather than writing rows by hand. `listing` reads `fetchStatus`, not `isPending`: a disabled query (no workspace resolved yet) stays pending forever and would have pinned the table to its loading state. `@agenta/settings` gains the same query peers `@agenta/observability` and `@agenta/sessions` already declare; jotai was already a peer here.
…gs tabs The code editor's scrollbar was `#ddd` on `#f0f0f0` — invisible against a dark editor; both read theme tokens now. Two more fixed literals go with them: the `--ag-c-FFFFFF` in the agent operations header and the copied-toast tick, which was pinned to `--ant-lime-6` (a token that only exists where antd's ConfigProvider mounts, so it resolved to nothing on `/m`). Tools and Triggers are gated at the render boundary as well as in the tab resolver. The resolver already redirects a disabled `?tab=tools`, but a render boundary that trusts the router is one refactor away from rendering a surface this deployment turned off. The template strip's storage key takes the `agenta:` prefix every other key in this app uses; "the prototype spelled it differently" was not a reason to opt out. The `previewUrl` ownership note named `DriveExplorer`, which only reads it — `useImagePreviews` derives and revokes it. The jotai peer floor moves off `>=2.0.0`, which was never true of anything this repo tests: the apps run 2.16. The triage document records the three findings that are NOT actionable at the call site, with the evidence: the mount download has no Fern method to route through, the secret sheets cannot adopt "empty means keep stored" because the write path throws on a missing key, and the sidebar grouping reads as correct.
Context
Observability had no mobile surface. This adds one: traces and sessions, project-wide, at
/m/w/:workspace/p/:project/observability, with a nav entry after Agents.It is deliberately small, because WP0 through WP5 already moved everything it needs into packages. The screen is composition.
Changes
Traces reuse the packaged pieces end to end.
TracesListisObservabilityList(WP5's shell) renderingTraceRow(WP5's row). There is no mobile-only rendering of a span, so a change to how a trace reads lands on desktop and here at the same time.The range control is the same component desktop renders. The original plan scheduled an
ObservabilitySortSheetwith the ten presets. It is not here, because the chrome conversion landedObservabilityRangePickerin@agenta/observability-uifirst, which is exactly the ordering argument that motivated doing the chrome work before this WP. Same for filtering: the engine and dialog already exist, so no parallel sheet gets written.Sessions take the other path, on purpose. An observability session has no non-table rendering anywhere to extract, so mobile stacks the WP3 session cells in a layout it owns rather than inventing a shared row for one caller. The cells stay the single source of formatting. This is option (a) of the open design question in the plan; option (b), a designed session row shared by both surfaces, is a design ask and nothing here forecloses it.
Worth restating: this is spans grouped by session id, not the agent-session entity from
@agenta/sessions.SessionCardListrenders aSessionRowVmthat this data cannot fill.No scope binding.
observabilityScopeAtomandobservabilityWorkflowContextAtomalready default to project-wide with no workflow, which is precisely this screen, so binding them would only re-state the defaults.Every data-bearing component has its designed states in
states/: a skeleton that mirrors a real row's geometry so the list does not shift when data lands, an empty state, a filtered-to-nothing state with a clear action, and an error state with retry.Tests / notes
features/chat/ChatScreen.tsx, which this branch does not touch (a pre-existing prettier failure).@agenta/observabilityand@agenta/observability-uiboth grep clean for antd, which is what makes them safe to pull into/mat all.What to QA
On a phone-width viewport, at
/m/w/<workspace>/p/<project>/observability.