Skip to content

feat(agents): restore panes + session surfaces (R1–R3 of the architecture correction) - #2259

Merged
2witstudios merged 30 commits into
pu/session-unconflatefrom
pu/agents-restore-panes
Jul 30, 2026
Merged

2witstudios merged 30 commits into
pu/session-unconflatefrom
pu/agents-restore-panes

Conversation

@2witstudios

Copy link
Copy Markdown
Owner

Stacked on #2258 (R0: session ≠ conversation un-conflation). Together they complete the corrective overhaul of the agents surface — epic ta6prjapfdcrfjyg3wi7gsv8, phases R1–R3.

What this restores / builds

R1 — pane system (restored from 623e632e7^, stripped of machine topology)

  • Pure columns-of-panes reducer + PaneBar + SessionPanes + PanePicker + resolvePaneSurface, session-keyed persisted workspace store, AgentPanes container.
  • Lifecycle invariant both directions: a session is born with its first conversation in its first pane; closing the LAST pane ends the session (store-level interception, container does the DELETE). Panes never appear in the sidebar.

R2 — session surfaces

  • Sidebar rewritten to Drive → Session → conversations; selection is ?session=&c=&agent= via pushState (no navigation, shells survive clicks).
  • Spawn is one act: POST /api/agent-sessions mints the session AND its first conversation; the user lands inside it. No sandbox until first tool call/shell.
  • Explicit End session on the row (confirmed; conversations remain as history — conversations.sessionId is ON DELETE SET NULL).
  • Global-assistant sessions: useAssistantSessionChat (global pipeline sibling of the agent hook), one shared SessionChatView, assistant offered in the pane picker, Assistant group + one-click spawn in the sidebar, POST /api/agent-sessions/[sessionId]/conversations as the session-centric creator (the only way an assistant thread can join a session).

