Skip to content

EVL-145 + EVL-161: Sessions list in React, and the backend fields it needs - #1668

Draft
rogefm wants to merge 15 commits into
mainfrom
rogerio/evl-145-b21-sessions-list-sessions-sessionsfiltered
Draft

EVL-145 + EVL-161: Sessions list in React, and the backend fields it needs#1668
rogefm wants to merge 15 commits into
mainfrom
rogerio/evl-145-b21-sessions-list-sessions-sessionsfiltered

Conversation

@rogefm

@rogefm rogefm commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Warning

Do not merge without the session-details modal. Shipping the list alone
changes a user-facing behaviour — a row click opens a modal in v1, and would
navigate to a full page here — and adding the modal later would change it a
second time. Two breaks where there should be none. The full port of
session_details.cljs lands on this branch before this is mergeable; scope and
progress below.

📝 Description

Ships EVL-145 (React Sessions list) and EVL-161 (the gateway fields it needs) together.

/sessions and /sessions/filtered are now React. /sessions/:id deliberately gets no React route — it keeps falling through the catch-all to the CLJS dedicated page, which is already in production via Share links and self-fetches its own data (app.cljs:514-521). EVL-132 flips it. No feature flag; exposure is route registration.

On the backend, models.ListSessions never selected s.ai_analysis, so it was always null on GET /sessions — even though openapi.Session has always declared it, because list and detail share that schema. The embedded review object had the same divergence. A list UI could not render a risk badge without N+1 calls to the detail endpoint.

Zero CLJS files change in this PR: webapp/src/webapp/app.cljs is owned by #1665, so the CLJS :sessions-panel stays registered. Harmless — React Router's exact route wins.

📣 User-facing impact

The Sessions list is now a React page: filters round-trip through the URL so a filtered view can be bookmarked and shared, pagination works past 100 sessions, rows are keyboard-navigable and open in a new tab, and revoked/processing/executed reviews are finally visible instead of rendering an empty cell.

🔗 Related Issue

  • EVL-145 — B2.1 Sessions list
  • EVL-161 — T2.1 Expose ai_analysis (and the missing review fields) on GET /sessions

🚀 Type of Change

  • 🐛 Bug fix (non-breaking change which fixes an issue)
  • ✨ New feature (non-breaking change which adds functionality)
  • 💥 Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • 📚 Documentation update
  • 🎨 Style/UI update
  • ♻️ Code refactor
  • ⚡ Performance improvement
  • ✅ Test update
  • 🔧 Build configuration change
  • 🧹 Chore

📋 Changes Made

Backend (EVL-161)gateway/models/session.go, SQL only, two hunks in the rows query of ListSessions:

  • add s.ai_analysis and s.origin to the SELECT
  • add time_window, access_request_rule_name, min_approvals, force_approval_groups to the review jsonb_build_object

No Go changes are needed: Session.AIAnalysis already carries gorm:"serializer:json", SessionReview's custom Scan unmarshals the whole blob and all four keys already have json tags, and parser.go routes list rows through the same toOpenApiSession/topOpenApiReview mappers as the detail path. Both callers — the HTTP handler and the MCP sessions_list tool — scan by column name, so the extra columns are additive (MCP's sessionToMap already emitted ai_analysis conditionally; it just never had a value).

Two deliberate decisions worth a reviewer's attention:

  • s.origin is included for literal alignment only. It has no API surface — openapi.Session has no origin field — and is otherwise read only on the write path.
  • s.blob_stream_id is left alone. It is list-only, has no matching struct field, and GORM discards it. Removing it is an unrelated cleanup that would muddy this diff.

Frontend (EVL-145)webapp_v2/src/pages/Sessions/ plus an extended services/sessions.js and a new utils/datetime.js. Ports audit/views/{main,sessions_list,session_item,audit_filters,sessions_filtered_by_id}.cljs and ~150 of the 786 LOC in events/audit.cljs. events/audit.cljs is not deleted — the other ~636 LOC is Wave 6.

DocsMIGRATION_ROADMAP.md reordered to Wave 1 → 2 → 6 → 3 → 4 → 5 → 7 → Endgame (Wave 6 pulled ahead for EVL-165), plus CONTEXT_MIGRATION.md and COMPONENTS.md.

Two deliberate deviations from v1

  1. Pagination is offset+append with a dedupe by session id, not v1's limit = count + 20 refetch. The gateway clamps limit at 100 (session.go:46) and computes has_next_page as len(items) == limit, so past 100 rows v1's button renders forever and adds nothing. hasMore is derived from total and additionally requires the last append to have added something — with ORDER BY created_at DESC and sessions arriving mid-paging, the offset window shifts and total grows.
  2. Rows are a real table with a headerreverted after seeing it rendered. v1 was never a table: main.cljs wraps the rows in a plain rounded-lg border container and each row is a Radix Grid columns="4" with no header. The column labels were inventing chrome the original never had, so the list is back to Paper + Grid. The real anchor in the user cell stays — it is what restores keyboard access, and it renders as plain text.

Bugs fixed while porting

All of these are current v1 behaviour:

Bug v1
A failed fetch left the spinner running forever :audit->get-sessions registered no :on-failure
REVOKED / PROCESSING / EXECUTED / UNKNOWN reviews rendered nothing — a revoked session was indistinguishable from an unreviewed one session_item.cljs:31-32 :default returned nil
A null user_name threw and took the whole list down machine-identity sessions can have one
The date filter throws a RangeError on Safari new Date("YYYY-MM-DD 00:00:00.000Z") is not a valid ISO string (audit_filters.cljs:59-63)
/sessions/filtered?id= fetched everything twice both app.cljs:543-551 and the component dispatched
A batch_id containing & broke the fetch and the Share link string-concatenated, never encoded
"Load more" appended # to the URL and jumped scroll <a href="#"> with no preventDefault
The session list was unreachable without a mouse rows were divs with no tabIndex/role/key handler
Enter in the ID search cost 2N requests fired immediately and left the debounce running
ID-search results appeared in HTTP-race order each arrival was prepended
A mistyped session id silently produced no row errors were collected only to detect completion

Rather than role="link" on the <tr> (which strips row semantics), the user cell holds a real anchor — keyboard focus, accessible name, and open-in-new-tab.

Visual-fidelity pass after manual testing

Running the page side by side with the CLJS original turned up four divergences, all fixed in 747b2079:

  • Table → list. See above.
  • Badge colours. v1 renders [:> Badge {:color "green" :size "2"}], and Radix's default Badge variant is soft. I had mapped them onto the semantic active/warning/danger variants, which are filled — solid yellow especially is near-illegible. Now color + variant="light".
  • Filter label weight. Not font-weight: v1 uses font-semibold too. Mantine's default button variant ignores color and paints the label with --mantine-color-text (near-black) where the Radix trigger was color="gray". Fixed with c="dimmed". Blast radius: ValueFilter/AsyncValueFilter are shared, so the Rulepacks and Data Masking filters lighten too. That matches v1 everywhere, but it is a change outside Sessions.
  • Page flashed on every request. lookupByIds and fetchBatch reset their slice to EMPTY_* on entering loading, emptying the list and handing the content area back to PageLoader. Both now keep the previous rows mounted, matching v1 (main.cljs:57-64 only shows its spinner when the list is empty). The main list already preserved its items.

Session details stays a full-page navigation, not the v1 modal. A bridge to the CLJS modal is not available: on a React-only route the CLJS tree is parked hidden by ClojureApp, so the modal would open invisible — the same constraint documented in EVL-123. It returns in EVL-131, in the DES-27 layout.

Other notes for reviewers

  • The React list calls /sessions; CLJS called /plugins/audit/sessions. Same handler (server.go:805 is an alias) — flagged so nobody diffing network tabs files a parity bug.
  • usePaginatedConnections yields { value: connection.id }, but the gateway matches ?connection= against the connection NAME. The Resource Role filter remaps so the option value is the name; passing the id writes a UUID and silently returns zero rows. Commented at the call site.
  • FILTER_KEYS is an allow-list. v1 forwarded every URL param to the API and relied on the gateway ignoring unknowns. /sessions?batch_id= therefore no longer filters — nothing links to it; /sessions/filtered?batch_id= is the supported URL and is unaffected.
  • The ID search is capped at 50 ids (new). Each id costs one request, so an unbounded paste was a self-inflicted fan-out.
  • getFilteredByIds drops ?event_stream=base64. No list row renders anything from the event stream; v1 downloaded a full base64 transcript per session and discarded it.
  • sessionsService.list() still returns the raw axios response while every new method unwraps to .data. useConfigStatusStore reads data.total off the response — unwrapping would not throw, it would silently pin the sidebar checklist to false. Commented in the file and in COMPONENTS.md.
  • downloadFile/downloadInput follow v1 in using GET /sessions/:id with blob params rather than the dedicated /download routes (server.go:814-815). Wave 6 should choose deliberately rather than inherit this.
  • No analytics events were added, removed or moved by this PR.

🧪 Testing

Test Configuration:

  • Browser(s): Chrome, Safari (the date-filter fix is Safari-specific)
  • OS: macOS

Tests performed:

  • Unit tests pass — make test-oss green
  • Integration tests pass
  • Manual testing completed
go build ./gateway/... && go vet ./gateway/models/...
make test-oss

# The openapi tree must stay untouched — #1665 owns those files
make generate-openapi-docs
git status --porcelain -- gateway/api/openapi      # verified EMPTY

cd webapp_v2 && npm run lint && npm run build      # lint at the 15-error baseline, build clean

Proof the SELECT fix reaches the wire — every review key in the detail response must also appear in the list response:

diff <(curl -s -H "Authorization: Bearer $T" "$GW/sessions?limit=1" | jq -S '.data[0].review|keys') \
     <(curl -s -H "Authorization: Bearer $T" "$GW/sessions/$SID"    | jq -S '.review|keys')

How to test

  1. Pagination — scroll to "Load more sessions": rows append, no duplicates, no # in the URL, and the button disappears at the true end. Worth testing an org with exactly 20/40/60 sessions (the has_next_page bug) and one with >100 (where v1 dead-ends).
  2. Filters — each of user / resource role / type / access request / date range / Jira: selecting adds the param and refetches once (watch the Network tab for loops); clearing deletes the key rather than writing key=. Confirm Resource Role puts a name in ?connection=, not a UUID, and that rows actually narrow. Deep-link a fully-populated filter URL in a fresh tab; hit Back after three filter changes.
  3. Date range in Safari — no RangeError, params are T-separated ISO.
  4. ID search — three ids produce exactly three requests with no event_stream param, rows in typed order; Enter mid-debounce still fires N not 2N; a bogus id raises a snackbar; clearing restores the filtered list with no extra fetch.
  5. Row click — lands on the CLJS /sessions/:id page fully loaded, no blank flash. Back restores the React list with filters, rows and loaded pages intact. Hard-reload /sessions/:id and confirm CLJS boots cold. Also try the CLJS page's own back/close to confirm the handoff to React /sessions (this seam can't be covered by CI — :sessions-panel is still registered on the CLJS side because app.cljs is frozen by EVL-172 + EVL-159: show which features are active on a resource role in the Terminal #1665).
  6. /sessions/filtered?batch_id=X — sticky header, infinite scroll with no duplicates, Share copies a working URL (test a batch id containing &), Share is absent when disable_clipboard_copy_cut is on, search filters without a request. ?id=a,b,c fires exactly 3 requests with StrictMode on. ?batch_id=X&id=a,b → batch wins.
  7. Keyboard — Tab to a row, Enter navigates; Tab to a workflow chip, Enter opens the workflow and does not also fire the row.
  8. Badges — an open connect session pulses Live; a REVOKED review shows a visible badge; a machine-identity session with a null user_name renders.
  9. Error state — stop the gateway and reload /sessions: an error with a retry, not an eternal spinner.
  10. Regression — as an admin, the sidebar Config Status "session ran" check still resolves (proves sessionsService.list stayed raw).

✅ Checklist

  • My code follows the style guidelines of this project
  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • My changes generate no new warnings
  • New and existing unit tests pass locally with my changes
  • I have checked my code and corrected any misspellings

📄 Additional Notes

Reviewer focus, in order:

  1. The s.origin / s.blob_stream_id calls in the backend commit — both are judgement calls, both explained above.
  2. The connection-name remap in SessionsFilterBar.jsx. It fails silently if wrong: the chip shows a plausible name and the list just returns nothing.
  3. The roadmap reorder. Zero code risk, real planning risk — four coupled locations plus ~25 Wave N mentions were re-read in the new order.

Deliberately out of scope: pages/Settings/AuditLogs keeps its own copy of the date formatter (utils/datetime.js is now the canonical one, noted in COMPONENTS.md for whenever that page is next touched).

@rogefm rogefm added the minor Bumps the minor version on release (new features) label Aug 4, 2026
rogefm and others added 6 commits August 4, 2026 12:33
models.ListSessions did not SELECT s.ai_analysis, so ai_analysis was always
null on GET /sessions even though openapi.Session declares it — the list and
detail endpoints share that schema. The embedded review object had the same
divergence, omitting time_window, access_request_rule_name, min_approvals and
force_approval_groups.

Practical consequence: a list UI could not render a risk badge without N+1
calls to the detail endpoint.

SQL only — no Go changes. Session.AIAnalysis already carries
gorm:"serializer:json", SessionReview's custom Scan unmarshals the whole jsonb
blob and all four keys already have json tags, and parser.go routes list rows
through the same toOpenApiSession/topOpenApiReview mappers as the detail path.
Both callers (the HTTP handler and the MCP sessions_list tool) scan by column
name, so the added columns are additive for them too.

s.origin is included so the two SELECTs read identically; it has no API
surface (openapi.Session has no origin field) and is only read on the write
path. s.blob_stream_id stays as-is: it is list-only and has no matching struct
field, so GORM discards it — removing it is an unrelated cleanup.

make generate-openapi-docs leaves gateway/api/openapi byte-identical, as
expected: both schemas already promised these fields.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
services/sessions.js grows get, getFilteredByIds, getByBatchId, streamResult,
downloadFile and downloadInput, mapped from the CLJS events in
webapp/src/webapp/events/audit.cljs. The last three have no UI yet — they are
Wave 6 consumers, mapped now while that file is open so EVL-131/EVL-132
inherit the URLs instead of re-deriving them.

list() deliberately keeps returning the raw axios response while every new
method unwraps to .data: useConfigStatusStore destructures { data } off the
response and reads data.total, so unwrapping would not throw — it would
silently pin the sidebar "session ran" check to false. Commented in place.

getFilteredByIds drops the ?event_stream=base64 the CLJS version sent (no list
row renders anything from the event stream, so it downloaded a full base64
transcript per session and discarded it) and the 1000ms dispatch-later (a
uniform delay, not a stagger). It also preserves input order; CLJS prepended
each arrival, so what users saw was HTTP-race order.

getByBatchId uses axios params, fixing the unencoded batch_id CLJS
concatenated into the URI.

utils/datetime.js is the canonical port of formatters/time-parsed->full-date
plus local-day range bounds. CLJS built those bounds as
new Date("YYYY-MM-DD 00:00:00.000Z") — not a valid ISO string, so Safari
returns an Invalid Date and .toISOString() throws a RangeError. It also parsed
bare YYYY-MM-DD as UTC midnight, which lands on the previous day west of UTC;
parseDateInput builds from parts to avoid that.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
pages/Sessions/store.js holds three slices — list (URL-filtered), lookup (the
ID search box and /sessions/filtered?id=) and batch (?batch_id=) — because
their lifecycles genuinely differ. v1 collapsed the last two into one
:audit->filtered-session-by-id key and they fought over it.

Pagination is offset+append with a dedupe by session id. v1 refetched the whole
query with limit = count + 20, which dead-ends at the gateway's 100 cap while
has_next_page keeps reporting true, leaving a button that renders forever and
does nothing. hasMore is derived from total rather than has_next_page (which is
len(items) == limit, so wrongly true on every exact multiple) and additionally
requires the last append to have added something — with ORDER BY created_at
DESC and sessions arriving mid-paging, the window shifts and total grows.

patchSession is the EVL-132 seam: it fans a partial update across all three
slices and preserves slice identity where the id is absent, so a live tail on
one surface never re-renders another. The reader itself must live in a
module-level variable, never in store state — same idiom as
useConfigStatusStore's inFlight.

useSessionFilters keeps the query string as the single source of truth, as v1
did by reading window.location.search inside the event handler. Two
differences: FILTER_KEYS is an allow-list (v1 forwarded every URL param to the
API and relied on the gateway ignoring unknowns), and the fetch effect depends
on a primitive queryKey projection rather than on the filters object, which is
what keeps it from looping.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Ports audit/views/{main,sessions_list,session_item,audit_filters}.cljs. The
route goes live immediately; /sessions/:id deliberately gets no React route and
keeps falling through the catch-all to the CLJS dedicated page (already in
production via Share links, and self-fetching) until EVL-132 flips it.

Rows are a real table with a header (User / Resource Role / Status / Started)
rather than v1's headerless 4-column grid of divs, matching every other
migrated page. Rather than putting role="link" on the <tr> — which would strip
its row semantics — the user cell holds a real anchor, so keyboard users get a
focus target and everyone gets open-in-new-tab. v1 rows had no tabIndex, role
or key handler at all.

The correlation-ID control is a faithful port of v1's Workflow popover: a
navigator to /workflows/:id, not a list filter, even though the gateway does
accept correlation_id.

Bugs fixed while porting, all of them v1 behaviour:
- no error state at all (:audit->get-sessions had no :on-failure, so a failed
  fetch left the spinner running forever) — now an error state with a retry
- REVOKED / PROCESSING / EXECUTED / UNKNOWN reviews rendered nothing, making a
  revoked session indistinguishable from an unreviewed one; every status is
  mapped and the fallback shows the raw string
- a null user_name threw in the avatar and took the whole list down
- Enter in the ID search fired immediately *and* left the debounce running,
  costing 2N requests
- "Load more" was an <a href="#"> with no preventDefault
The ID search is also capped at 50 ids, since each one costs a request, and
now surfaces ids that failed to load instead of silently omitting them.

usePaginatedConnections yields { value: connection.id }, but the gateway
matches ?connection= against the connection NAME — the Resource Role filter
remaps so the option value is the name. Passing the id writes a UUID and
silently returns zero rows.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Ports audit/views/sessions_filtered_by_id.cljs: sticky header, Share link,
client-side search over connection/type/id/user_name, and real offset-based
infinite scroll in batch mode (the id-list mode has no server pagination, same
guard as v1). batch_id wins over id when both are present.

Share uses the existing CopyButton, which already returns null when
disable_clipboard_copy_cut is set — exactly the gate v1 put on its own button —
and the URL is built with URLSearchParams, so a batch id containing & or a
space now round-trips.

v1 fetched twice for ?id= because both the panel (app.cljs:543-551) and the
component dispatched the same event, costing 2N requests. One effect here, plus
the store's key guard, which also covers StrictMode's double-invoke.

With neither batch_id nor id in the URL v1 rendered a blank page; this shows the
empty state explaining what the page needs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
MIGRATION_ROADMAP.md now sequences Wave 1 → 2 → 6 → 3 → 4 → 5 → 7 → Endgame.
Wave 6 (session details) is pulled ahead because the "After Execution" work in
the Major Features Visibility project depends on the reworked detail view
(EVL-165). Wave labels are frozen — B3.1, B6.1 and the rest are live Linear
ticket IDs — so the section order carries the schedule and a note at the top of
Track B says so.

Recorded with the move: only the jira prompt-gate slice of B5.1 is a Wave 6
prerequisite (parallel_mode, 1,393 LOC, stays in Wave 5), and B6.1's
credentials block goes through useBridgeStore.resumeNativeClientCredentials
to /resources until B4.0/EVL-123 lands, because dispatching the CLJS flow while
that tree is parked would open the modal invisible.

Coupled references updated so the doc doesn't contradict itself: the B2.1 row
(now done), B4.0's Unblocks, B5.1's split scope and B5.2's rationale, the
reviews_plugin.cljs deferral in Track A, the Track C "When" column (MS Clarity
pinned to Wave 2 — "Wave 2–3" would now drift months later), the sequencing
fence, and Top Risks, where playback fidelity is promoted to #1 since it loses
most of its schedule slack.

CONTEXT_MIGRATION.md: /sessions and /sessions/filtered marked React/Done, with
/sessions/:id listed explicitly as intentionally still CLJS.

COMPONENTS.md: documents the extended sessionsService (including why list()
alone returns the raw response) and utils/datetime.js, and fixes four
inaccuracies in files this PR consumes — AsyncValueFilter takes option objects
rather than labels and scrolls with ScrollArea rather than useIntersection,
useMinDelay is a named export, and Table striping is opt-in (that last one was
wrong in the component's own JSDoc, not in COMPONENTS.md).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@rogefm
rogefm force-pushed the rogerio/evl-145-b21-sessions-list-sessions-sessionsfiltered branch from d2ff496 to 9a16f8c Compare August 4, 2026 15:36
@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Migration Safety Analysis

No database migrations were changed in this PR. Safe to deploy to sandbox.

@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

📋 API Changelog

API Changelog unknown vs. unknown

No changes detected

@sandromello

Copy link
Copy Markdown
Contributor

✅ Build Completed with Success, Version=1668.0.0-g9a16f8c

rogefm and others added 9 commits August 4, 2026 16:23
Feedback from running the page against the ClojureScript original. Four
corrections, three of which reverse choices I had proposed from ASCII mockups —
seeing it rendered is what caught them.

Table -> list. v1 was never a table: main.cljs wraps the rows in a plain
`rounded-lg border` container and each row is a Radix `Grid columns="4"` with a
bottom border and no header, so the column labels I added were inventing chrome
the original never had. SessionsTable is now SessionsList, built from Paper +
Grid. The real anchor in the user cell stays — it is what gives the list
keyboard access and open-in-new-tab, and it renders as plain text, so it costs
nothing visually.

Badge colours. v1 renders `[:> Badge {:color "green" :size "2"}]`, and Radix's
default Badge variant is `soft` — tinted, not solid. I had mapped them onto the
semantic active/warning/danger variants, which are filled; solid yellow in
particular is close to illegible. Now colour + `variant="light"`, the Mantine
equivalent of soft.

Filter label weight. Not a font-weight problem — v1 uses `font-semibold` too.
Mantine's `default` button variant ignores `color` and paints the label with
--mantine-color-text (near-black), where the legacy Radix trigger was
`color="gray"`. Fixed with `c="dimmed"` on both ValueFilter and
AsyncValueFilter. Note the blast radius: those are shared, so the Rulepacks and
Data Masking filters lighten too. That matches v1 everywhere, but it is a
change outside Sessions.

Screen flashing on every request. `lookupByIds` and `fetchBatch` reset their
slice to EMPTY_* when entering `loading`, which empties the list and hands the
whole content area back to PageLoader — so each ID search or batch fetch blanked
the page. Both now keep the previous rows mounted while loading, matching v1,
which only ever shows its spinner when the list is empty (main.cljs:57-64). The
main list already preserved its items, so filter changes were not affected by
this path.

Session details stays a full-page navigation rather than the v1 modal. A bridge
to the CLJS modal is not an option — on a React-only route the CLJS tree is
parked hidden by ClojureApp, so the modal would open invisible (same constraint
as the credentials block in EVL-123). It returns in EVL-131, in the DES-27
layout.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The page component subscribed to the `list` and `lookup` slices, so every
response re-rendered the title, the count and the entire filter bar along with
the rows. v1 never did that — re-frame repaints only the subtree that is
subscribed — and it is what made the screen flash on each request.

Split the two pieces that depend on fetched data into their own subscribers:
`sections/SessionsResults` (loader / empty / error / list / load-more) and
`sections/SessionsCount`. The page itself now subscribes to nothing but the
stable `fetchList` action, so it re-renders only when the URL params change,
which is exactly when the filter bar genuinely needs to update.

SessionsCount also keeps the previous numbers on screen while a refetch is in
flight, so the header does not collapse and reflow the page under the user.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
First slice of the full session-details port. Row click opens a modal again,
as in v1, instead of navigating away.

The store gains a `detail` slice implementing v1's two-phase fetch verbatim
(:audit->get-session-by-id + :audit->check-session-size, events/audit.cljs
206-273): phase 1 is a probe that exists only to read `event_size` and
`script_size`, phase 2 re-fetches with an `expand` derived from them, and when
both exceed the 4 MB threshold the second request is skipped entirely. Two
round-trips for one modal is wasteful, but the sizes are not on the list
payload and this is what v1 does.

`SessionInfo` ports sessions/components/session_details.cljs (259 LOC): the
pinned two-column rows, the review-group list, and the See more/See less
expansion — which resets on mount, same as v1.

Two things deliberately NOT ported:
- `review-status-icon` / `review-status-text` (session_details.cljs:98-123) are
  dead code. Grep across src/ finds only their own definitions, and
  `review-status-icon`'s methods take zero args while the `identity` dispatch
  passes one, so calling it would throw. The live rendering is
  `review-group-item`, which is what SessionInfo follows.
- `user-name-initials` (session_details.cljs:114) throws on a nil user_name;
  `initialsFor` guards it. This one is a fix, not parity — a render crash takes
  the whole modal down.

The row anchor still points at /sessions/:id so keyboard access and
open-in-new-tab keep working; a plain click is intercepted and opens the modal.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Ports the review block (session_details.cljs:220-267 and 408-458) plus its two
sub-modals (reject_details_modal.cljs 32 LOC, time_window_modal.cljs 51 LOC).

`canReview` and `canForceApprove` follow v1 exactly, including the branch where
the force-approve set comes from the connection's `force_approve_groups` when
the review carries no `access_request_rule_name`, and from the review's own
`force_approval_groups` otherwise. That is the only reason the modal fetches
`GET /connections/:name` at all.

All four actions go through `reviewsService.addReview` → `PUT /reviews/:id`:
approve, reject with a comment, force approve, and approve with a time window.
Time windows are converted to UTC before sending and rendered back in local
time, matching `local-time->utc-time` / `utc-time->display-time` — both now in
utils/datetime.js. The 500ms delay before refetching is v1's
(events/audit.cljs:560-566); the gateway propagates the status asynchronously.

Two deliberate divergences:
- The sub-modals stack instead of replacing. v1 has a single global modal slot,
  so opening either one destroyed the session-details modal underneath and ran
  its cleanup — the user never got back to the details after confirming. That
  reads as an implementation limit, not intent.
- The time-window modal keeps v1's "continues to the next day" copy even though
  v1's own `is-within-time-window?` compares plain minutes-of-day and does not
  wrap. The gateway owns that behaviour; the copy is unchanged.

Also fixes a bug introduced a moment earlier: the post-review list refresh
passed empty filters, which would have silently dropped the user's filtering.
The list slice now remembers its filters and exposes `refreshList`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Ports the self-contained half of sessions/components/session_header.cljs (133
LOC) — a separate CLJS file from session_details.cljs, which the modal composes.

View Timeline, Kill Session and Share, with v1's visibility rules intact:
kill requires owner-or-admin on a non-done exec session, share hides when the
gateway sets disable_clipboard_copy_cut, and the timeline opens in a new tab
from the modal so the session stays open behind it.

`killing-status` is component state here. In v1 it is a module-level atom
(session_header.cljs:37) shared by every header instance, so two open sessions
would have shown each other's spinner.

Re-run is deliberately left out of this commit. Its script branch routes
through the Jira template gate (events/audit.cljs:444-447), and shipping the
button without that would silently skip a required prompt on any connection
that has a template configured. It lands with the jira-templates port.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ection

Ports the four independent info blocks and wires them into the modal in v1's
order (session_details.cljs:349-355). Built in parallel by four agents, each
reviewed against its ClojureScript original by a second agent; the findings
below are theirs.

Three defects the review caught and this commit fixes:

- The AI analysis card swallowed unrecognized `action` values. v1 nests its
  `case` inside the bordered Box, so a truthy-but-unknown action still renders
  the separator and its spacing. Gating both on the lookup dropped them.
- `color="orange"` (medium risk) is not in the theme, which defines only
  indigo/gray/green/amber/red/sky — it would have fallen back to Mantine's
  stock palette, bypassing the theme. Now `amber`.
- The AI analysis and Guardrails cards had drifted apart: same root class in v1
  (`w-full p-3 bg-[--gray-1] rounded-md border border-[--gray-3]`, rendered
  adjacent), different chrome here. They now share `components/InfoAccordion`,
  which also corrects the surface — Radix `--gray-1` is body-white, not the
  tinted `gray.0`, and Mantine's `contained` variant hardcodes a mid-slate item
  border that is unreachable from `--mantine-color-default-border`.

The redaction report is fetched alongside the session, as v1 does from
:audit->check-session-size, and never blocks the modal: data masking falls back
to `session.metrics.data_analyzer` when the report is unavailable — again v1's
own behaviour.

Two things flagged and deliberately left as-is:
- `violet` is also outside the theme palette. v1 uses Radix Violet for the data
  masking card and Mantine's stock violet is close, so it stays; the clean fix
  is adding a violet scale to theme.js, which is a design decision.
- `RejectionReason.jsx` was swept into the previous commit (dce7aa4) by a
  `git add -A` that raced the agents writing it. Content is unaffected; the
  commit message there just does not mention it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Ports session_details.cljs:295-346 plus `large-input-warning` (:46-65), in v1's
block order.

Metadata deliberately hides `credentials_expire_at`, `credentials_revoked_at`
and `credential_session`: v1 excludes them from the rows because they drive the
credentials block and the "Credentials Session" detail row instead, and the
whole section disappears when nothing else remains. Values that parse as http(s)
URLs render as links, matching v1's `is-url-http` check.

The script area swaps for a download callout above the 4 MB input threshold —
past that the gateway does not send the script at all. The download button
honours the gateway's `disable_sessions_download`, which the user store did not
expose; it now does, alongside `disableClipboard`.

One divergence: v1 throws when `labels.runbookParameters` is malformed JSON.
Here that is treated as absent. The strip is informational and not worth
taking the whole modal down for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Ports the results stack: results_container.cljs (94), ag_grid_table.cljs (153),
logs_container.cljs (108) and results_download_menu.cljs (149). The three leaf
components were built in parallel by agents and each reviewed against its CLJS
original by a second agent.

ag-grid is lazy-loaded. It lands in its own 1,026 kB chunk (276 kB gzipped) that
only downloads when someone opens the SQL "Table" tab; the main bundle goes from
351 to 375 kB gzipped, and those 24 kB are papaparse + fancy-ansi + the new
Sessions code, not the grid. Importing it eagerly would have roughly doubled the
main bundle for every user. This mirrors how Router.jsx already defers the
Dashboard's recharts.

Worth knowing for planning: this does NOT remove ag-grid from the CLJS bundle.
It has two independent consumers there — results_container (ported here) and
webclient/log_area/main.cljs, the SQL editor at /client, which is Wave 7. Both
bundles carry it until then. The roadmap already anticipated this: B6.2 lists a
"shared AgGrid table wrapper (also serves the webclient)".

Also adds `decodeB64`, a faithful port of utilities/decode-b64 including the
escape/decodeURIComponent recovery and the ∞→tab substitution, and wires
/result/stream into the store for payloads over the 4 MB threshold, where the
session response omits the event stream entirely.

The `connect` branch of the output section is not implemented yet — that is
asciinema, the RDP canvas and the SSE live tail, which land with the playback
commit. Exec sessions, which is what the output section mostly serves, are
complete.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The verification pass on 7129fc9 found real problems, two of which would have
been obvious on first use.

Blank output pane. LogsContainer gated on `status === 'ready'` while every
caller passes `'success'`, so the Plain-text tab — the default tab for both SQL
and non-SQL sessions — rendered "No logs to show" for every exec session with
output. The port had renamed the vocabulary to the store's idle/loading/ready,
on the false premise that the status came from the store; it is computed
locally in SessionOutput. Now on v1's vocabulary (logs_container.cljs:15,21)
with a comment at the seam, since that is where the two conventions meet.

Wrong colour scheme. results_container.cljs:34 passes its arguments
positionally — `results-heads results-body false true` — so `dark-mode?` is
TRUE and v1's session grid is dark blue. ResultsContainer passed nothing and
AgGridTable defaults to light, so the grid rendered light warm.

Auto-sizing that v1 does not do. v1 asks for `:auto-size-columns? true`, but
its implementation gates on `params.columnApi`, which ag-grid 33 removed — the
branch is dead and columns are never auto-sized today. The port called
`params.api.autoSizeAllColumns()` on `gridReady`, which both changes behaviour
and fires before rows paint, so it would size to headers only. Defaulted off to
match what users actually see; the intent is noted at the call site.

Screen-reader label. v1 labels the output trigger "Output options"; the
ActionMenu wrapper hardcoded "Actions" with no way to override. Added an
`ariaLabel` prop — this also helps Settings/ApiKeys and AiAgentsIdentities,
which render lists of identical "Actions" buttons.

Lazy-load confirmed after wiring: ag-grid is a separate 1,026 kB chunk
(276 kB gzipped); the main bundle is 375 kB gzipped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

minor Bumps the minor version on release (new features)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants