Repository navigation
feat(agents): restore panes + session surfaces (R1–R3 of the architecture correction) - #2259
Conversation
…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
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
💡 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); |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
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.
| const workspace = workspaces[sessionId]; | ||
| if (!workspace) { | ||
| ensureWorkspace(sessionId, { | ||
| kind: 'chat', | ||
| name: initialConversation.name, | ||
| targetId: initialConversation.conversationId, | ||
| agentPageId: initialConversation.agentPageId, | ||
| }); |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
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.
| const access = await checkAccessForSubject(auth.userId, { | ||
| sessionId: 'about-to-be-minted', | ||
| ownerId: auth.userId, | ||
| driveId, | ||
| }); |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
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.
| 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); |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
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.
| // 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(); |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
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.
…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
🔬 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 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 Findings not anchorable to this diff:
Suggested fix order: H1 → H2/H2b → H3 → M1 ( 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 |
…gents-restore-panes
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
…gents-restore-panes
…gents-restore-panes
…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
…gents-restore-panes
…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
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)resolvePaneSurface, session-keyed persisted workspace store,AgentPanescontainer.R2 — session surfaces
?session=&c=&agent=via pushState (no navigation, shells survive clicks).POST /api/agent-sessionsmints the session AND its first conversation; the user lands inside it. No sandbox until first tool call/shell.conversations.sessionIdis ON DELETE SET NULL).useAssistantSessionChat(global pipeline sibling of the agent hook), one sharedSessionChatView, assistant offered in the pane picker, Assistant group + one-click spawn in the sidebar,POST /api/agent-sessions/[sessionId]/conversationsas the session-centric creator (the only way an assistant thread can join a session).R3 — agent page restoration (from
e479b0053^)grid grid-cols-3 max-w-lg, icons); History full-height tab (popover deleted); Save pinned in the header row.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.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 stalescripts/test-security.shpaths (files excluded from the default config since April / deleted) — pre-existing on master, untouched here.🤖 Generated with Claude Code
https://claude.ai/code/session_017V3eqwRX5cFy3Tdu2Syoro