R3 — agent page restoration (from e479b0053^)

  • Chat | History | Settings as real pills (grid grid-cols-3 max-w-lg, icons); History full-height tab (popover deleted); Save pinned in the header row.
  • The Chat tab hosts the pane grid for session-bound conversations (splitting is the only new affordance); session users get session-born page conversations, everyone else keeps plain chat unchanged.
  • Removals: AgentView + session-tabs + status chip + Add-shell + history popover (PR fix(agents): show shells the agent opens or closes #2256 and issue Agent-created shells never appear as tabs (no invalidation for spawn_shell/kill_shell) #2255 closed as superseded).
  • Sandbox switch restored: pages.sandboxEnabled (default off, successor to machineAccess), gating the bash/git/session tool families at request time and out of the Default Tools picker; provisioning stays lazy and automatic.

Verification

  • bun run typecheck ✅ · bun run lint ✅ · knip ratchet ✅
  • bun run test:unit: 15,014 web tests pass; the 3 failing files are the pre-existing env-only bucket (2 need the test Postgres, 1 is TZ-sensitive and passes under UTC) — all green when run against a real DB / UTC.
  • All 240 migrations (incl. new 0239 pages.sandboxEnabled) replay clean on fresh Postgres 17; the agent-sessions store integration suite (18 tests, incl. the two-conversations-one-sandbox payoff) passes against it.
  • test:security: 44/50; the 6 failures are stale scripts/test-security.sh paths (files excluded from the default config since April / deleted) — pre-existing on master, untouched here.
  • Outstanding: the R4 live-app E2E checklists (spawn → provision → Sprite kill in a running deployment, mobile viewport pass) — tracked on the epic's R4 board.

🤖 Generated with Claude Code

https://claude.ai/code/session_017V3eqwRX5cFy3Tdu2Syoro

2witstudios and others added 17 commits July 29, 2026 12:32
…logy

Ports the columns-of-panes reducer deleted in the phase-8 teardown
(623e632), whose removal the rebuild plan explicitly instructed —
"Don't carry pane-surface.ts/workspace-reducer/PaneBar forward" — and
which is why the agents surface ended up with a flat tab bar instead of
a split grid.

The transitions are unchanged: a horizontal row of columns, each an
independent vertical stack; splitRight adds a column, splitDown stacks
within one; deliberately not a recursive split tree. A split focuses the
new pane's picker rather than leaving a blank rectangle. Every transition
no-ops on an id it cannot resolve, so a stale click racing a close is
never an error.

What the port drops, and why:

- The machine topology. MachineNodeScope/OpenTerminalScope and the
  projectName/branchName plumbing existed because a grid hung off a
  Machine at a git checkout. Git is no longer the IA.
- The multi-workspace list. A Machine owned several named workspaces;
  the workspace unit is now the CONVERSATION, one grid keyed by its id.
  The sidebar's leaves are conversations, which is what its workspace
  leaves already were.
- Server-synced layouts (useMachineWorkspaceSync, the Server*DTO types,
  mergeServerWorkspaces). Layout is local and persisted, as intended.

A pane stores a PaneScope — kind ('chat' | 'terminal'), the id it
addresses, and for a conversation which agent it belongs to. That last
field is what lets one grid hold conversations with several different
agents side by side, and a null one is a global-assistant conversation.

Adds PANE_KINDS/paneScopeSchema to the session contract and corrects the
doc block that claimed PTY-only "by construction" for the whole pane
surface. That was a fact about the shells TABLE — sessions and shells are
now two tables — but it was read as a fact about what a pane can show,
which is what removed agent conversations from panes.

28 reducer tests + 7 contract tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017V3eqwRX5cFy3Tdu2Syoro
Ports PaneBar from the machine workspace grid (623e632^) unchanged
apart from its one piece of git topology.

One slim bar per pane: identity left, actions right, and the bar tint AS
the focus state. That single bar replaced two pieces of floating chrome —
the hover-revealed split/close chip, which on chat panes physically
covered the chat header's own controls, and the 2px top accent line.
Actions dim rather than hide (opacity, never display), so they stay
clickable on every pointer type without the coarse-pointer escape hatch
an opacity-0 chip needs.

Pure presentational: no store, no hooks, no network. A terminal pane and
a chat pane wear the same bar without either knowing about the other,
which is what let the old grid host both surfaces.

The topology drop: the checkout chip named a project/branch. That slot is
now the agent label, so a grid holding conversations with several
different agents says which is which.

All 8 original tests ported and passing, including the two that pin the
non-obvious rules: canSplit=false renders close only (a phone cannot hold
a split grid), and control clicks stopPropagation so closing a pane never
first re-selects it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017V3eqwRX5cFy3Tdu2Syoro
Ports pane-surface.ts from 623e632^, simplified by the model change but
keeping the rule that mattered.

The old version resolved a kind-less binding by looking the pane's NAME up
in a session list, because layouts predating the chat pane stored no kind.
Its safety rule for that lookup was that an unanswered list means
'loading', never a mounted Xterm — "opening a PTY stream registers this
pane as a viewer server-side, so guessing 'pty' for what turns out to be a
chat isn't a harmless flash, it's a connection."

There are no kind-less bindings now: paneScopeSchema requires kind, and
every binding is written by the picker that knew what it spawned. So the
name lookup is gone. The rule it protected is not — a pane bound to a kind
but not yet to a row renders 'loading', never a speculative terminal, and
a test pins that for the terminal case specifically since that is the one
where guessing costs a connection.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017V3eqwRX5cFy3Tdu2Syoro
Ports TerminalPanes from 623e632^ as SessionPanes. Layout is two
levels, never a recursive tree: a horizontal group of columns, each a
vertical group of panes, using the ResizablePanelGroup primitives that
survived the teardown.

Surfaces are INJECTED via renderPane rather than resolved here, so the
grid knows nothing about chat or terminals. That is what lets one grid
serve both the console and the agent page, and lets these tests run
without mounting an xterm.

The two narrow-viewport rules are the reason this is a port and not a
rewrite — both encode bugs that are invisible in a screenshot:

1. Inactive panes are HIDDEN, NOT UNMOUNTED. Unmounting a terminal emits
   a disconnect, drops this pane's viewer entry and, when it was the
   last, arms the idle reap. An agent finishing while its pane was
   off-screen would lose its final output and exit code, and returning
   would cold-start a fresh PTY instead of showing the completed run.

2. `invisible` (visibility:hidden), NOT `hidden` (display:none). xterm
   measures its character cell from the DOM at open(); in a display:none
   box that measurement is 0, the fit addon proposes no dimensions, and
   even the refit on re-show is a no-op — the pane stays blank for good.
   visibility:hidden also keeps offsetParent/clientWidth truthy, which is
   what the terminal's own visibility gate checks before it fits.

Both are pinned by tests; the second is mutation-verified (swapping to
`hidden` fails that test and nothing else). The pane strip is kept too:
once the grid collapses to one pane it is the ONLY route back to the
others.

Split/close handlers are deliberately not props here — the caller already
closes over them to build each pane's bar, so taking them would be a
second copy of wiring this component never calls.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017V3eqwRX5cFy3Tdu2Syoro
… a shell

The old machine grid's picker offered the two agent TYPES of one machine
— pagespace (Agent) or shell. This one offers a choice that surface could
not: WHICH AGENT the conversation belongs to, so a single grid can hold
conversations with several different agents side by side. That is the one
deliberate extension in this restoration; everything else is a port.

The global assistant is a first-class choice here, reported as a null
agentPageId — which is exactly what agent_sessions.agentPageId being
nullable already means. It is also the groundwork for reaching the global
assistant from the sidebar.

Presentational, like PaneBar: it renders choices and reports them.
Minting a conversation or a shell is IO and belongs to the container,
which is also the only thing that knows whether a pick should reuse an
existing row.

Two layout decisions worth naming:

- the drive's agents are listed BELOW the two fixed choices rather than
  merged with them. The list is unbounded, and a drive with forty agents
  must not push "Shell" off the top of a short pane.
- loading says "Loading agents…" rather than rendering an empty list,
  because "not answered yet" and "this drive has no agents" are different
  facts and only one of them is worth acting on.

autoFocus takes the first choice on mount, preserving the rule a split
already encodes: the user asked for something in this pane, not for a
blank rectangle with a control to go hunt for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017V3eqwRX5cFy3Tdu2Syoro
The IO shell for the pane grid: identity minting, persistence and
subscription. Every transition delegates to the pure reducer, so the
store holds no layout logic of its own — which is what keeps those rules
exhaustively testable without React.

Persisted, NOT synced. The old machine grid pushed layouts to the server
via useMachineWorkspaceSync; that is deliberately not restored. A layout
is a local view preference, and syncing it made every split a write.

Keyed by conversationId, because the conversation IS the workspace unit
now. Opening a conversation restores the grid you left it in, and the
PTYs behind those panes are still running server-side to reattach to.

Three behaviours worth naming:

- ensureWorkspace is idempotent. Re-opening a conversation must never
  discard the layout you built in it, and "give it a grid once" is the
  only sane reading of a mount effect that fires on every remount.
- a transition aimed at a grid that is GONE no-ops rather than throwing
  or fabricating one. A close can land after the conversation was deleted
  and its grid forgotten; there is nothing meaningful to build from a
  split of something absent.
- id minting falls back to a counter when crypto.randomUUID is
  unavailable, so a non-secure context still gets distinct pane ids
  instead of colliding on one.

39 tests across the reducer and the store.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017V3eqwRX5cFy3Tdu2Syoro
… off)

