Skip to content

feat(frontend): agent playground sessions, files, and inspector - #5306

Merged
bekossy merged 137 commits into
release/v0.105.3from
big-agents-add-sessions
Jul 17, 2026
Merged

feat(frontend): agent playground sessions, files, and inspector#5306
bekossy merged 137 commits into
release/v0.105.3from
big-agents-add-sessions

Conversation

@ardaerzin

@ardaerzin ardaerzin commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Context

This branch builds the agent playground's session and files experience on top of big-agents. It adds client-side multi-session chat, a durable Files/drive surface with rich previews for what the agent reads and writes, a records-backed Inspector, and the supporting backend/runner pieces those surfaces depend on (file recency, durable-replay trace ids, resume-after-kill). Base is big-agents (now merged into main); the branch has been re-aligned several times.

Scope note: sessions are client-side (backend is not yet a source of truth)

The backend does not yet own sessions — there is no server session registry, list endpoint, title, or app link (foundational gaps tracked separately). So "sessions" here are a client-side construct: the session list lives in the browser (localStorage), and each session hydrates its durable content (the record log + mounts) by id. Cross-device / persistent session listing, server-side titles, and a session↔app link are out of scope for this branch and gated on that backend work. What ships is the playground UX over local sessions plus durable per-session content — not server-owned sessions.

What this adds

Sessions (client-side). Multi-session chat in the playground: a tab bar in build mode, a rail plus history popover in chat mode. Sessions are held in localStorage and hydrate durable content (records / mounts) per session id. New sessions autofocus the composer; never-run empty "husk" sessions are pruned so the local list stays clean; per-session drive/quick-look state is keyed by session id, so a backgrounded tab never clobbers the active one. Per the scope note, the list and titles are local, not server-owned.

Drive / Files. One Files surface across the config panel, the chat context rail, and a full two-pane drawer. It aggregates the session cwd mount and the agent's durable per-artifact mount (folded under agent-files/), tags each file Agent vs Session (with an explanatory tooltip), orders newest-first via the object store's mtime, and hides runner-internal artifacts (agents/…, .agenta-*); dot-prefixed files are dimmed in the drawer and dropped from the compact config list. The preview experience adds:

  • a clickable breadcrumb and a folder view — a folder's immediate children as a uniform tile grid, where subfolders drill in and files open the preview;
  • a fixed header (copy-path, a details toggle with an animated metadata grid, download) over scrollable content, consistent between the build two-pane drawer and the chat single-file view;
  • type-aware previews: scrollable markdown, syntax-highlighted code / JSON, CSV tables, PDF / image / audio / video, and HTML rendered in a sandboxed iframe — linked CSS and images are inlined from the same mount so multi-file sites render styled, and internal links open the target file in the drive (agent scripts / inline handlers are stripped; sandbox="allow-scripts" runs only a small link interceptor).

Unified Inspector. One records-backed panel replaces the old Turn and Session panels, with Timeline, Context, and Runtime lenses. While the panel is open you can click any assistant turn to focus it (gated on the panel being open, not on a turn already being selected). The Streams and State tabs live-revalidate during a run.

Backend / runner.

  • MountFile.mtime is threaded from the object store's LastModified, so "newest first" works no matter how a file was created (bash script, Write tool, upload). Previously recency came only from Write/Edit tool records, so bash-created files fell back to alphabetical.
  • The runner stamps the run's trace_id on the terminal done event, so a reloaded transcript links a replayed turn back to its trace.
  • A partial-unique index on session_streams (excluding soft-deleted rows) lets a resumed session mint a fresh stream row after a kill instead of colliding with the dead one.
  • A ReDoS-flagged slash-trim regex on mount paths was replaced with a linear scan (CodeQL).

Tests / notes

  • tsc and pnpm lint-fix are clean across the touched packages (no new type errors vs the big-agents baseline). CodeQL and the web production build pass.
  • Sessions are client-side pending backend session ownership (see the scope note): this branch does not implement a server session list / title / registry, and the description no longer implies persistent, server-owned sessions.
  • The HTML preview runs sandbox="allow-scripts" after stripping the agent's <script> / inline handlers / javascript: URLs / nested srcdoc, so only our link interceptor runs and the frame stays origin-isolated from Agenta. Residual risk if a script vector is missed; it still cannot reach Agenta's origin, DOM, or cookies.
  • Unit tests added: session abort-error handling, and the runner plus SDK wire-contract (trace_id on the done event).
  • This is a draft. Most of it is verified via type-check, lint, unit tests, and local UI checks, not a full end-to-end pass. The mounts / runner / trace backend came in from big-agents across the re-aligns.
  • NEXT_PUBLIC_AGENT_CHAT_STOP_KILLS_SESSION (Stop also kills the session) is behind a flag and still in progress; the resume-after-kill migration lands here to support it.
  • Known issue inherited from big-agents (not introduced here): SessionInspector/StreamsTab.tsx has a pre-existing SessionStream.status type error.
  • Feature-freeze context (per the team thread): several commits here are features, not pure bug fixes. Confirm sequencing against the freeze before merging.

What to QA

  • Sessions (browser-local). Create a few sessions with +, switch tabs, then reopen a closed one via the history clock. There is no cross-device list — sessions live in this browser. A new session focuses the composer; abandoning an empty one leaves no residue in the local history.
  • Files list. Run the agent so it writes files. The Files surfaces list them newest-first, tagged Agent vs Session when both kinds exist, and agent-files appear even before a conversation opens. Runner internals (agents/sessions/…, .agenta-*) must NOT appear, and dot-prefixed files (e.g. .claude/…) are hidden from the config list.
  • Preview drawer. Open the drawer, navigate via the tree and the clickable breadcrumb, open a folder (folder view of its children). Preview markdown (scrolls), code, and an HTML file: toggle Preview/Source, confirm linked CSS + images render, and that clicking an internal link opens that file. Copy-path and the Details (metadata) toggle work, and the file header stays pinned while content scrolls.
  • Inspector. Open it in build mode and click between assistant turns. The selected turn is marked and the panel refocuses on it; the Streams/State tabs update while a run is live.
  • Reload. Reload a conversation that has history. The transcript, per-turn token/cost metrics, and trace hover restore.
  • Regression. Toggle chat and build mode. The session bar, rail, and inspector divider do not jump, and the edge fade does not dim a lone tab.

ardaerzin added 30 commits July 10, 2026 21:35
Ports the parked session FE (@agenta/entities/session Fern+zod wrappers, the records-replay adapter transcriptToMessages, loadSession, + regression test) onto fe-chore/request-optimizations-onbig (= big-agents + FE/sdk/runner improvements).

AgentChatPanel was fully rewritten on that base (frame-split; the per-session useChat moved to AgentConversation.tsx), so the records-hydration hook is re-wired there as a follow-up, not carried from the old AgentChatPanel.
…tion

Re-attaches the records-replay hydration to req-opt's frame-split structure (the per-session useChat moved from AgentChatPanel to AgentConversation). Cache-first: on open with an empty local message cache, loadSessionMessages (queryRecords → v6 messages) seeds the conversation; own-run sessions with a warm cache skip it. Stamps seenIdsRef + restoredIdsRef so restored history renders settled, and arms the bottom pin.
…d/utils

ElicitationWidget imports partitionElicitationDraft from the utils barrel, but the elicitation-m1-followups change added it to elicitation.ts + the widget without adding it to index.ts — breaking the next-dev build. Present on big-agents too (base bug); this unblocks the local build.
…ig panel

Tool rows in the agent template's Tools section now surface resolution
problems without opening each drawer: a catalog action that 404s (renamed/
removed) or a missing connection shows a red Unresolved tag; a connection
with is_valid=false shows an amber Reconnect tag. Collapsed provider groups
roll up their worst child status, and the section header tooltip reflects it.

The probe reuses the drawer's action-detail query family (shared cache,
5-min staleTime) with a low-priority fetch so playground load is unaffected;
404s skip retries and transient errors never mark a row. GatewayToolsBridge
gains connectionsErrored so a failed connections fetch reads as unknown
rather than "all connections removed".
…y clients

- add session liveness primitive (deriveStreamNest / deriveSessionLifecycle /
  refineLifecycleWithSandbox) with 11 unit tests
- add killSession (DELETE /sessions/streams) to the session API
- thread lowPriority through querySessionRecords / fetchSessionStream /
  querySessionStreams; add per-resource sessions/mounts clients (normal + low-priority)
- zod boundary: interaction status as string, add interaction created_at
…session

- records replay: split assistant turns on `done`; hydrate empty-cache sessions from
  records with a transcript skeleton; guard brand-new sessions from the empty query
- SWR revalidate-on-open: refetch records low-priority, adopt only if server strictly ahead
- liveness badge in the tab dot (local run-state, else backend liveness) from ONE shared
  project-wide is_alive query; low-priority hydration + liveness
- End session (kill) in the history menu, shown only when the session is alive
…ctions status

- StreamsTab uses deriveStreamNest (deletes local nest.ts) + shows resumable/reattachable
- InteractionsTab reads status via the validated package query (string, not object.code)
Records what's done/pending/blocked for the agent-chat FE integration, the backend asks
(durable archive/delete cascade, session naming), and FOLLOWUP(sessions,*) code markers.
…ivation

- add queryMountFiles / readMountFile (mounts file ops) with zod schemas
- deriveMountRows: fold the flat whole-tree listing into a one-level folder/file view,
  plus mountBreadcrumbs (7 unit tests)
Upgrade the Mounts tab from a flat metadata list to a navigable folder/file tree with
text preview (drill-in client-side via deriveMountRows). Shows a clear notice when the
mount object store isn't configured. Doc updated (browser done, read-write follow-up,
store-config gotcha).
Replace the fixed-width TurnInspector with one adaptive right panel beside the chat:
- nested antd Splitter, persisted width, min/max + a chat-width floor so the panel can't
  squeeze the transcript; collapses to 0 when closed (chat never remounts)
- refined Turn|Session segmented switch; Turn = the migrated turn inspector (live messages),
  Session = session-scoped content (Mounts/State/Interactions, reusing the durable endpoints)
- rightPanelAtom replaces turnInspectorAtom; session view is valid in chat mode too
Group the left config panel: draftable config sections (model/instructions/tools/mcp/
skills/advanced) then an 'Operations' divider for sections that never enter a revision —
Triggers (moved out) and a new Mounts stub (placeholder for agent-level mounts, which
don't exist yet; a conversation's live files are in the chat session panel).
Triggers and Mounts render as regular sections; the labeled divider didn't
match any existing header style.
…on header

Replace the removed uppercase divider with an 'Operations' heading that
matches the config panel header's typography (13px semibold), so the two
regions read as peers.
Configuration, Triggers, and Mounts are now three peer regions with
identical header styling (the panel header's 13px semibold). Triggers and
Mounts leave the accordion: their headers carry the count/add-trigger and
coming-soon affordances, bodies render directly (Subscriptions/Schedules
sub-sections come from TriggerManagementSection).
…-style bars

Full-bleed 48px tinted bars with the bottom hairline — a visual copy of the
panel's sticky Configuration header — instead of inline text headings.
…r Triggers

The Triggers region bar drops its + (count only); its body now renders two
ConfigAccordionSection sections — Subscriptions and Schedules — with the
same icon/count/header-+ /collapse treatment as the template config
sections. Each section's + and empty-state link open its own create drawer
(subscription picker / schedule drawer), default-bound to the agent.
Slide the panel in/out (220ms width transition on the splitter panes)
instead of snapping. The transition is enabled only around open-state flips
so divider dragging stays free of rubber-banding, and the panel content is
retained through the close so it slides out instead of blanking.
The transition class was gated behind an effect-driven flag, so it landed a
commit after the width change and never animated. The panes now carry the
transition whenever the divider isn't being dragged (onResizeStart/End flip
it synchronously), and the close-hold is derived during render so content
is retained in the same commit that starts the collapse.
…uration

