docs/plans/2026-08-29-standalone-pr6-webshell-ui.md
Turn the explicit daemon session contexts delivered by PR5 (#10418) into the
visible standalone-chat product defined by
docs/design/standalone-daemon-sessions.md (§PR6). Home and global New
Chat create standalone sessions on capable daemons; project-scoped entry
points stay workspace-bound; a top-level Recents group manages standalone
sessions through their full lifecycle; standalone chats hide every
project-only surface. No behavior falls back to the primary workspace unless
the daemon lacks standalone_sessions_v1.
This PR touches only packages/web-shell. The #9811 WebShell cutover
(fe34a5cf22) moved the PR5 provider surface from packages/webui into
packages/web-shell/client/daemon/session/, so PR6 is fully contained in one
package. It adds no daemon route and no SDK method.
Suggested title: feat(web-shell): Add standalone chats.
Verified against origin/main (PR3 34a6e918b6, PR4 7357136dd1, PR5
ac761e11c2, cutover fe34a5cf22).
registerStandaloneSessionRoutes
(routes/standalone-sessions.ts:247):
POST/GET /standalone/sessions, GET /standalone/sessions/:id,
POST .../load, POST .../resume, POST .../repair-directory,
PATCH .../metadata, GET .../export, POST .../archive,
POST .../unarchive, POST .../delete.standalone_sessions_v1 registered at
capabilities.ts:136, advertised only when the full dependency set is
installed.cwd,
workspaceCwd, sourceType, sourceId, sessionScope, branch,
worktree) with 400 invalid_request.STANDALONE_SESSIONS_CAPABILITY = 'standalone_sessions_v1'
(standalone-sessions.ts:16).DaemonClient: createStandaloneSession (generates the UUID, wraps
response loss into DaemonStandaloneCreationOutcomeUnknownError carrying
sessionId + exact-lookup recovery, never auto-retries; accepts only
sessionId?, modelServiceId?, approvalMode?),
listStandaloneSessions(Page), getStandaloneSession (exact lookup:
creating / summary / not-found), loadStandaloneSession,
resumeStandaloneSession, repairStandaloneSessionDirectory,
renameStandaloneSession, exportStandaloneSession,
archiveStandaloneSessions, unarchiveStandaloneSessions,
deleteStandaloneSessions (batch result with removed, notFound,
errors, fileCleanupPending). All standalone methods are
capability-gated.DaemonSessionClient.createStandalone / loadStandalone / resumeStandalone statics and the { kind: 'standalone' } restore
strategy (DaemonSessionClient.ts:444-485, restore dispatch at :710).DaemonProductSessionContext (types.ts:71):
{ kind: 'workspace'; cwd } | { kind: 'standalone' } | { kind: 'live' }.DaemonSessionProviderProps.sessionContext (types.ts:160) alongside
legacy workspaceCwd; conflicts throw before any request.createSession / loadSession / resumeSession accept
options.sessionContext (types.ts:438-471). Standalone create is not
retried and is exempt from the generic 30 s action timeout; Live create is
rejected by this provider.createDetachedStandaloneSession,
DaemonSessionProvider.tsx:3968) forwards only modelServiceId and
approvalMode to DaemonSessionClient.createStandalone — workspace-only
fields never reach the daemon.sessionContext and
standaloneSession?: DaemonStandaloneConnectionState
({ projectlessOutputDirectory?, workingDirectory?, creationRecovery?, errorCode? }, types.ts:76-82, 94-98). Directory error codes are copied
into standaloneSession.errorCode for UI branching.session-context.ts helpers: resolveProviderSessionContext,
resolveActionSessionContext, sessionContextKey,
restoreSessionContextMatches, getDaemonErrorCode,
isDaemonErrorExplicitlyNonRetryable, getStandaloneConnectionState.Fixed by the umbrella contract; PR6 wires each visible entry point to an explicit context:
| Entry point | New-session context |
|---|---|
| Home / global New Chat | standalone |
| New Chat inside a selected or locked project | workspace |
| Goals and Git entry points | workspace |
| Current-session New Chat | Inherit explicit context |
| Live Voice | live |
Implementation map (packages/web-shell/client, line numbers verified
against origin/main after the #9811 cutover; the product UI currently has
zero sessionContext references and passes legacy workspaceCwd
everywhere):
handleNewSession()
(call site components/sidebar/WebShellSidebar.tsx:5097) →
createNewSession() (App.tsx:8641). Today this only clears; creation is
lazy. With capability, it sets pending context { kind: 'standalone' }.
The Home first-prompt path (ensureSessionForPrompt, App.tsx:6179,
target-cwd resolution inside it →
utils/sessionPreparation.ts#createAndAttachSessionForPrompt) consumes
the pending context instead of resolving locked/selected/primary cwd.createAndAttachSessionForPrompt always passes
sourceType: WEB_SHELL_SESSION_SOURCE_TYPE and may pass
worktree/branch; the daemon rejects all of these on the standalone
route, and the provider's standalone lane accepts neither. The creation
flow therefore branches: a standalone pending context dispatches
createSession({ sessionContext: { kind: 'standalone' }, approvalMode? })
only — never the generic helper's sourceType/worktree/branch
payload. Tests assert the exact request body for both branches./new, /clear, the new-session
suggestion, and the shell API all reach the same createNewSession()
today, and Goals passes an undefined cwd while needing workspace — with a
workspace active context, the callee cannot distinguish global standalone
creation from current-session workspace inheritance. The caller contract
becomes an explicit intent: { kind: 'global' } (Home/global New Chat →
standalone when capable), { kind: 'inherit' } (current-session
entries), or an explicit { kind: 'workspace', cwd } (project New Chat,
Goals). Intent is never inferred from an undefined cwd or current state,
and every real callsite is covered by tests.handleNewSession(wsCwd)
(WebShellSidebar.tsx:5514) → createNewSession({ kind: 'workspace', cwd: wsCwd }) under the new intent contract.onCreateGoal (App.tsx:13223) first resolves the target
cwd exactly as today (locked ?? selected ?? primary), then calls
createNewSession({ kind: 'workspace', cwd: resolved }, { keepView: true }) + ensureSessionForPrompt; stays workspace-bound,
ignoring any standalone pending context. It never passes a bare
undefined cwd.resolveSessionForWorkspace (App.tsx:6661) and the composer
git chips (gated by gitModeEligible, App.tsx:6763); stays
workspace-bound.ScheduledTasksDialog.onCreateViaChat resolves an explicit workspace cwd
and passes { kind: 'workspace', cwd } — durable standalone scheduling
is out of scope, so an active standalone context must never choose
inherit/global here. The same rule audits every remaining
workspace-maintenance callsite, with a standalone→Scheduled-Tasks
regression test.{ kind: 'inherit' }) — inherits the
active connection.sessionContext: standalone → pending standalone,
workspace → pending workspace with the current cwd (today's behavior). A
Live current session routes through the existing Live-specific
startLive('new') path (client/live/useLiveVoice) before any clearing,
never a silent remap to standalone — the routing contract's "inherit
explicit context" row stays intact (review P1).sessionId strings
(split URL, sessionStorage) and pane ownership derives from workspace
catalogs, so a standalone pane has no context source after a cold split
URL or refresh and would fall through to workspace resolution. Blocking
only the Recents row is insufficient — the global footer button can seed
the current connection.sessionId, and ?split=, sessionStorage
restoration, and externally controlled splitSessionIds can all inject a
bare standalone ID. PR6 disables the global Split View entry for
non-workspace effective contexts and rejects or sanitizes standalone IDs
at every seed, URL, storage, and controlled-prop boundary before panes
mount. Because bare ids carry no context, sanitization uses a fail-closed
async classifier before panes mount: on a capable daemon, an unmapped id
is exact-checked via getStandaloneSession — standalone or creating
ids are rejected, and only a 404 continues through workspace ownership
resolution. The classifier has a cancellable pending state, treats lookup
errors as rejection, and hands the controlled host the sanitized set so
it cannot immediately re-inject a stripped id. Delayed-catalog and
controlled-prop tests cover this. Context-bearing pane descriptors are a
follow-up.WorkspaceSessionProvider
(components/WorkspaceSessionProvider.tsx) still passes legacy
workspaceCwd into DaemonSessionProvider; PR6 passes the resolved
sessionContext prop instead (the provider normalizes legacy cwd at its
compatibility boundary, so both forms remain valid during the cutover).loadSidebarSession (App.tsx:9114)
currently passes { workspaceCwd }; for standalone entries it calls
loadSession(id, { sessionContext: { kind: 'standalone' } }).Legacy callers that pass only workspaceCwd keep today's behavior
untouched.
Read the capability from useWorkspace().capabilities.features —
DaemonWorkspaceProvider fetches client.capabilities() once at startup,
before any session exists, so entry points (which fire before a session)
must not rely on the session-scoped connection.capabilities. Compare
against STANDALONE_SESSIONS_CAPABILITY from @qwen-code/sdk/daemon.
The capability value is tri-state (review P1), and only one state may fall back to legacy behavior:
capabilities === undefined while the startup fetch is in
flight): Home and global New Chat wait — the standalone decision is
disabled, not defaulted. Treating loading as absent could silently create
a primary-workspace session on a capable daemon.503
ownership/runtime failure or a 409 directory conflict.WebShell creates the daemon session lazily on first prompt for the Home
flow (createNewSession only clears; ensureSessionForPrompt materializes
on submit). PR6 stores an explicit pending context alongside that deferred
intent:
pendingSessionContext: DaemonProductSessionContext | undefined state, set by every entry point
above. Pending context is a DaemonProductSessionContext, never a bare
undefined cwd — "no cwd yet" is not standalone semantics.ensureSessionForPrompt (App.tsx:6179) prefers the pending context:
{ kind: 'standalone' } → the standalone creation branch (no
workspace-only fields); a workspace pending context → today's exact-cwd
path; none → legacy locked/selected/primary resolution.409/503 creation failures do not publish a standalone
connection context, and a cleared provider can still hold the previous
workspace context — so routing and draft-UI gates read
pendingSessionContext ?? connection.sessionContext, and the pending
state is cleared only after create-and-attach completes successfully. A
failed attempt leaves the pending standalone intent (and its error)
visible for retry.loadSidebarSession / switchWorkspace (App.tsx:8703) overwrite the
pending context with the loaded session's explicit context.shouldDeferInitialSessionCreation)
keeps working unchanged underneath.components/sidebar/WebShellSidebar.tsx). No cross-context list exists
today: useScopedSessions / useWebShellSessions
(session-catalog/session-catalog-hooks.ts) are keyed by
SessionCatalogQuery { routeKind, workspaceCwd }
(session-catalog/session-catalog-store.ts:11) — workspace-scoped by
construction. PR6 adds a standalone catalog lane fed by
listStandaloneSessionsPage (cursor pagination, archiveState filter)
rather than forcing standalone rows through the workspace catalog store.
Live sessions and project sessions keep their existing groups; standalone
children (sub-sessions with parentSessionId) never appear.workspaceCwd for protocol
routing, and the existing session-details row renders workspaceCwd as a
project folder. Recents rows therefore use a standalone-specific view
model that drops workspaceCwd entirely, and a standalone-specific
action dispatch that never passes it to project details, navigation, or
workspace resolvers.WebShellSidebarSessionActionItem menu pattern
(WebShellSidebar.tsx:270: details | rename | export | delete | pin |
archive) but dispatch to the standalone SDK routes: rename
(renameStandaloneSession), export (exportStandaloneSession),
archive / unarchive (batch routes), delete
(deleteStandaloneSessions). Workspace-only actions (pin, group) are not
offered on standalone rows.DeleteSessionDialog) and states that the transcript and private files
are removed. On success the session leaves Recents even when the response
carries fileCleanupPending; that subset is surfaced as a non-blocking
notice, not a blocking error.archiveState=archived lane — today
that section is backed by workspace catalogs only, while the umbrella
contract requires archived standalone sessions to remain visible. Because
PR5 deliberately skips workspace event invalidation for standalone
contexts, the standalone lanes refresh explicitly after create, prompt
completion, and every lifecycle action.archived/alreadyArchived, unarchived/alreadyActive, or
removed/notFound as appropriate; an id in errors keeps its row and
surfaces the per-session code/message. fileCleanupPending is only an
additional notice for an otherwise removed id.In a standalone (or Live) chat, hide: workspace/project selector, Git status/branch/worktree controls, project file browser, project settings, pin/group controls, and attachments/uploads (standalone MVP excludes uploads). Model, approval, tool, permission, transcript, and supported metadata controls remain.
pendingSessionContext ?? connection.sessionContext) so draft standalone chats hide project
surfaces before the session exists — never on the presence of a cwd. The
established pattern to extend is ordinaryWorkspaces /
isKnownLiveWorkspaceCwd (App.tsx:2401-2405), which already hides
project UI for the Live runtime; PR6 generalizes it to "current chat is
not a workspace context".connection.commands/skills, and
createNewSession with no cwd reloads primary-workspace skills, while
atWorkspaceCwd falls back to the primary workspace when no session
exists — so useComposerCore would select workspace-scoped draft and
input-history keys and expose project commands/skills in a standalone
draft. Commands, skills, atWorkspaceCwd, and composer storage identity
all derive from the effective product context: a standalone draft skips
the workspace skill reload, keeps only local built-in commands before
attach, uses a standalone-specific draft/history identity with no primary
fallback, and after attach uses only the standalone session's
session-supported data. Covered by a workspace→standalone transition test
with stale commands, skills, draft, and history snapshots.activeWorkspaceCwd from
locked/selected/primary and starts the Git-status effect, and /diff,
/log, /prs, /settings, /schedule, /memory add can open or
default to workspace-scoped flows — hidden controls alone leave these
live against the primary workspace. A workspace-eligible cwd is derived
only when effectiveContext.kind === 'workspace' and gates background
effects, command discovery, and submit-time handlers; tests assert zero
workspace requests or panels from both pending and attached standalone
contexts./branch and the per-message Branch action call
POST /session/:id/branch, whose route is registered with
rejectStandalone: true (packages/cli/src/serve/routes/session.ts) —
the action deterministically fails for standalone sessions. Pending and
attached standalone contexts omit the Branch action, drop /branch from
command discovery, and block its submit-time handler; tests assert no
generic branch request is issued. /fork stays separate — guarded
background fork-agent work is supported.webTerminalAvailable (App.tsx:3206) checks only the
web_terminal capability, but a standalone connection exposes no product
workspaceCwd, so TerminalPanel would open the terminal route without
a cwd and the daemon rejects it with Terminal workspace unavailable —
the generic route resolves only registered trusted workspaces. Standalone
sessions still run ordinary Shell/tool commands in their private
directory through the session permission pipeline; this gates only the
separate terminal surface. Terminal availability and the open handler
require effectiveContext.kind === 'workspace', inherited terminal tabs
are discarded or closed when entering a non-workspace context, and tests
assert pending and attached standalone/Live contexts create no terminal
WebSocket while workspace terminals remain unchanged.useComposerCore/useInputHistory fall back
to the unscoped qwen-web-shell-history key whenever a scoped history is
empty, so a standalone-specific key alone would still surface legacy
workspace-era prompts. The fallback policy becomes
product-context-aware — no legacy/global fallback for a non-workspace
history identity — tested with an empty standalone history while the
legacy key is populated.origin/main): composer workspace selector
(components/WorkspaceSelector.tsx, rendered ChatEditor.tsx:3116);
Git chips/popovers (GitBranchIndicator, GitModePopover,
BranchPickerPopover, gated by gitBranchVisible,
ChatEditor.tsx:2435); Git dialogs (GitDialog et al.); project
settings panel (openPanel('settings') + workspace-scoped
useSettings); @-mention file browser (components/AtMentionPanel.tsx,
hooks/useAtMentionMenu.ts); pin/group controls
(SESSION_ORGANIZATION_FEATURE sidebar grouping).hooks/useFileUpload.ts → client.uploadWorkspaceFile,
rendered via composer/AddMenu.tsx and the @-panel "Upload file" item,
already kill-switched by fileUploadEnabled and the
workspace_file_upload capability (ChatEditor.tsx:1585-1588); (2)
inline prompt attachments — pasted/dropped images and text files in
useComposerCore.ts (pastedImages/pastedFiles, :1718-1719). The
acceptance matrix keeps all attachments out of standalone MVP; lane 2 is
session-scoped, so it needs an explicit context check, not a capability
check.pastedImages/pastedFiles already held by useComposerCore, and
createNewSession does not clear them either — files added in a
workspace could otherwise ride into the first standalone prompt.
Switching into a standalone draft clears held attachments with a visible
notice, and a submit-time non-workspace guard blocks stale or
programmatically supplied attachments from reaching a standalone session.There is no router library: main.tsx + utils/sessionPath.ts own the
/session/<id>?workspace=<workspaceId> scheme (getSessionIdFromUrl /
getWorkspaceIdFromUrl, main.tsx:83-87;
parseSessionId/buildSessionPathname), and the App reports context
upward through onSessionIdChange (App.tsx:8282) so main.tsx can
replaceState the URL.
/session/<id>?context=standalone (no ?workspace=). UUIDs are reserved
daemon-wide, so a context-tagged link can never collide with a workspace
session. Legacy context-free links keep today's workspace resolution —
standalone sessions did not exist before this feature, so no migration is
needed.main.tsx passes the URL sessionId into
WorkspaceSessionProvider, which mounts DaemonSessionProvider outside
App — with no explicit context, resolveProviderSessionContext would
inherit the primary workspace and start a workspace restore before any
App-level lookup exists. The root therefore parses and validates
context alongside the session id, passes the explicit context through
WorkspaceSessionProvider, and for context=standalone suppresses
provider session loading until exact getStandaloneSession lookup
selects the route: an active summary mounts the provider with
{ kind: 'standalone' }, while an archived summary is never loaded
directly (review P1) — getStandaloneSession returns active and archived
summaries alike, but the daemon rejects load/resume with
session_archived. The flow renders the archived state, runs Unarchive,
verifies the target id in the per-id success array, and only then mounts
and loads. Not-found shows the existing missing-session UI
(connection.missingSession → showMissingSessionState). It never
guesses the primary workspace, and a cold-load test asserts zero
workspace-load requests.creating lookup must reach a terminal state (review P2): the
resolver polls exact lookup with bounded backoff (e.g. up to 30 s, capped
attempts) and then stops at an explicit Retry action; it never hangs
in the resolving state forever.loadSession, so a delayed
creating poll resolving after the user opened another session would
start a load that incorrectly wins. A route-resolution token held by the
root resolver (above the provider) is invalidated on every navigation and
checked before each poll and immediately before loadSession.?context=standalone link
carrying a conflicting ?workspace= parameter is rejected, not
reconciled; on a daemon without the capability, a standalone link shows
the "requires a daemon upgrade" state instead of resolving anywhere; on a
capable daemon whose Conversations runtime is unavailable (503), the
structured error is shown with retry.onSessionIdChange becomes context-aware: standalone sessions report
context=standalone and drop workspaceId; workspace and Live links
keep their existing resolvers. PR5's switching semantics already
serialize cross-context commits, so a late-arriving resolution cannot
overwrite a newer target.Render the typed PR5 state instead of parsing strings:
standaloneSession.workingDirectory.state === 'recreated' → warning that
the transcript survived but previous files were not recovered.standaloneSession.errorCode === 'working_directory_missing' → offer
explicit repair (repairStandaloneSessionDirectory); repair never
replays a prompt. This requires one small provider/App transition in PR6
scope (review P1): today sendPrompt's catch surfaces the
prompt-admission 409 only as a generic notice and never copies its
typed code into standaloneSession, and the SDK repair call neither
clears provider error state nor reloads the session — so the Repair UI
could neither reliably appear nor reliably unblock. PR6 captures the
typed code into standaloneSession.errorCode on prompt-admission
rejection, owner-guards Repair to the affected session, and on success
reloads the same standalone session, clearing the error only after the
reload commits.standaloneSession.errorCode === 'working_directory_compromised' →
fail-closed blocking state (review P1): the service rejects an
existing compromised path instead of recreating it, so no Repair action
is offered — the user gets terminal guidance (export the transcript,
delete the session) instead.standaloneSession.creationRecovery → a complete outcome-unknown
recovery state machine (review P1). The provider publishes the reserved
UUID as connection.sessionId without an attached session, so
ensureSessionForPrompt would no-op on the existing id while submission
fails on the empty session ref — ordinary submission and unconfirmed
new-session actions are therefore blocked while recovery is unresolved.
The owner-guarded Check Status action performs exact lookup with
transitions for every recovery state: creating → bounded polling;
existing → load and attach the same id, branching on isArchived
first — an archived summary renders the archived state, runs Unarchive,
verifies the per-id success array, and only then loads, because the
daemon rejects direct loads with session_archived (review P1);
absent → an explicit user-triggered retry (never an automatic create);
unknown → a persistent recovery UI. All four transitions are tested,
including archived summaries on both the cold-link and recovery paths.fileCleanupPending → non-blocking notice that
file cleanup will finish automatically; the transcript is already gone.Unit/component (vitest, packages/web-shell):
startLive('new').workspaceCwd,
sourceType, worktree, or branch (review P1); workspace creation
keeps them.mockDaemon request log); load error fails
closed with retry (review P1).fileCleanupPending removal semantics, and the standalone
view model never exposing the internal cwd to details/navigation
(review P2).creating polls to a terminal state and stops at Retry;
conflicting ?workspace= + context=standalone rejected; incapable
daemon shows upgrade state; not-found shows the missing-session UI
(review P2).recreated warning, repair offered only for working_directory_missing,
working_directory_compromised rendered fail-closed without a repair
action (review P1), creation-recovery banner rendering from typed state.working_directory_missing reaches
standaloneSession.errorCode, Repair is owner-guarded to the affected
session, and success reloads the same session and clears the error
(review P1).creating poll canceled by a user session switch never starts
a load (review P1).context=standalone load issues zero workspace-load requests, with
resolution completed at the root before the provider mounts (review P1).?split=<standalone-id>, sessionStorage restoration, and
controlled-prop injection rejected or sanitized before panes mount
(review P1).isArchived, unarchive
with per-id success verification, and only then load (review P1).atWorkspaceCwd, draft, and input history with stale snapshots present
(review P1/P2).creating
rejection, only 404 continues to workspace ownership resolution, and a
controlled host cannot re-inject sanitized ids (review P1)./diff, /log, /prs, /settings,
/schedule, /memory) (review P1).ScheduledTasksDialog.onCreateViaChat forces workspace intent even from
an active standalone context (review P1)./branch from
discovery, and block its handler — zero generic branch requests;
/fork remains available (review P1)./resume, /delete, /release ingresses hidden or
blocked from pending and attached standalone contexts; /resume <id>
for a standalone session routes only through the explicit standalone
context (review P1).E2E (Playwright, packages/web-shell/client/e2e):
utils/mockDaemon.ts scenario with a
standalone_sessions_v1 capability toggle and the standalone route
family; add web-shell.standalone.spec.ts covering: Home New Chat →
standalone create body asserted; project New Chat → workspace create;
Recents lifecycle; capability loading/absent/error states; capable-daemon
failure with retained intent..qwen/e2e-tests/2026-08-29-webshell-standalone-chats.md and dry-run
against a real qwen serve daemon before merge, per repo workflow.Commands: cd packages/web-shell && npx vitest run <file> for focused
tests, npm run verify (lint + format:check + typecheck + test:ci) and
npm run test:e2e, then root npm run build && npm run typecheck.
PR6 does not add: standalone attachments/uploads (waits on the workspace
upload follow-up), moving/forking a standalone session into a project,
durable standalone scheduling, storage quotas, or child-session management
UI. SessionOverviewPanel and ResumeDialog remain workspace-scoped in
this PR, and every ingress to them is gated (review P1): bare /resume,
/delete, and /release mount workspace dialogs keyed by
lockedWorkspaceCwd, and when it is undefined useScopedSessions selects
the primary WebShell catalog — so /delete could act on unrelated
workspace sessions from a standalone context. Non-workspace effective
contexts hide or block the sidebar canOpenSessionsOverview entry,
shellApi.openSessionOverview(), and the bare workspace dialog commands,
issuing zero legacy catalog or destructive requests; /resume <id> for a
standalone session routes only through the explicit standalone context.
Both pending and attached standalone states are tested. The top-level
Recents group lives in the sidebar only. Split View
stays workspace-only (context-bearing pane descriptors are a follow-up).
The one in-scope provider change is the prompt-admission error-code capture
plus repair-and-reload transition, because the Repair UI depends on it; any
other typed gap discovered in the provider surface is raised as a
follow-up. It does not change daemon lifecycle behavior, the SDK contract,
or the PR5 provider switching semantics.