The picker offered "Assistant" while nothing could render the pick:
SessionChat resolves its display identity from an agent PAGE, and a
global-assistant conversation has none. A menu item with no renderer is
a dead choice — the same offered-but-unsupplied shape as the unwired
measureStorage seam found earlier.

The option stays built and tested; canPickAssistant (default false)
turns it on when the assistant identity path lands (tracked in the
epic's Phase R2). A test pins the default-off state so flipping it is a
deliberate act, not a drive-by.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017V3eqwRX5cFy3Tdu2Syoro
…he session

R1: rework the pane store onto the un-conflated model (this branch now
stacks on pu/session-unconflate).

The workspace unit is the SESSION — the thing that owns the sandbox
every pane shares by construction — so grids key on agent_sessions.id,
and newWorkspace takes a sessionId with its opening pane bound to the
session's FIRST conversation (a session is born with one).

Closing the LAST pane ends the session. The pure reducer's closePane
still no-ops on it — a WorkspaceState transition cannot delete its own
container — and the STORE is the container, so the interception lives
there: the grid is removed and closePane returns 'session-ended', the
caller's signal to tear the sandbox down as the same act. This is the
container level the old machine closePaneIn owned, dropped in the
original port and restored where it belongs. 'closed' and 'noop' verdicts
let the caller distinguish a layout change from a stale click.

Layout transitions, PaneBar, SessionPanes, PanePicker and
resolvePaneSurface survive unchanged — all sandbox-agnostic by design.

78 pane-layer tests, typecheck 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017V3eqwRX5cFy3Tdu2Syoro
R1's last structural piece. Composes the pure pane pieces into a working
grid for one session:

  SessionPanes (layout only, surfaces injected)
   └─ pane: PaneBar (identity + split/close) over
       picker   → PanePicker  (choose an agent in the drive, or a shell)
       chat     → PaneChat    (a conversation IN this session)
       terminal → Shell       (a PTY on this session's sandbox)
       loading  → spinner     (bound, row not minted — never speculative)

The container owns ALL the IO a pick triggers and writes the resulting
PaneScope back through assignPane:

- Agent pick: mints a conversation id and POSTs it to the page-agents
  conversations route with { sessionId } — the route (extended here)
  gates the binding on checkSessionAccess and creates the thread already
  BOUND to the workspace, so its tool calls resolve the shared sandbox by
  construction. The pane holds `loading` (kind set, target null) during
  the mint, and error paths reset it to the picker — a pane stuck on
  loading forever is a dead pane.
- Shell pick: POSTs the session's shells route; the session must already
  exist (a shell opens inside a workspace, never creates one).
- Close: the store's 'session-ended' verdict (last pane) triggers the
  DELETE — emptying the session ends it, one act — with a toast when the
  teardown IO fails, since a silent failure bills until reclaim.

PaneChat exists because SessionChat takes a resolved AgentInfo and hooks
cannot run inside a render-prop map: each pane resolves its OWN agent,
which is also what lets one grid hold conversations with different
agents side by side. A null agentPageId renders a notice (assistant
identity path is Phase R2; the picker doesn't offer it until then).

78 pane-layer tests; typecheck 0; lint clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017V3eqwRX5cFy3Tdu2Syoro
…s first conversation

R2's spawn flow, server side. One act: mint the workspace row and create
its first conversation (chosen agent) already BOUND to it — a session is
never empty, and the grid never starts on a picker.

Spawn is instant and free: nothing here provisions. The route does not
even import the provisioner, so "no sandbox until first use" is
structural, and the test says so rather than asserting a mock was quiet.

If the first conversation fails (squat guard, dead agent), the
just-minted session is ENDED before the error returns — the model says
an empty workspace cannot exist, so the failure path enforces it too.

Access runs the same shared decision every session surface uses
(drive membership + capability), applied to the row-to-be BEFORE
anything is minted. Global-assistant spawns (null drive) are refused
until the assistant identity path lands; the client picker does not
offer them.

Also extended earlier in this branch: the page-agents conversations POST
accepts { sessionId } to bind a new thread into an existing workspace,
gated on checkSessionAccess — the pane picker's "new conversation in
THIS session" path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017V3eqwRX5cFy3Tdu2Syoro
…onversations

The left sidebar's tree now matches the corrected model: a SESSION (drive-level
workspace) is the second level, its CONVERSATIONS the third — panes never
appear here (layout is centre-view state). Selecting a session opens its most
recent conversation; selecting a conversation carries session+c+agent as one
pushState transition, so nothing navigates and live shells survive every click.

- lib/agents/agent-selection.ts: grammar is ?session=&c=&agent= (stable order)
- stores/agents/useAgentSurfaceStore: selectSession / selectConversation,
  session switch clears the conversation (it belonged to the old workspace)
- AgentsSurface renders AgentPanes keyed by session; degenerate deep links get
  prompts, never a speculative grid
- AgentsSidebar rewritten: sessions via /api/agent-sessions (admin-gated null
  SWR key), per-session New-conversation, one-act New-session (server answers
  with session + first conversation — no empty-session state is ever visible)
- GET /api/agent-sessions now attaches conversations per session
  (listSessionConversations); POST spawn already landed
- tests rewritten to pin the new grammar, store API, sidebar tree and route

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017V3eqwRX5cFy3Tdu2Syoro
The lifecycle already ends a session when its last pane closes; this adds the
sidebar-side act for a session you don't want to open first. Confirmed via
AlertDialog (the sandbox dies — never one accidental hover-click). On confirm:
DELETE the session, forget its local pane grid, clear the selection if it was
open, refetch the list. Conversations remain as history in each agent's list
(conversations.sessionId is ON DELETE SET NULL).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017V3eqwRX5cFy3Tdu2Syoro
… sidebar group

The assistant becomes a first-class session inhabitant. The gap was display
identity + creation paths; the pipeline itself (type:'global' rows, owner-only
access for null-drive sessions, owner-as-tenant provisioning) already existed.

- useAssistantSessionChat: useAgentSessionChat's sibling on the global chat
  pipeline (global channel, /api/ai/global/[id]/messages, global loaders,
  buildGlobalChatRequestBody; no per-hook socket — GlobalChatProvider is
  app-wide). Same return shape, so one view renders both.
- SessionChat split into SessionChatView (presentation) + the agent wrapper;
  new AssistantSessionChat wrapper reads identity from the assistant settings
  store. PaneChat's null-agent branch now renders it instead of a notice.
- PanePicker offers the Assistant in every session (canPickAssistant on);
  AgentPanes routes the pick through the new session-centric creator.
- POST /api/agent-sessions/[sessionId]/conversations: threads born INTO a
  session — session access + (for agent pages) canPrincipalViewPage layered,
  agentPageId null = assistant thread (the only way one can join a session).
- POST /api/agent-sessions accepts the both-null shape: a global-assistant
  session (owner-only), first conversation an assistant thread. Half-specified
  shapes still 400.
- Sidebar: Assistant group always present in global mode (one-click spawn, no
  chooser), assistant new-conversation via the session route.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017V3eqwRX5cFy3Tdu2Syoro
…at tab

The drive AI_CHAT page gets its good UI back, with panes as the ONE addition:

- Chat | History | Settings are real tabs again: grid grid-cols-3 max-w-lg
  pills with icons in the p-4 border-b header block — the same Tabs defaults
  every other tabbed surface uses. The full-bleed rounded-none bar is gone.
- History is a full-height TAB (PageAgentHistoryTab is h-full + virtualized;
  the 320px popover gave it no height to resolve against — deleted).
- Save Settings is pinned in the header row beside the tabs; Webhooks is back
  to the icon-only ghost button.
- The Chat tab hosts the PANE GRID for a session-bound conversation
  (AgentPanes, full page renderer via chatContext) — split-capable, every pane
  sharing the session's one sandbox by construction. Sessions are
  capability-shaped, so new conversations are born WITH a session for session
  users (spawn route; refused spawns fall back to plain) and plain for
  everyone else; pre-session threads render the plain chat (binding is set at
  creation and permanent — an old thread cannot join a workspace).
- Conversations now carry sessionId through the listing (repo SQL, GET route,
  ConversationData) so History selection lands in the right surface, and
  "Open in Agents" deep-links ?session=&c=&agent= when bound.
- Removed: AgentView + its tabs container, session-tabs.ts, session-status.ts
  (status chip copy), useAgentSession/useSessionShells, the Add-shell button
  and its provisioning path, the history Popover. No sandbox chrome remains —
  provisioning is lazy and automatic.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017V3eqwRX5cFy3Tdu2Syoro
…stored)

DECIDE settled as (a): a per-agent boolean, pages.sandboxEnabled (default
false — code execution is opt-in per agent, as machineAccess was). The old
Machine Access card returns as a plain Sandbox card: one Switch, no machine
topology (there is nothing to pick — the sandbox belongs to the conversation's
SESSION and provisions automatically on first use).

- schema: pages.sandboxEnabled boolean NOT NULL DEFAULT false (0239, additive)
- tool-filtering: SANDBOX_TOOL_NAMES = core (bash/files) ∪ git+gh ∪
  session/shell families; filterToolsForSandboxEnablement strips ALL of them
  (reads included — this is agent configuration, not the read-only gate).
  Drift-guarded against createSandboxTools' actual keys.
- request-time gates: /api/ai/chat (also covers spawned workers, which
  dispatch through it) and both /api/v1/chat/completions branches. The
  allowlist cannot re-grant a stripped tool. Env kill-switch + canRunCode
  remain the security boundaries underneath.
- agent-config GET/PATCH round-trips sandboxEnabled.
- settings tab: Sandbox card + Switch; Default Tools hides the sandbox
  families while the switch is off (the old MACHINE_TOOL_NAMES behaviour).
- also fixes a stale expectation from e8cb425 (global lifecycle never
  carried sessionId).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017V3eqwRX5cFy3Tdu2Syoro
resolveAgentPageDriveId and findSessionConversation lost their last callers
when sessions stopped being addressed through conversations/agent pages.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017V3eqwRX5cFy3Tdu2Syoro
@coderabbitai

coderabbitai Bot commented Jul 30, 2026 •

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 5447e7f1-dd9e-4be0-a9ec-0648c9992ee7

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 023ae2c1e4

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".


const [activeTab, setActiveTab] = useState<string>('chat');
const [webhooksOpen, setWebhooksOpen] = useState(false);
const [agentConfig, setAgentConfig] = useState<AgentConfig | null>(null);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Load the agent config before showing Settings

agentConfig is initialized to null, but the previous effect that fetched /api/pages/${page.id}/agent-config was removed and no replacement populates this state. Its only setter is passed to PageAgentSettingsTab, while that child returns its loading spinner whenever config is null, so opening Settings on any agent page now displays “Loading agent configuration...” forever and Save Settings cannot work.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in 7ca594cdd: the abortable /api/pages/[id]/agent-config fetch effect is restored, with a regression test pinning that the Settings tab receives config (mock asserts data-has-config="true" after the fetch). Leaving open for your verification.

Comment on lines +89 to +96
const workspace = workspaces[sessionId];
if (!workspace) {
ensureWorkspace(sessionId, {
kind: 'chat',
name: initialConversation.name,
targetId: initialConversation.conversationId,
agentPageId: initialConversation.agentPageId,
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Honor selected conversations in an existing workspace

Once this session already has a workspace—especially after persistence hydration—selecting another conversation in the same session only changes initialConversation; AgentsSurface keeps the component keyed by sessionId, and this block ignores the new conversation whenever workspace exists. Consequently the URL and sidebar selection move to the requested conversation while the pane grid continues showing the old one; the existing workspace needs to focus a pane already showing the target or open/bind the selected conversation.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in 5dc27aeee: new store action openConversation — seeds a fresh grid, FOCUSES the pane already showing the thread (via the previously-unused paneShowing), otherwise opens it in the active/first non-terminal pane (never over a running PTY; splits right when every pane is a terminal). AgentPanes drives it from an effect on (sessionId, conversationId), so mount seeding and later selections share one path — the sidebar's row/conversation/New-conversation clicks and the page's History picks now visibly open the thread. Five store tests pin the policy. Leaving open for your verification.

Comment on lines +135 to +139
const access = await checkAccessForSubject(auth.userId, {
sessionId: 'about-to-be-minted',
ownerId: auth.userId,
driveId,
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Validate the initial agent before spawning a session

For the { driveId, agentPageId } spawn shape, authorization checks only the supplied drive and never resolves or permission-checks agentPageId. Because createConversation accepts any page ID without a foreign-key or access check, a crafted or stale request can successfully create a session whose first conversation points at a nonexistent, non-agent, cross-drive, or inaccessible page; apply the same getAiAgent and centralized page-view check used by the conversation routes before minting the session.

AGENTS.md reference: AGENTS.md:L119-L124

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in ca3689db9: the spawn shape now runs getAiAgent (404 for missing/trashed/non-agent pages), an explicit agent.driveId === driveId check (400), and canPrincipalViewPage (403 + denial audit) BEFORE minting anything — plus the central binding gate from d2bcdba77 as defense in depth. Session names are also trimmed and capped at 120 chars. Leaving open for your verification.

Comment on lines +203 to +206
const sessionId: string | null =
typeof body.sessionId === 'string' && body.sessionId.length > 0 ? body.sessionId : null;
if (sessionId !== null) {
const sessionAccess = await checkSessionAccess(auth.userId, sessionId);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Keep session-bound agents in the session's drive

When a user can view an agent in drive B and access a session in drive A, these independent checks accept the combination and bind the drive-B conversation into the drive-A session. Sandbox provisioning and session authorization subsequently derive their tenant and capability scope from the session row's driveId, creating a cross-drive workspace and billing/authorization mismatch; verify that the resolved agent's driveId equals the target session's driveId before binding it, including in the session-centric conversation endpoint.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in d2bcdba77 (+ca3689db9 for the spawn route): the drive equality check lives at the CENTRAL binding path (createConversationInSessionWith), so both conversation routes AND the worker-spawn tool get it without per-site checks — AgentNotInSessionDriveError, fail-closed on unresolved facts, global sessions host only assistant threads. Leaving open for your verification.

Comment on lines +352 to +358
// The session leaves the sidebar; its conversations remain as history in
// each agent's list. Drop the local grid too — its panes pointed at a
// sandbox that no longer exists.
forgetWorkspace(session.sessionId);
if (selectedSessionId === session.sessionId) selectSession(null);
setConfirmingEnd(false);
onChanged();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Hide ended sessions from the active session list

After DELETE succeeds, endAgentSession deliberately retains the database row and stamps it ended, but onChanged() immediately refetches an unfiltered listSessions result. Since the list store does not exclude endedAt, the supposedly ended session remains in or reappears in the sidebar instead of leaving as this handler expects, and selecting it can provision it again; active sidebar results need to filter out ended rows or the client must explicitly omit them.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in f6a94e22d: store.list() now excludes endedAt IS NOT NULL rows (plus newest-first ordering and a 100-row cap), so the refetch after DELETE no longer resurrects the ended session — the handler's expectation is now the store's behavior. Ended rows remain for lifecycle/billing but are not listings. Leaving open for your verification.

2witstudios added a commit that referenced this pull request Jul 30, 2026
…on; baseline stack-consumed exports

- resolveAgentPageDriveId / findSessionConversation lost their callers in the
  un-conflation itself — deleted (same cleanup already on pu/agents-restore-panes).
- The global stream-lifecycle test expected a sessionId the lifecycle never
  carries — a stray from e8cb425's sweep.
- checkAccessForSubject / spawnSession are this phase's contract surface; their
  callers are the very next PR in the stack (#2259, which removes these
  baseline entries again). Baselined rather than deleted so the phase ships
  its deliverable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017V3eqwRX5cFy3Tdu2Syoro
…gents-restore-panes

# Conflicts:
#	apps/web/src/lib/agent-sessions/agent-sessions-runtime.ts
Comment thread apps/web/src/lib/agent-sessions/agent-sessions-runtime.ts
Comment thread apps/web/src/components/agents/panes/AgentPanes.tsx Outdated
Comment thread apps/web/src/components/agents/AgentPageView.tsx Outdated
Comment thread apps/web/src/components/agents/AgentsSurface.tsx Outdated
Comment thread apps/web/src/app/api/agent-sessions/route.ts
Comment thread packages/lib/src/agent-sessions/contract.ts Outdated
Comment thread apps/web/src/lib/ai/shared/hooks/useConversations.ts Outdated
Comment thread apps/web/src/components/layout/left-sidebar/AgentsSidebar.tsx
@2witstudios

Copy link
Copy Markdown
Owner Author

🔬 Review summary (full pass over #2258 + #2259 as a unit)

Verdict: architecture is right; not merge-ready yet. One high-severity access-control hole (H1, inline), one broken tool family (H2/H2b, on #2258), and a cluster of half-wired seams — mostly hidden because unit fakes still encode the old sessionId ≡ conversationId model exactly where the bugs live.

OWASP Top 10 sweep: A01 — violations (H1 here, H3/M6); A02 clean (HMAC-SHA3-256 sprite keys, fail-closed secret); A03 clean (parameterized SQL incl. the new sessionId column; argv-array exec with --prefix guards); A04 — H1/H3 root causes; A05 clean (note: canRunCode's app-admin rung is NODE_ENV==='production'-only, so non-prod deploys with CODE_EXECUTION_ENABLED expose session routes to all authenticated users); A06 clean (no new deps); A07 clean (auth+CSRF on all mutations, realtime origin checks); A08 — H1 (no DB-level binding immutability); A09 adequate (gap: a hijack audits only under the attacker's session); A10 clean.

Findings not anchorable to this diff:

  • "End" isn't an end: any subsequent ensure (chat tool call, shell open) silently revives an ended session with a fresh empty filesystem mid-turn, no signal to model or user; no live-stream/shell guard on DELETE (plan-session-lifecycle.ts revive + the tools' ensure path).
  • Dead code: session-anchor.ts (whole module, zero prod importers, canonical stale docblock); running-badges.ts + useUserActiveStreams.ts (dropped by the sidebar rewrite — also a lost behavior: no live "agent running" badges, only the sandbox dot); pending-prompt scaffolding (pendingPrompt/clearPanePrompt/Shell.initialInput — wired at neither end); EMPTY_AGENT_SELECTION; PaneSessionIdentity.label (the "which agent is this pane" affordance promised in PaneBar's docblock and not delivered — every chat pane just says "Conversation").
  • Plain conversations + sandboxEnabled: tools appear but every call fails with the generic "Could not provision a sandbox for this run" — the no_session cause never becomes an actionable message.
  • Deep-link/URL grammar: no invariant that c belongs to session — a stale/crafted link seeds a grid framed as session Y whose chat pane executes against session X's sandbox while its shell panes use Y's; foreign/deleted session links mint phantom persisted workspaces that are never GC'd.
  • Two-tab races: the last-pane-close verdict is computed from one tab's localStorage; the other tab's stale grid resurrects the map on its next write (zustand persist has no cross-tab sync).
  • Test gaps: AgentPanes (the one component owning all pane IO) has no test while its presentational children all do; no adversarial test exercises the binding UPDATE or shell verbs against the real store.

Suggested fix order: H1 → H2/H2b → H3 → M1 (paneShowing focus-or-open) → M2/M3 → doc-rot sweep in one mechanical pass.

Epic adherence: all R0–R3 board tasks implemented as specced; R4's two live-app E2E tasks remain open by design.

🤖 Generated with Claude Code

2witstudios and others added 12 commits July 29, 2026 23:43
Brings up the base branch's security fixes (congenital binding, one-namespace
shell/session addressing, real end-capability) and adds the #2259-side route
mapping: ConversationUnavailableError → 409 with an authz.access.denied audit
on both conversation-creating routes (a state conflict, not a 5xx). Restores
the conversations schema import the merge dropped (listSessionConversations
needs it here).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017V3eqwRX5cFy3Tdu2Syoro
…opped

PageAgentSettingsTab renders its loading state until config arrives, and the
page never fetched it — Settings showed 'Loading agent configuration...'
forever and Save could not work (codex review, P1). The abortable
/api/pages/[id]/agent-config effect is back, with a test pinning that the
Settings tab actually receives data.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017V3eqwRX5cFy3Tdu2Syoro
…es (M6)

Merges the base branch's central cross-drive binding gate and adds the
spawn-route half: getAiAgent (404 for a missing/trashed/non-agent page), an
explicit agent.driveId === driveId check (400 — the central gate would refuse
the mismatch anyway, but failing here means no session row is minted and then
rolled back), and canPrincipalViewPage (403 + denial audit), matching the
sibling conversation routes. Session names are trimmed AND capped at 120
chars — a label rendered everywhere must stay bounded.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017V3eqwRX5cFy3Tdu2Syoro
…uota)

- The collection GET now fetches children in TWO bulk queries however many
  sessions are listed (listShellsBulk + listSessionConversationsBulk),
  replacing the 1+2N-per-poll shape; the now-caller-less singular
  conversation listing is deleted.
- Spawn gets a ceiling: countActiveSessionsForOwner >= 100 answers 429 via
  the shared quota response — spawn stays instant and free, but not
  unbounded (the live-sandbox concurrency quota never applied to it).

With the base branch's store bounds (active-only, ordered, limited), the
sidebar now shows a stable newest-first slice, ended sessions actually leave
it, and each poll costs 3 queries.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017V3eqwRX5cFy3Tdu2Syoro
…ting grids (M1)

ensureWorkspace only seeded FRESH grids, so clicking a conversation the
persisted layout didn't already show changed the URL and highlight and
nothing else — the sidebar's core click paths (row open, conversation pick,
per-session New conversation) and the page's History picks were no-ops on
any revisited session (review M1 + codex 94QV; spec #20).

New store action openConversation(sessionId, scope): seeds when no grid
exists; focuses the pane already showing the thread (paneShowing, previously
exported-and-unused); otherwise opens it in the active pane when that pane is
a chat/picker, or the first non-terminal pane — never over a running PTY
(its only surface) — and with every pane a terminal, splits right.
AgentPanes drives it from an effect on (sessionId, conversationId), covering
mount seeding and later selections through one path. Five store tests pin
the policy.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017V3eqwRX5cFy3Tdu2Syoro
…ches every pane (M2)

- AgentPageView renders the pane grid only for canUseSessions users: a
  non-admin landing on a shared session-bound thread (their most-recent
  conversation can be one) got a grid whose every affordance 403s — except
  last-pane-close, which destroyed the session. They now get the plain chat
  they can actually use.
- isReadOnly threads through AgentPanes → PaneChat → SessionChat/
  AssistantSessionChat, and SessionChatView's console context now withholds
  edit/delete/retry like the page context (SidebarMessagesContent handlers
  widened to optional — CompactMessageRenderer already hides affordances for
  absent handlers, so existing callers are unchanged).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017V3eqwRX5cFy3Tdu2Syoro
AgentsSurface passed the console's own drive to AgentPanes — null on
/dashboard/agents — so in global mode every drive session's pane picker had
no agents to offer. The surface now resolves the selected session's record
(GET /api/agent-sessions/[sessionId], giving that endpoint its first UI
caller) and passes the session's real driveId, with the surface's drive
covering the in-flight window. Test pins the global-mode case.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017V3eqwRX5cFy3Tdu2Syoro
…s; last lows

Doc-rot sweep (review + codex convergence): every surviving 'sessionId ≡
conversationId' / 'a conversation IS a session' / AgentView / machines
reference is rewritten to the real model — contract.ts, the MODEL-FACING
system-prompt sessions bullet, tool-filtering, build-session-chat-request,
pane-reducer/workspace-store comments, useAgentSessionChat, SessionChat,
Shell, useResolvedAgent, git-tool-runners, sandbox-storage-measure, both
agent-sessions route docs (incl. the end-access doc updated for H3), the
agent-sessions service list doc, and PanePicker's canPickAssistant doc (the
identity path exists now).

Deleted dead modules: session-anchor.ts (the conflated model's anchor, zero
prod importers), running-badges.ts + useUserActiveStreams.ts (dropped by the
sidebar rewrite; the sandbox dot is the running signal now), and the
pending-prompt scaffolding (pendingPrompt/setPanePendingPrompt/
clearPanePrompt — wired at neither end; Shell/XtermTerminal keep their
initialInput capability, which is coherent on its own).

Lows: the no_session sandbox denial is now its own reason with actionable
copy ('start a new conversation') instead of the generic provision failure;
optimistic conversation entries carry sessionId so a broadcast-created
session thread doesn't render as plain chat until refetch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017V3eqwRX5cFy3Tdu2Syoro
@2witstudios
2witstudios merged commit 3c17ef2 into pu/session-unconflate Jul 30, 2026
1 check passed
@2witstudios
2witstudios deleted the pu/agents-restore-panes branch August 14, 2026 13:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant