Files
PiAgentUI/docs/superpowers/specs/2026-05-30-piagentui-active-session-switch-design.md
T
Evandro b3489e1ede
Build & Push Frontend Docker Image / build-and-push (push) Canceled after 0s
Build Validation / validate (push) Canceled after 6s
Build & Push Backend Docker Image / build-and-push (push) Failing after 10m10s
Build & Push Gateway Docker Image / build-and-push (push) Failing after 10m9s
🎉 Initial commit: PiAgentUI — AI Agent Desktop & Web UI
A professional workspace for AI coding agents with:
- Real-time streaming chat with SSE
- Session management (fork, clone, tree navigation)
- MCP tools browser & metadata panel
- Skills browser (global, project, package sources)
- Voice input & STT provider configuration
- Integrated terminal & file explorer
- Model selector with capability metadata
- Keyboard-first UI with command palette
- 8 languages (en, pt-BR, es, ja, zh-CN, ar, bn, hi)
- Tauri 2 desktop app
- React 19 + TypeScript + Vite 8 + Tailwind CSS 4
- Pi Agent extension runtime

Originally forked from OpenCodeUI, now an independent project.
License: GPL-3.0-only
2026-06-01 00:50:06 -03:00

6.7 KiB

PiAgentUI Active Session Switch Design

Goal

When the user opens an existing session in PiAgentUI and sends a message, the message must be delivered to that selected Pi session, not to whichever session was active when the Pi TUI launched.

Current finding

  • The frontend route/store correctly loads selected sessions and their messages.
  • src/hooks/useChatSession.ts sends the selected sessionId to sendMessageAsync().
  • extensions/piagentui-server.ts receives sessionId, but still calls pi.sendUserMessage(parsed.text).
  • pi.sendUserMessage() always targets the current active Pi runtime session.
  • Pi exposes switchSession() only on ExtensionCommandContext, not on normal event ExtensionContext used by session_start and the HTTP server.
  • AgentSessionRuntime.switchSession() intentionally emits session_shutdown, disposes the old session, creates the replacement session, rebinds extensions, then runs withSession(replacedCtx).
  • The existing PiAgentUI extension currently stops the web server on every session_shutdown, so a normal Pi session switch can close the HTTP/SSE server and force reconnects.

Selected approach: activate and send on the backend

The backend should become authoritative for message routing:

  1. Resolve requested sessionId to the JSONL session file in the current session directory.
  2. If the requested session differs from the active Pi session, switch the real Pi runtime to that session.
  3. Send the user message inside the replacement session callback so the message cannot land in the old session.
  4. Keep the existing frontend model: route/session selection still works as today; sendMessageAsync() keeps passing sessionId.

This preserves the current UI while making the server guarantee correctness.

Required changes

Pi core

Expose a safe session-switch capability to extension event contexts, or a narrowly-scoped equivalent that can be used by long-lived extension services.

Preferred API shape:

interface ExtensionContext {
  switchSession(
    sessionPath: string,
    options?: { withSession?: (ctx: ReplacedSessionContext) => Promise<void> },
  ): Promise<{ cancelled: boolean }>
}

Implementation should reuse the existing switchSessionHandler already used by createCommandContext(). The important behavior is not a new session implementation; it is making the already-existing runtime switch available to the PiAgentUI HTTP server context.

PiAgentUI extension server

Add helpers in extensions/piagentui-server.ts:

  • getSessionFileForId(sessionId) scans latestCtx.sessionManager.getSessionDir() for *_${sessionId}.jsonl.
  • sendUserMessageToSession(parsed) validates sessionId, resolves the session file, switches if needed, and sends in the correct context.
  • session_shutdown handling must not tear down the HTTP server on reason === 'resume' if the server is intentionally switching sessions for the web UI. The server should keep the port stable and update latestCtx on the following session_start.

Target send flow:

if (requestedSessionId && requestedSessionId !== activeSessionId) {
  const sessionFile = getSessionFileForId(requestedSessionId)
  const result = await latestCtx.switchSession(sessionFile, {
    withSession: async replacedCtx => {
      await maybeSetModel(replacedCtx, parsed.model)
      await replacedCtx.sendUserMessage(parsed.text)
    },
  })
  if (result.cancelled) return HTTP 409
  return HTTP 202
}

await maybeSetModel(latestCtx, parsed.model)
pi.sendUserMessage(parsed.text)

If sessionId is missing or already active, keep the existing path.

Frontend

Keep frontend mostly unchanged.

Small additions only if needed:

  • sendMessageAsync() continues sending sessionId.
  • Optional: handle HTTP 409/404 with a clear toast if target session cannot be activated.
  • Optional: add activeSessionId from server.connected/session.status later, but not required for the core fix.

Alternatives considered

Activate on session selection

Frontend would call /api/sessions/:id/activate when a user clicks a session. This matches the user's mental model, but it introduces a race: if the user sends before activation completes, the message can still hit the old session unless send also validates activation. It is useful as a UX enhancement after backend routing is correct, not as the primary correctness mechanism.

Frontend guard only

Frontend could block sending if selected session differs from active Pi session. This avoids Pi core changes but makes UX worse and still requires a trustworthy active-session signal from the backend. It detects the problem instead of solving it.

Tests

Extension/unit tests

Add or update extension server tests to cover:

  1. Sending with active sessionId calls pi.sendUserMessage() without switching.
  2. Sending with different sessionId resolves the matching JSONL file and calls ctx.switchSession(sessionFile, { withSession }).
  3. The withSession callback calls replacedCtx.sendUserMessage(text).
  4. Unknown sessionId returns 404 and does not send to the active session.
  5. Cancelled switch returns 409 and does not send.
  6. session_shutdown with reason: 'resume' does not close the web server during web-initiated session switching.

Frontend tests

Add or update API/hook tests to cover:

  1. sendMessageAsync() includes sessionId in the payload.
  2. Send error responses surface through the existing error handling path.
  3. Existing route/session selection behavior remains unchanged.

Manual verification

  1. Start Pi in session A.
  2. Open PiAgentUI.
  3. Select existing session B in the sidebar.
  4. Send a message.
  5. Verify new user and assistant messages appear in session B.
  6. Verify session A JSONL is unchanged.
  7. Verify /global/event remains connected and streaming works without F5.

Risks and mitigations

  • Switching sessions invalidates old extension contexts. Mitigation: send inside withSession(replacedCtx) and avoid captured stale pi/ctx after switch.
  • Current extension closes the HTTP server on session_shutdown. Mitigation: preserve server across resume switches and update latestCtx on session_start.
  • Pi core API change can be too broad. Mitigation: expose only the existing switch handler; do not create parallel session logic.
  • Session file lookup by suffix must be strict. Mitigation: only match _${sessionId}.jsonl inside the current session dir.

Acceptance criteria

  • Sending from selected session B never writes to active-at-launch session A.
  • Frontend routing and message loading stay visually the same.
  • The web server port remains stable during a session switch initiated by PiAgentUI.
  • Streaming still updates live after switch.
  • Typecheck, extension typecheck, relevant tests, and build pass.