Move them out of AgentTemplateControl (where they sat inside the
Configuration section's body, under its sticky header) into a new
AgentOperationsSections rendered at the panel level. Each section wraps its
own sticky Configuration-style header bar, so the panel is three vertically
stacked sections and scrolling swaps the pinned header per region.
AgentConfigSkeleton drops its Triggers row (no longer a config section);
new AgentOperationsSkeleton renders the real Triggers/Mounts header bars
over pulsing bodies below the Configuration section while hydration is
pending or agent-ness is unknown — the panel holds its final three-section
shape from first paint, and no trigger queries fire for maybe-prompt apps.
Rename the Mounts region to Storage and give it config-style inner sections
like Triggers has: App drive (the agent's durable folder, #5215 — coming
soon) and Session drive (per-conversation working folder, browsable from
the session panel). 'Drive' over 'mount' — mounts name the mechanism, not
the thing. Skeleton updated to match.
The Session drive section now browses the ACTIVE conversation's files: the
app layer slots a SessionDriveContent body (chat scope + active tab -> the
shared mount file browser) into AgentOperationsSections' new sessionDrive
prop, keeping entity-ui decoupled from chat state. The section is
controlled-open so the browser and its queries mount only when expanded
(zero cold-load requests); no open conversation shows an explanatory empty
state, and MountsTab's empty copy drops the mount jargon.
@ardaerzin

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 16, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

The folder tree row selected and toggled on the same click, so collapsing
a folder also selected it and flipped the right pane to the folder view.
Split the caret (toggle only) from the row body (select only); the row now
reveals children on select but never collapses.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 6e332904-4236-425f-bf35-6179d27b401f

📥 Commits

Reviewing files that changed from the base of the PR and between 0c5f88e and 89eca3a.

📒 Files selected for processing (24)
  • web/oss/.gitignore
  • web/oss/package.json
  • web/oss/scripts/copy-pdf-worker.mjs
  • web/oss/src/components/AgentChatSlice/assets/markdown.tsx
  • web/oss/src/components/Drives/DriveDrawer.tsx
  • web/oss/src/components/Drives/DriveExplorer.tsx
  • web/oss/src/components/Drives/DriveFileRow.tsx
  • web/oss/src/components/Drives/FileThumb.tsx
  • web/oss/src/components/Drives/FilesDrawer.tsx
  • web/oss/src/components/Drives/FilesDrawerBody.tsx
  • web/oss/src/components/Drives/OriginTag.tsx
  • web/oss/src/components/Drives/StorageSection.tsx
  • web/oss/src/components/Drives/agentDrive.ts
  • web/oss/src/components/Drives/driveIcons.tsx
  • web/oss/src/components/Drives/driveKinds.ts
  • web/oss/src/components/Drives/driveTree.ts
  • web/oss/src/components/Drives/fileMeta.tsx
  • web/oss/src/components/Drives/pdfThumb.ts
  • web/oss/src/components/Drives/renderers.tsx
  • web/oss/src/components/Drives/useSessionDrive.ts
  • web/packages/agenta-entities/src/session/core/fileActivity.ts
  • web/packages/agenta-entities/src/session/core/mountBrowser.ts
  • web/packages/agenta-entities/src/session/core/pathUtils.ts
  • web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/SkillFormView.tsx
🚧 Files skipped from review as they are similar to previous changes (17)
  • web/oss/src/components/Drives/FilesDrawerBody.tsx
  • web/oss/src/components/Drives/driveIcons.tsx
  • web/oss/src/components/Drives/StorageSection.tsx
  • web/oss/src/components/Drives/OriginTag.tsx
  • web/oss/src/components/Drives/DriveFileRow.tsx
  • web/oss/src/components/AgentChatSlice/assets/markdown.tsx
  • web/oss/src/components/Drives/pdfThumb.ts
  • web/packages/agenta-entities/src/session/core/pathUtils.ts
  • web/packages/agenta-entities/src/session/core/mountBrowser.ts
  • web/oss/src/components/Drives/useSessionDrive.ts
  • web/oss/src/components/Drives/fileMeta.tsx
  • web/oss/src/components/Drives/FileThumb.tsx
  • web/oss/src/components/Drives/driveKinds.ts
  • web/packages/agenta-entities/src/session/core/fileActivity.ts
  • web/oss/src/components/Drives/agentDrive.ts
  • web/oss/src/components/Drives/driveTree.ts
  • web/oss/src/components/Drives/FilesDrawer.tsx

Comment thread web/oss/src/components/Drives/DriveExplorer.tsx
Comment on lines +277 to +284
const resolveRel = (dir: string, rel: string): string => {
const out: string[] = []
for (const seg of (dir ? dir.split("/") : []).concat(rel.split("/"))) {
if (seg === "" || seg === ".") continue
if (seg === "..") out.pop()
else out.push(seg)
}
return out.join("/")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Resolve root-relative URLs from the mount root.

resolveRel("docs", "/assets/logo.png") currently returns docs/assets/logo.png. Leading-slash links and assets should discard dir.

Proposed fix
 const resolveRel = (dir: string, rel: string): string => {
     const out: string[] = []
-    for (const seg of (dir ? dir.split("/") : []).concat(rel.split("/"))) {
+    const base = rel.startsWith("/") ? [] : dir ? dir.split("/") : []
+    const target = rel.replace(/^\/+/, "")
+    for (const seg of base.concat(target.split("/"))) {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const resolveRel = (dir: string, rel: string): string => {
const out: string[] = []
for (const seg of (dir ? dir.split("/") : []).concat(rel.split("/"))) {
if (seg === "" || seg === ".") continue
if (seg === "..") out.pop()
else out.push(seg)
}
return out.join("/")
const resolveRel = (dir: string, rel: string): string => {
const out: string[] = []
const base = rel.startsWith("/") ? [] : dir ? dir.split("/") : []
const target = rel.replace(/^\/+/, "")
for (const seg of base.concat(target.split("/"))) {
if (seg === "" || seg === ".") continue
if (seg === "..") out.pop()
else out.push(seg)
}
return out.join("/")
}

Comment on lines +298 to +309
async function fetchMountDataUri(
mountId: string,
projectId: string,
path: string,
): Promise<string | null> {
const blob = await fetchMountFileBlob({mountId, projectId, path})
if (!blob || blob.size > INLINE_ASSET_CAP) return null
return new Promise((resolve) => {
const reader = new FileReader()
reader.onload = () => resolve(typeof reader.result === "string" ? reader.result : null)
reader.onerror = () => resolve(null)
reader.readAsDataURL(blob)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Bound and throttle HTML asset expansion before downloading.

Promise.all launches one request per stylesheet/image, while INLINE_ASSET_CAP is checked only after the complete blob downloads. A small HTML file can trigger thousands of concurrent multi-megabyte requests. Deduplicate URLs, limit asset count/concurrency, and enforce the byte limit before or during transfer.

Also applies to: 338-359

Comment on lines +338 to +375
await Promise.all(
Array.from(doc.querySelectorAll<HTMLLinkElement>('link[rel~="stylesheet"][href]')).map(
async (link) => {
const href = link.getAttribute("href") ?? ""
if (!href || isExternalUrl(href)) return
const css = await fetchMountText(mountId, projectId, resolveRel(dir, href))
if (css == null) return
const style = doc.createElement("style")
style.textContent = css
link.replaceWith(style)
},
),
)

await Promise.all(
Array.from(doc.querySelectorAll<HTMLImageElement>("img[src]")).map(async (img) => {
const src = img.getAttribute("src") ?? ""
if (!src || isExternalUrl(src) || src.startsWith("data:")) return
const uri = await fetchMountDataUri(mountId, projectId, resolveRel(dir, src))
if (uri) img.setAttribute("src", uri)
}),
)

// Strip every agent-authored script vector so allow-scripts only runs our interceptor.
doc.querySelectorAll("script").forEach((s) => s.remove())
doc.querySelectorAll("iframe[srcdoc]").forEach((f) => f.removeAttribute("srcdoc"))
doc.querySelectorAll("*").forEach((el) => {
for (const attr of Array.from(el.attributes)) {
if (/^on/i.test(attr.name)) el.removeAttribute(attr.name)
else if (/^\s*javascript:/i.test(attr.value)) el.setAttribute(attr.name, "#")
}
})
doc.querySelectorAll("a[href]").forEach((a) => {
if (isExternalUrl(a.getAttribute("href") ?? "")) {
a.setAttribute("target", "_blank")
a.setAttribute("rel", "noopener noreferrer")
}
})

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Block remote subresources in untrusted HTML previews.

External stylesheets, images, embeds, and CSS URLs remain active. Opening an agent-authored file can therefore contact attacker-controlled hosts automatically. Inject a restrictive CSP allowing only inlined data: assets/styles and the trusted interceptor script, or strip remote subresources.

Also applies to: 469-472

Comment on lines +381 to +382
} catch {
return html

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Fail closed when HTML assembly throws.

The catch returns the original agent-authored HTML, which is then rendered with allow-scripts. Any assembly failure therefore bypasses all script and handler stripping. Return an inert escaped document or surface a preview error instead.

Also applies to: 469-472

…keyboard nav

Replace the harsh full-saturation accent bar/ring (which hooked the rounded
corner and read badly in dark mode) with a simple left-edge accent border,
square on list rows. Applies to the session/drawer selected rows and the
drive files' just-changed state.

Add a shared themed focus-visible ring across every drive surface (rows, tree,
folder tiles) in place of the browser default outline, and blur the drawer-open
triggers so an ESC-close no longer leaves a stuck ring.

Add keyboard navigation: arrow keys for the flat lists (config Files, chat rail),
the WAI-ARIA tree pattern for the explorer (up/down/left/right/expand/collapse),
and 2D arrows for the tile grids. The tree focuses its selected row on open so
arrows work immediately. Reuse the origin tag's tooltip copy on the All/Agent/
Session filter tabs.
…n sections

Both ConfigAccordionSection siblings fell back to key="empty" when their list
was empty, so opening the playground with no triggers logged a React duplicate-
key warning. Scope the fallbacks per section (no-subscriptions / no-schedules);
the has-* keys were already unique and the 0<->1 remount behaviour is preserved.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: dcbeef67-7e0f-42b2-a1cc-68276399967f

📥 Commits

Reviewing files that changed from the base of the PR and between 89eca3a and 7dc4788.

📒 Files selected for processing (10)
  • web/oss/src/components/Drives/ContextRail.tsx
  • web/oss/src/components/Drives/DriveExplorer.tsx
  • web/oss/src/components/Drives/DriveFileRow.tsx
  • web/oss/src/components/Drives/FilesWindow.tsx
  • web/oss/src/components/Drives/OriginTag.tsx
  • web/oss/src/components/Drives/StorageFilesHeader.tsx
  • web/oss/src/components/Drives/StorageSection.tsx
  • web/oss/src/components/Drives/driveKeyboard.ts
  • web/oss/src/styles/globals.css
  • web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/TriggerManagementSection.tsx
🚧 Files skipped from review as they are similar to previous changes (7)
  • web/oss/src/components/Drives/OriginTag.tsx
  • web/oss/src/components/Drives/StorageFilesHeader.tsx
  • web/oss/src/components/Drives/StorageSection.tsx
  • web/oss/src/components/Drives/ContextRail.tsx
  • web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/TriggerManagementSection.tsx
  • web/oss/src/components/Drives/DriveExplorer.tsx
  • web/oss/src/components/Drives/FilesWindow.tsx

Comment on lines +31 to +55
export const gridArrowKeyDown = (e: KeyboardEvent<HTMLElement>) => {
if (!["ArrowDown", "ArrowUp", "ArrowLeft", "ArrowRight", "Home", "End"].includes(e.key)) return
const items = Array.from(e.currentTarget.querySelectorAll<HTMLButtonElement>("button"))
if (!items.length) return
e.preventDefault()
const idx = items.findIndex((r) => r === document.activeElement)
const cur = idx < 0 ? 0 : idx
const top0 = items[0].offsetTop
const cols = Math.max(1, items.filter((it) => it.offsetTop === top0).length)
const focusAt = (i: number) => items[Math.min(Math.max(i, 0), items.length - 1)]?.focus()
switch (e.key) {
case "Home":
return focusAt(0)
case "End":
return focusAt(items.length - 1)
case "ArrowLeft":
return focusAt(cur - 1)
case "ArrowRight":
return focusAt(cur + 1)
case "ArrowUp":
return focusAt(idx < 0 ? 0 : cur - cols)
case "ArrowDown":
return focusAt(idx < 0 ? 0 : cur + cols)
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Fix idx < 0 skipping behavior and robustify column counting.

There are two issues with the grid navigation logic:

  1. If no item is currently focused (idx < 0), pressing ArrowRight or ArrowDown uses cur = 0 and adds 1 or cols, meaning it skips the first item entirely and focuses item 1 or cols instead.
  2. In FilesWindow.tsx, the grid items are wrapped in motion.div components. Framer Motion components often establish a containing block via transforms, meaning offsetTop for all buttons inside them will evaluate to 0. This would cause cols to equal items.length, breaking up/down navigation.

Handle idx < 0 upfront and use getBoundingClientRect().top for a robust, DOM-agnostic column calculation.

🛠️ Proposed fix
 export const gridArrowKeyDown = (e: KeyboardEvent<HTMLElement>) => {
     if (!["ArrowDown", "ArrowUp", "ArrowLeft", "ArrowRight", "Home", "End"].includes(e.key)) return
     const items = Array.from(e.currentTarget.querySelectorAll<HTMLButtonElement>("button"))
     if (!items.length) return
     e.preventDefault()
     const idx = items.findIndex((r) => r === document.activeElement)
-    const cur = idx < 0 ? 0 : idx
-    const top0 = items[0].offsetTop
-    const cols = Math.max(1, items.filter((it) => it.offsetTop === top0).length)
     const focusAt = (i: number) => items[Math.min(Math.max(i, 0), items.length - 1)]?.focus()
+
+    if (idx < 0) {
+        return e.key === "End" ? focusAt(items.length - 1) : focusAt(0)
+    }
+
+    // Using getBoundingClientRect() avoids offsetParent issues with animated wrappers (e.g., motion.div).
+    const top0 = items[0].getBoundingClientRect().top
+    const cols = Math.max(1, items.filter((it) => Math.abs(it.getBoundingClientRect().top - top0) < 5).length)
+
     switch (e.key) {
         case "Home":
             return focusAt(0)
         case "End":
             return focusAt(items.length - 1)
         case "ArrowLeft":
-            return focusAt(cur - 1)
+            return focusAt(idx - 1)
         case "ArrowRight":
-            return focusAt(cur + 1)
+            return focusAt(idx + 1)
         case "ArrowUp":
-            return focusAt(idx < 0 ? 0 : cur - cols)
+            return focusAt(idx - cols)
         case "ArrowDown":
-            return focusAt(idx < 0 ? 0 : cur + cols)
+            return focusAt(idx + cols)
     }
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export const gridArrowKeyDown = (e: KeyboardEvent<HTMLElement>) => {
if (!["ArrowDown", "ArrowUp", "ArrowLeft", "ArrowRight", "Home", "End"].includes(e.key)) return
const items = Array.from(e.currentTarget.querySelectorAll<HTMLButtonElement>("button"))
if (!items.length) return
e.preventDefault()
const idx = items.findIndex((r) => r === document.activeElement)
const cur = idx < 0 ? 0 : idx
const top0 = items[0].offsetTop
const cols = Math.max(1, items.filter((it) => it.offsetTop === top0).length)
const focusAt = (i: number) => items[Math.min(Math.max(i, 0), items.length - 1)]?.focus()
switch (e.key) {
case "Home":
return focusAt(0)
case "End":
return focusAt(items.length - 1)
case "ArrowLeft":
return focusAt(cur - 1)
case "ArrowRight":
return focusAt(cur + 1)
case "ArrowUp":
return focusAt(idx < 0 ? 0 : cur - cols)
case "ArrowDown":
return focusAt(idx < 0 ? 0 : cur + cols)
}
}
export const gridArrowKeyDown = (e: KeyboardEvent<HTMLElement>) => {
if (!["ArrowDown", "ArrowUp", "ArrowLeft", "ArrowRight", "Home", "End"].includes(e.key)) return
const items = Array.from(e.currentTarget.querySelectorAll<HTMLButtonElement>("button"))
if (!items.length) return
e.preventDefault()
const idx = items.findIndex((r) => r === document.activeElement)
const focusAt = (i: number) => items[Math.min(Math.max(i, 0), items.length - 1)]?.focus()
if (idx < 0) {
return e.key === "End" ? focusAt(items.length - 1) : focusAt(0)
}
// Using getBoundingClientRect() avoids offsetParent issues with animated wrappers (e.g., motion.div).
const top0 = items[0].getBoundingClientRect().top
const cols = Math.max(1, items.filter((it) => Math.abs(it.getBoundingClientRect().top - top0) < 5).length)
switch (e.key) {
case "Home":
return focusAt(0)
case "End":
return focusAt(items.length - 1)
case "ArrowLeft":
return focusAt(idx - 1)
case "ArrowRight":
return focusAt(idx + 1)
case "ArrowUp":
return focusAt(idx - cols)
case "ArrowDown":
return focusAt(idx + cols)
}
}

@bekossy
bekossy changed the base branch from main to release/v0.105.2 July 17, 2026 09:21
# Conflicts:
#	web/oss/package.json
#	web/pnpm-lock.yaml
@jp-agenta
jp-agenta changed the base branch from release/v0.105.2 to main July 17, 2026 10:13
@bekossy
bekossy changed the base branch from main to release/v0.105.2 July 17, 2026 12:46
…s-add-sessions

# Conflicts:
#	web/oss/package.json
#	web/pnpm-lock.yaml
@bekossy
bekossy changed the base branch from release/v0.105.2 to release/v0.105.3 July 17, 2026 13:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature Frontend lgtm This PR has been approved by a maintainer size:XXL This PR changes 1000+ lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants