30 KiB
Orchestration Pill Bar in Shared Session Web Viewer
Context
See PRODUCT.md for user-visible behavior.
Current pill bar (native, non-shared)
The native pill bar renders in app/src/ai/blocklist/agent_view/orchestration_pill_bar.rs as a View that reads from BlocklistAIHistoryModel. Child conversations are created by start_new_child_conversation() in app/src/ai/blocklist/history_model.rs (398-433), which registers the parent-child relationship in a children_by_parent: HashMap index. The pill bar queries child_conversations_of(parent_id) to build its pill list.
The pill bar is instantiated in TerminalView and rendered in app/src/terminal/view/pane_impl.rs (528-537) as a secondary header row, gated on FeatureFlag::OrchestrationPillBar and AgentView and the agent view being fullscreen.
Pill click actions dispatch TerminalAction::SwitchAgentViewToConversation (in-place switch), OpenChildAgentInNewPane, OpenChildAgentInNewTab, or RevealChildAgent. The last three depend on WorkspaceRegistry and PaneGroup APIs that don't exist on WASM.
Current shared session viewer
The viewer connects via WebSocket to the session-sharing server, receiving DownstreamMessage::OrderedTerminalEvent events serialized as JSON. Events are defined in the external session-sharing-protocol crate (pinned at rev 3a12b87 in Cargo.toml:252).
AgentResponseEvent is the only event type carrying agent conversation data. It includes:
response_event: String— base64-encoded protobufResponseEvent(fromwarp_multi_agent_api)response_initiator: Option<ParticipantId>forked_from_conversation_token: Option<ServerConversationToken>
The fork mechanism already supports creating new conversations from a forked token via link_forked_conversation_token() in app/src/ai/blocklist/controller/shared_session.rs (591-622). However, it treats forks as sequential continuations, not hierarchical parent-child relationships.
The gap
Three things are missing:
-
No child agent discovery in shared sessions. The viewer has no mechanism to learn that the parent session spawned child agents, what their names/statuses are, or how to access their transcripts. Child tasks exist server-side with their own shared sessions, but the viewer doesn't query for them.
-
No parent-child conversation registration. The pill bar renders from
BlocklistAIHistoryModel.child_conversations_of(parent_id). In shared sessions, no child conversations are created and no parent-child relationships are registered, so the pill bar has no data. -
Pill bar has WASM-incompatible dependencies.
is_conversation_open_in_other_visible_view()andpane_group_id_containing_terminal_view()inorchestration_pill_bar.rs (1696-1750)callWorkspaceRegistryandPaneGroupAPIs that don't exist in the WASM target. The 3-dot menu dispatches pane-split actions unavailable on web.
Architectural lessons from the V1 attempt
An initial implementation routed all child shared-session traffic through the parent viewer's TerminalView + BlocklistAIController, opened child Networks on the parent's TerminalManager, and switched the parent's agent view in place when the user clicked a child pill. That approach is structurally incompatible with the existing app in three ways:
BlocklistAIControlleris single-stream.shared_session_state.current_response_idis a singleOption<ResponseStreamId>set inon_shared_initand read byon_shared_client_actions.ClientActionscarry no stream identifier, so two Networks feeding the same controller can interleave and route one stream's actions into the other stream's conversation.- Conversation ownership is single-view.
BlocklistAIHistoryModel.live_conversation_ids_for_terminal_viewtreats each conversation as owned by exactly one view, andset_active_conversation_idtransfers ownership. Any flow that materializes a hidden child pane (e.g.restore_missing_child_agent_panes_for_parent, triggered onAgentViewControllerEvent::EnteredAgentViewfor a parent that has children registered) hands the child conversation to that new pane and the parent view's in-place switch then fails the ownership check. - The parent session emits a fresh
Initfor every request the orchestrator processes. EachInitrunsset_pending_query_state_for_existing_conversation(parent), which callstry_enter_agent_view(parent), which exits any child agent view the user navigated to and re-enters the parent's. This refiresEnteredAgentView, which refires pane materialization, which then transfers child conversation ownership all over again.
The local-orchestration UI already has a clean answer to all three problems: each child agent gets its own TerminalView, BlocklistAIController, conversation, and (for remote children) Network. The pill bar is a navigation affordance over the pane group, and clicking a pill swaps the visible pane via SwapPaneToConversation. Shared-session viewing of an orchestration should mirror that architecture rather than invent a new pattern.
Relevant files
Pill bar (shared with native):
app/src/ai/blocklist/agent_view/orchestration_pill_bar.rs— pill bar View (1994 lines);pill_specs()(418-479),is_conversation_open_in_other_visible_view()(1696-1724),pane_group_id_containing_terminal_view()(1731-1750)app/src/ai/blocklist/history_model.rs (388-450)— parent-child conversation index (child_conversations_of,start_new_child_conversation,set_parent_for_conversation)app/src/terminal/view/pane_impl.rs (503-543)— pill bar rendering integrationcrates/warp_features/src/lib.rs (665-720)— feature flags (OrchestrationPillBar,Orchestration)
Shared session viewer:
app/src/terminal/shared_session/viewer/terminal_manager.rs (730-750)— task_id extraction fromSessionSourceType::AmbientAgentapp/src/terminal/shared_session/viewer/network.rs— WebSocket session connection managementapp/src/terminal/shared_session/viewer/event_loop.rs (275-318)— AgentResponseEvent processingapp/src/ai/blocklist/controller/shared_session.rs (87-240)— shared session init,handle_shared_session_response_event()app/src/ai/blocklist/task_status_sync_model.rs (125-137)— skips status reporting foris_viewing_shared_sessionconversationsapp/src/workspace/view/wasm_view.rs (180-196)— WASM viewer REST API calls viaServerApiProvider
Server REST API (read-only, no changes needed):
warp-server/router/handlers/public_api/agent_webhooks.go (1807-1814)—ancestor_run_idfilter validationwarp-server/public_api/types/types.gen.go (1579-1658)—RunItemstruct withSessionId,SessionLink,State,Title,AgentConfig,Artifactswarp-server/authz/engine.go (556-605)—aiTaskPolicies, ownership-basedViewAction
Design options
Two approaches can deliver orchestration data to the web viewer. They share the same client-side pill bar work (WASM compat, conversation switching) but differ in how orchestration metadata and child transcripts reach the viewer.
Option A: REST + per-child hidden shared-session pane (recommended for V1)
The viewer queries existing warp-server REST APIs for child metadata and status. Each discovered child gets its own hidden shared-session viewer pane (its own TerminalView, TerminalManager, and child-session Network), reusing the pattern that local orchestration uses for remote child agents. Pill clicks navigate via the existing SwapPaneToConversation mechanism. No changes to session-sharing-protocol or warp-proto-apis.
How it works:
- Viewer joins session → gets
task_idfromSessionSourceType::AmbientAgentin the join handshake (already implemented interminal_manager.rs:730-750). - Viewer calls
GET /agent/runs?ancestor_run_id={taskId}→ receives list of childRunItems with names, states, agent configs, parent_run_id. - For each child, the viewer creates a child conversation in
BlocklistAIHistoryModel(withis_viewing_shared_session = true,parent_conversation_idset,task_idset) and — onceRunItem.SessionIdis known — materializes a hidden shared-session viewer pane that joins that child's session. - Viewer periodically re-fetches
GET /agent/runs?ancestor_run_id={taskId}to detect new children, surfacesession_ids that weren't ready at first discovery, and update status badges. - When user clicks a child pill →
SwapPaneToConversationswaps the visible pane for the hidden child pane. The user sees that pane's own agent view, fed by its ownNetworkagainst the child's shared session.
Existing infrastructure:
- HTTP client (
crates/http_client/) works on WASM — the web viewer already makes REST calls. GET /agent/runs?ancestor_run_id=can be polled periodically to get current state for all children in a single request.GET /agent/runs?ancestor_run_id=returnsRunItemwithState(QUEUED, PENDING, CLAIMED, INPROGRESS, SUCCEEDED, FAILED, ERROR, BLOCKED, CANCELLED) which maps directly toConversationStatuspill badges.- Child tasks have their own shared sessions (
RunItem.SessionId). The viewer can join child sessions via WebSocket using the same session-sharing protocol as the parent, getting liveAgentResponseEventstreams that are harness-agnostic.
Pros:
- Repos touched: 1-2 (warp client + possibly minor warp-server authz). No
session-sharing-protocolorwarp-proto-apischanges. - Uses existing, tested APIs and client patterns.
- Simpler server-side work.
Cons:
- Status badge updates have polling latency (a few seconds between fetches).
- Hidden panes for clicked-but-not-yet-running children are created lazily once a
session_idis available, so the first click on a freshly queued child may briefly land on an empty agent view until the next poll surfaces the session id and the hidden pane is materialized. - External link-shared viewers (not on the owning team) cannot access child tasks via REST. Covered by ownership for the common cases (see Layer 1).
Option B: Streaming through session-sharing protocol
The server forwards child AgentResponseEvents and lifecycle status updates through the parent's shared session WebSocket, enriched with orchestration metadata.
How it works:
- Extend
StreamInitinwarp-proto-apis/apis/multi_agent/v1/response.protowith optional orchestration fields:agent_name,agent_role,parent_agent_id,harness_type,working_directory,agent_description. - Add
OrchestrationEvent::ChildStatusUpdatevariant toOrderedTerminalEventTypeinsession-sharing-protocol. - Server forwards child
ResponseEvents to the parent's shared session (viaforked_from_conversation_token) and publishes lifecycle status updates asOrchestrationEvents. - Client processes forked child events to create child conversations and registers parent-child relationships. Status updates arrive via the existing WebSocket — no polling needed.
Pros:
- Real-time: status and transcript updates arrive instantly via the existing WebSocket.
- Child transcripts are always available (streamed proactively).
- No auth gap: events flow through the already-authorized session connection.
Cons:
- Repos touched: 4 (session-sharing-protocol, warp-proto-apis, warp-server, warp client).
- Complex server plumbing: forwarding child events to parent session, lifecycle notifier integration.
- Event volume scales with child count.
session-sharing-protocolis an external repo pinned by git rev.
Recommendation
Option A for V1, with each child hosted in its own hidden shared-session pane (see Layer 2). The polling latency is acceptable for viewing sessions (status badges don't need sub-second updates), and touching 1-2 repos vs 4 significantly reduces implementation scope. Option B can be layered on later if real-time fidelity becomes important.
Proposed changes (Option A)
Layer 1: warp-server — authz (no changes needed)
The authz model is ownership-based (authz/engine.go:556-605): ViewAction on an AI task is granted if the principal owns it or belongs to the owning team. Child tasks spawned by run_agents inherit the same user/team ownership as the parent. Therefore, any principal who can view the parent task already has ViewAction on the children — no new authz rules are required.
This covers the two common cases:
- User viewing their own orchestrated session (owns all tasks).
- Teammate viewing a shared link (team-owned tasks are accessible).
External users with only a session link who are not on the owning team would not have access to child tasks. This is an edge case that can be addressed later if link-shared orchestrated sessions become common.
Layer 2: warp client — children poller + per-child hidden shared-session panes
Split the responsibilities cleanly:
- A new
OrchestrationViewerModelonly discovers children via REST, registers them inBlocklistAIHistoryModel, and emitsTerminalViewEvent::EnsureSharedSessionViewerChildPaneon the parent'sTerminalViewonce a child first reports asession_id. It owns noNetworks. - The pane group's
ensure_shared_session_viewer_child_panehandler materializes a hidden shared-session viewer pane per child. Each hidden pane is created viacreate_shared_session_viewer(child_session_id, resources, view_size, /* enable_orchestration_polling */ false, ctx), giving the child its ownTerminalView,TerminalManager,BlocklistAIController, and viewer-sideNetwork. The pane is held off-tree inchild_agent_panes, exactly like local-orchestration remote children. - Pill clicks dispatch
TerminalAction::SwitchAgentViewToConversation, whichTerminalView::handle_actiontranslates toEvent::SwapPaneToConversation.TerminalPane::handle_view_eventthen callsensure_hidden_child_agent_pane_for_conversation+swap_active_pane_to_conversation(pane_group/pane/terminal_pane.rs).
Initialization
The viewer TerminalManager owns the OrchestrationViewerModel slot:
orchestration_viewer_model: Arc<FairMutex<Option<ModelHandle<OrchestrationViewerModel>>>>
The Arc<FairMutex<Option<...>>> matches the existing current_network storage pattern so the network-event closure can write into the slot without &mut self. Lazily created in the JoinedSuccessfully arm of the network-event subscription, only when:
enable_orchestration_polling == trueon thisTerminalManager(root viewer pane), ANDFeatureFlag::OrchestrationViewerPillBar.is_enabled(), AND- The slot is currently
None(is_none()guards against reconnect dupes), AND - The session source is
SessionSourceType::AmbientAgent { task_id }. The model captures the parentWeakViewHandle<TerminalView>so it can emitEnsureSharedSessionViewerChildPaneon the host view.
enable_orchestration_polling flag
A single bool plumbed through viewer::TerminalManager::new/new_deferred, ambient_agent::create_cloud_mode_view, PaneGroup::create_cloud_mode_terminal, and PaneGroup::create_shared_session_viewer. true for the root viewer pane of an orchestrator, false for per-child viewer panes. Skipping polling on children avoids both duplicated REST traffic and grandchild double-registration via the transitive ancestor_run_id filter.
Callers passing true (root viewers): create_ambient_agent_terminal, replace_loading_pane_with_restored_ambient_cloud_mode_pane, the restored-shared-session-viewer path in restore_pane_leaf, the new_for_shared_session_viewer constructor, the pending-restoration path in register_pending_ambient_restorations, and view_impl.rs::start_cloud_mode.
Callers passing false (per-child viewers): ensure_shared_session_viewer_child_pane, insert_ambient_agent_pane_hidden_for_child_agent.
a) Discover children on session join
On construction, the model calls GET /agent/runs?ancestor_run_id={taskId} via ServerApiProvider (same HTTP client used in wasm_view.rs). For each RunItem in the response:
- Create a child conversation via
start_new_child_conversation()and callset_viewing_shared_session_for_conversation(true). Theis_viewing_shared_sessionflag suppressesTaskStatusSyncModelround-tripping. - Call
conversation.set_task_id(task_id)so the pane group can look the task up later when it materializes the hidden pane. - Set
agent_namefromRunItem.Title. - Map
RunItem.State→ConversationStatusand set it on the conversation. The model does not subscribe toBlocklistAIHistoryEvent::SetActiveConversation. It does not ownNetworks. It does not callfind_existing_conversation_by_server_token. Its only side effects are updating the history model and emittingEnsureSharedSessionViewerChildPane.
b) Polling state machine
The model owns a single polling timer (Option<SpawnedFutureHandle>). SpawnedFutureHandle does not abort on drop, so schedule_next_poll explicitly calls prior.abort() before installing the next handle to prevent stacked timer chains.
Two cadences:
- Active (
STATUS_POLL_INTERVAL = 5s): at least one tracked child is non-terminal. - Idle (
STATUS_POLL_INTERVAL_IDLE = 30s): every known child is terminal. We don't stop polling entirely because follow-up user input on the orchestrator can spawn new children. The model also subscribes toBlocklistAIHistoryEvent::AppendedExchangeand kicks the polling cadence back to fast whenever a new exchange arrives on the orchestrator or a tracked child (only during the idle→active transition, to avoid REST pile-up while already polling at the active cadence).
c) Generation guard for stale fetches
The model holds a fetch_generation: u64 counter that is incremented immediately before each fetch dispatch. The response callback checks the captured generation against the live counter and silently drops responses from earlier generations. This guards against a slow timer-fired fetch clobbering a fresher kick fetch (e.g. timer-fired fetch starts, user types and triggers an AppendedExchange kick which aborts the timer and dispatches a fresh fetch — both responses now race, and only the freshest one should apply).
d) Stop conditions
stop_orchestration_polling aborts the polling handle and clears the model slot. Called from:
NetworkEvent::SessionEnded(non-ambient session) — viewer leaves a regular shared session.NetworkEvent::SessionEnded(ambient session, non-owner viewers only) — owners may handoff viaattach_followup_session(sameTerminalManager, same orchestratortask_id), so their model is preserved. Non-owners (read-only viewers) won't get a follow-up session and should stop polling.NetworkEvent::ViewerRemoved— viewer was removed and will not re-attach.NetworkEvent::FailedToReconnect— terminal failure.
e) Materialize hidden shared-session panes for joinable children
OrchestrationViewerModel tracks each child in a ChildAgentEntry with a pane_materialization_requested: bool gate. When a child's session_id first transitions from None to Some, the model emits Event::EnsureSharedSessionViewerChildPane { conversation_id, session_id } on the parent's TerminalView exactly once per child (the gate prevents re-emission on subsequent polls).
The parent TerminalPane::handle_view_event forwards the event to PaneGroup::ensure_shared_session_viewer_child_pane, which:
- Race recovery: if the user clicked a pill before this helper ran,
create_hidden_child_agent_pane's viewer-side branch will have already created a loading-placeholder pane (visible as a spinner with the pill bar) and registered it inchild_agent_panes. The emission gate guarantees this helper runs at most once per child per model lifetime, so any existing entry must be that fallback — safe to discard. The helper captures the discarded pane's tree anchor (viaoriginal_pane_for_replacement) and re-swaps to the new pane after attaching it. - Calls
create_shared_session_viewer(child_session_id, resources, view_size, /* enable_orchestration_polling */ false, ctx)to mint a fully-equipped(TerminalView, TerminalManager)that begins joining the child's shared session. - Wraps them in a
TerminalPane, attaches off-tree viaattach_child_pane_off_tree, stores the newPaneIdinchild_agent_paneskeyed by the child conversation id. - Restores the local child conversation into the new view (
restore_conversation_after_view_creation), enters agent view, and opportunistically updatesambient_agent_view_modelif the child happens to expose one. While a child does not yet have asession_id, clicking the pill renders a loading-placeholder pane (with the pill bar visible so the user can navigate back) instead of a stale empty agent view. The placeholder is replaced once the next poll surfaces thesession_idand triggers this helper.
f) Per-pane network lifecycle
Each hidden child pane's viewer::TerminalManager owns its own Network. Events flow Network → channel_event_proxy → BlocklistAIController inside that pane, with no cross-talk into the parent viewer's controller. current_response_id is scoped to a single shared session per controller (the controller's existing single-stream invariant). When the parent viewer's pane group is torn down, the hidden child panes are dropped along with it (via detach_panes_for_close / discard_pane), which closes their networks.
g) Click path
- User clicks child pill →
OrchestrationPillBardispatchesTerminalAction::SwitchAgentViewToConversation. TerminalView::handle_actionemitsEvent::SwapPaneToConversation { conversation_id }(no in-place branch — every child has a hidden pane, so swap to it).TerminalPane::handle_view_eventcallsensure_hidden_child_agent_pane_for_conversationandswap_active_pane_to_conversation.- The pane swap makes the child pane visible. Its agent view renders the child conversation that the child's own
BlocklistAIControllerhas been populating.
h) Re-Inits don't disrupt user focus
The per-child-pane architecture eliminates the cross-stream interference described in §V1 lessons. The parent's BlocklistAIController continues to receive Inits for the parent's own request streams; those Inits only affect the parent's pane because each child pane has its own controller, its own agent view state, and its own selected-conversation tracking. No changes to on_shared_init are required.
Layer 3: warp client — make pill bar WASM-compatible
In orchestration_pill_bar.rs:
-
Gate pane-management helpers with
#[cfg(not(target_family = "wasm"))]:is_conversation_open_in_other_visible_view()(1696-1724) — on WASM, always returnfalsepane_group_id_containing_terminal_view()(1731-1750) — on WASM, always returnNone
-
Gate the 3-dot overflow menu creation and rendering with
#[cfg(not(target_family = "wasm"))]. On WASM, no menu is created. -
In
handle_action()(532-660), the WASM path only handlesSwitchAgentViewToConversationfor pill clicks (noOpenInNewPane,OpenInNewTab, orFocusOpenedConversation). -
In
render_pill()(1224-1471), skip the overflow button rendering on WASM.
On native, the shared session viewer pill bar retains full pane-management actions (open in new pane/tab, focus pane) since the native client supports multi-pane layouts. No #[cfg] gates are needed for native — the existing pill bar logic applies as-is once child conversations are registered.
Layer 4: warp client — enable pill bar for shared sessions
Add a new FeatureFlag::OrchestrationViewerPillBar flag in crates/warp_features/src/lib.rs. The pill bar for shared session viewers renders when OrchestrationViewerPillBar is enabled and the session has child agents.
In pane_impl.rs (528-537), the existing pill bar rendering condition checks FeatureFlag::OrchestrationPillBar + AgentView + fullscreen. For shared session viewing, add a parallel condition: FeatureFlag::OrchestrationViewerPillBar + AgentView + fullscreen + the conversation has children registered via the REST data fetch.
The pill bar's pill-click action is plain SwitchAgentViewToConversation; TerminalView's handler emits SwapPaneToConversation for shared-session children just like for local children. No in-place branch, no ownership-comparison guards, no is_viewing_shared_session fallback in view.rs.
Verify that child_conversations_of() returns the correct children for shared-session conversations and that pill_specs() (418-479) correctly builds the pill list from shared-session conversation data.
Diagram (Option A)
sequenceDiagram
participant Server as warp-server REST API
participant SSS as session-sharing-server
participant ParentPane as parent viewer pane
participant ChildPane as hidden child pane
Note over ParentPane: Joins parent session, gets task_id
ParentPane->>Server: GET /agent/runs?ancestor_run_id={taskId}
Server-->>ParentPane: [RunItem{name, state, session_id, config}, ...]
Note over ParentPane: Creates child conversation per child,<br/>renders pill bar.
Note over ParentPane,ChildPane: For each child with session_id known,<br/>materialize a hidden shared-session pane.
ParentPane->>ChildPane: create_shared_session_viewer(session_id)
ChildPane->>SSS: Join child session
SSS-->>ChildPane: Init + ClientActions (replay)
ChildPane->>ChildPane: Populate own child conversation
loop Poll while children active (~5s)
ParentPane->>Server: GET /agent/runs?ancestor_run_id={taskId}
Server-->>ParentPane: [RunItem{state, session_id?}, ...]
Note over ParentPane,ChildPane: New session_ids surface ⇒<br/>materialize additional hidden child panes.
end
Note over ParentPane: User clicks child pill ⇒ SwapPaneToConversation
ParentPane-->>ChildPane: Pane swap (existing mechanism)
Note over ChildPane: User sees child agent view fed by its<br/>own Network. No parent disruption.
Testing and validation
Unit tests
- Child discovery: Test that after joining a session with
task_id, callingGET /agent/runs?ancestor_run_id={taskId}and processing the response creates child conversations viastart_new_child_conversation()with correct parent links, agent names, and metadata. - Status polling: Test that periodic re-fetch of
GET /agent/runs?ancestor_run_id={taskId}detects state changes and triggersconversation.update_status()/UpdatedConversationStatus. Test that polling stops once all children reach terminal states. Test that newly spawned children are detected on re-fetch. - RunItem.State → ConversationStatus mapping: Test all state transitions: INPROGRESS → InProgress, SUCCEEDED → Success, FAILED/ERROR → Error, CANCELLED → Cancelled, BLOCKED → Blocked.
- Transcript loading: Test that clicking a child pill joins the child's session via WebSocket, that
AgentResponseEvents are processed throughhandle_shared_session_response_event(), and that switching back to an already-joined child does not re-join. - Pill bar WASM compat (
orchestration_pill_bar.rs): Verifyis_conversation_open_in_other_visible_view()returnsfalseundercfg(target_family = "wasm"). Verify no overflow menu is rendered on WASM.
Manual validation
Mapped to PRODUCT.md invariants:
- Invariants 1-5 (visibility): Open a completed orchestrated cloud session on web. Verify pill bar appears with orchestrator + all children. Open a non-orchestrated session; verify no pill bar.
- Invariants 6-10 (rendering): Verify orchestrator shows Oz icon, children show colored initials, avatar colors match between native client and web viewer for same agent names.
- Invariants 11-13 (status): Start a live orchestrated session, view on web, verify child badges update from In Progress → Succeeded/Failed as children complete.
- Invariants 14-17 (click): Click child pill, verify conversation switches in-place. Click orchestrator pill, verify it switches back. Verify no 3-dot menu appears.
- Invariants 18-21 (hover): Hover a pill for 300ms, verify details card appears with name, CWD, harness, description.
- Invariants 22-26 (conversation display): Switch to child, verify full transcript shows. Verify zero state shown during loading. Switch back to orchestrator, verify scroll position preserved. Verify terminal child transcript is cached; verify non-terminal child re-fetches.
- Invariants 27-30 (live): Join a live session mid-orchestration, verify children appear as they spawn on next poll, verify transcript loads on click.
- Invariant 33 (overflow): Test with 10+ children, verify overflow pills are clipped at pane boundary.
Risks and mitigations
Auth for external link-shared viewers
The authz model is ownership-based: ViewAction is granted to the task owner and their team members. Child tasks inherit parent ownership, so the common cases (own session, teammate session) work without changes. However, external users with only a session link who are not on the owning team cannot access child tasks via REST APIs. Mitigation: defer this edge case; it does not block V1.
Polling latency for status badges
Status updates depend on polling frequency. With a ~5s poll interval on GET /agent/runs?ancestor_run_id=, badges may lag behind real status by a few seconds. Mitigation: acceptable for V1; can upgrade to Option B (streaming) later if real-time matters.
Transcript loading latency
First click on a child pill triggers a WebSocket join + session replay. Large conversations may have notable replay latency. Mitigation: the session connection persists after join, so subsequent views are instant. Zero state is shown during initial join (see product invariant 23).
Children not yet visible at join time
If the viewer joins before run_agents has completed, the initial ancestor_run_id query may return zero children. The viewer must re-poll the children list while the parent session is live. Mitigation: poll the children endpoint periodically (e.g. every 5s) until the session ends or all children reach terminal state.
Backward compatibility
Older servers that don't support ancestor_run_id filtering will return empty results — the viewer simply won't show a pill bar (graceful degradation per product invariant 3).
Parallelization
With Option A, this work is entirely in the warp client repo (layers 2-4). No server changes are required (see Layer 1).
The client work itself is sequential: WASM pill bar compat (layer 3) and pill bar enablement (layer 4) can proceed without the data fetching (layer 2), but integration testing requires all layers. Given the small server-side scope, parallelization across repos provides limited benefit. A single agent can work through this sequentially.
Branch matthew/orch-web-ui is already set up across warp-server and warp.