qwen-code/packages/webui
Shaojin Wen ac2f371c44
feat(scheduled-tasks): add isolated run mode via create_sub_session tool (#6535)
* feat(scheduled-tasks): add isolated run mode via create_sub_session tool

Introduce a new `create_sub_session` tool (daemon-only) that spawns a
fresh top-level sub-session with its own clean context and transcript.
Wire it into the cron scheduler as an `isolated` run mode so each
scheduled fire dispatches its prompt into a fresh sub-session instead
of accumulating in one shared transcript.

- Add `create_sub_session` tool with `first-turn` and `sent` completion modes
- Add `SubSessionLauncher` in cli/serve with concurrency cap, timeout, and truncation
- Extend ACP bridge `extMethod` dispatch for child→daemon sub-session requests
- Add `runMode` field (`shared`|`isolated`) to DurableCronTask, CronJob, and API types
- Add run-mode radio picker to ScheduledTasksDialog UI
- Fix AuthMessage hardcoded placeholder to use i18n key

* fix(scheduled-tasks): dispatch isolated fires daemon-side, not via the model

An `isolated` fire was relayed through the model: the fired prompt was
wrapped with an instruction to call `create_sub_session`. That tool's
default permission is `'ask'`, so under `ApprovalMode.DEFAULT` an
unattended fire reached `client.requestPermission`, found no SSE
subscriber, and was cancelled by the daemon's 5-minute permission
timeout. The task never ran, and the cancel was booked as a successful
run — the headline use case of a scheduled task was broken.

Route isolated fires straight to the daemon instead: the cron `onFire`
handler in `Session` calls the sub-session spawner directly, with no
model relay and no tool-permission gate. The prompt was already approved
when the task was created; laundering it back through the model only
re-opened that gate. `create_sub_session` keeps `'ask'` for
model-initiated calls, and the attended "Run now" button keeps its relay
(a user is present to answer the prompt).

Also fix orphan-session cleanup in the launcher. `closeSession` was
guarded only by `.catch()`, which covers an async rejection but not a
synchronous throw; because the call sits inside the launcher's own
`catch (err)` block, a sync throw escaped and replaced the real launch
error. Guard both shapes.

Tests:
- Cover isolated routing: dispatch, in-session fallback with no spawner,
  missed one-shot, dispatch failure (dropped, never run inline), and
  shared mode.
- Cover the orphan close, including a `closeSession` that throws.
- Replace the sent-mode concurrency test, which only asserted the slot
  was eventually released (moving the release to the drain's *start*
  kept it green) with one that asserts the slot is HELD while the drain
  runs, plus one that asserts it is released at `turn_complete`.

* fix(scheduled-tasks): honor the caller's AbortSignal and harden the spawn boundary

Four findings from review, all in the model-initiated `create_sub_session`
path (the scheduled `isolated` dispatch reaches none of them).

`execute()` took no parameters, so it silently dropped the parent turn's
`AbortSignal`. `Session.ts` awaits `invocation.execute(signal)` without
racing the abort itself, so cancelling a turn with a `first-turn`
sub-session in flight pinned the caller's tool loop until the daemon's
5-minute ceiling. Accept the signal and return as soon as it fires.

The sub-session is deliberately NOT cancelled and deliberately KEEPS its
concurrency slot: `sendPrompt` has no abort seam, so the sub-session runs
on. Releasing its slot on cancel — as the review suggested — would let the
caller over-admit against sub-sessions that are still consuming a bridge
session and model quota.

`handleCreateSubSession` trusted the child-supplied `callerSessionId`
verbatim, and that id keys the launcher's per-caller concurrency bucket: a
fabricated id starts a fresh bucket at zero (cap evasion) and a victim's id
burns their slots (DoS). Validate it with the connection's existing
`ownsSession` seam.

Every daemon session wires a spawner, sub-sessions included, and each gets
its own cap-sized bucket — so one prompt could fan out 5ⁿ sub-sessions until
`maxSessions` ran dry. Gate nesting at one level: the launcher remembers the
sessions it spawned and refuses to spawn from them. With `callerSessionId`
now authenticated, the gate cannot be sidestepped.

Cap the prompt at 100,000 chars (matching the scheduled-task REST route) and
the display name at 200, both at the bridge trust boundary and, for the
prompt, in the tool's own validation so the model gets an actionable error.

Not changed: `create_sub_session` stays in `PermissionManager.CORE_TOOLS`.
Membership there SUBJECTS a tool to the `coreTools` allowlist; it does not
exempt it. Removing it — as the review suggested — is what would let the
tool bypass a user's allowlist, the way `agent` and `send_message` do today.

* fix(core): do not spawn a sub-session for an already-cancelled turn

`raceCancellation(spawner({…}), signal)` evaluated the spawner as an
argument, so the spawn started before the abort was ever checked. A turn
cancelled before `execute()` ran still created a sub-session on the daemon
— and it kept a concurrency slot — while the tool reported itself
cancelled.

Take a thunk instead, so the pre-abort check happens before any daemon work
is started. Track whether the spawn actually began, and say so: "cancelled
before it started, no sub-session was created" is a different fact from
"a sub-session may already have been created and is not cancelled".

Regression test asserts the spawner is never called for a pre-aborted
signal; it fails against the eager-argument form.

* fix(serve): require callerSessionId and stop misreporting an early stream close

Two findings from review.

`awaitFirstTurn`'s `'incomplete'` stopReason was unreachable. The cleanup
`finally` calls `ac.abort()` unconditionally to tear the subscription down,
so by the time the stopReason ternary read `ac.signal.aborted` it was always
true. An event stream that closed before the turn finished (bridge teardown,
WS drop) was reported as a 5-minute wall-clock `'timeout'` — indistinguishable
from a real one. Track the timer firing in its own flag.

`callerSessionId` was validated only when present. Omitting it handed the
launcher `undefined`, which minted an `anon:<uuid>` bucket — a fresh
concurrency bucket per call, so no cap — and skipped the depth-1 nesting gate
(`info.callerSessionId !== undefined && …`). Authenticating the id closed
forgery but not omission. It is now required at the bridge boundary, and
required in `CreateSubSessionInfo`, so the launcher's anonymous fallback and
the gate's presence check are both gone. Every real caller has a session id —
the tool only ever runs inside a session's turn.

* fix(serve): surface dropped fires and drain timeouts; bound sub-sessions per workspace

Three findings from review.

A dropped `isolated` scheduled fire left no trace. `debugLogger.warn` writes
nothing unless a debug log session is active, and the scheduler persists the
fire as a run before dispatch — so a nightly task could fail forever while its
history claimed it ran. It now also writes to stderr, which the daemon forwards
from the child.

A sent-mode drain that hit its 30-minute ceiling was equally silent: the catch
saw `drainAc.signal.aborted` and skipped logging, the `finally` freed the
concurrency slot, and the sub-session — which the abort does not cancel — kept
burning a bridge session and model quota. The timer now records its own firing
(the controller cannot: `finally` aborts it on every exit path) and the timeout
is written to stderr. The drain ceiling is injectable for tests, mirroring
`firstTurnTimeoutMs`.

The per-caller concurrency cap trusts `callerSessionId`, and the bridge can only
authenticate that id as "a session on this channel". Every session of a
workspace shares one child process, so nothing at the transport can prove which
of them issued the call — and a per-session secret would be readable by the
whole process anyway. Rather than pretend otherwise, add a workspace-wide
ceiling on concurrent sub-sessions that holds no matter which bucket a launch is
charged to.
2026-07-09 12:02:39 +00:00
..
.storybook feat(webui/storybook): add full height container support for ChatViewer 2026-01-20 23:57:30 +08:00
docs chore(docs): remove obsolete documentation files 2026-01-21 20:50:45 +08:00
examples style: apply formatting and linting fixes across codebase 2026-03-06 21:58:22 +08:00
scripts feat(webui): migrate icons, Tooltip, WaitingMessage from vscode-ide-companion 2026-01-15 19:53:19 +08:00
src feat(scheduled-tasks): add isolated run mode via create_sub_session tool (#6535) 2026-07-09 12:02:39 +00:00
.npmignore feat(webui): Add UMD build format and CDN usage support 2026-01-22 15:47:56 +08:00
package.json chore(release): v0.19.8 (#6549) 2026-07-08 15:51:03 +00:00
postcss.config.cjs feat(webui): Infrastructure Setup (Prerequisites) 2026-01-15 14:32:21 +08:00
README.md feat(web-shell): daemon web-shell improvements — token usage, settings, retry, streaming metrics, hidden commands (#5066) 2026-06-13 02:58:08 +00:00
tailwind.config.cjs feat(webui): Infrastructure Setup (Prerequisites) 2026-01-15 14:32:21 +08:00
tailwind.preset.cjs refactor(vscode-ide-companion/webui): migrate PermissionDrawer to shared webui package 2026-01-16 19:48:44 +08:00
tsconfig.json feat(daemon): merge daemon-mode feature batch into main (#4490) 2026-06-12 00:34:49 +08:00
vite.config.ts feat(daemon): merge daemon-mode feature batch into main (#4490) 2026-06-12 00:34:49 +08:00

@qwen-code/webui

A shared React component library for Qwen Code applications, providing cross-platform UI components with consistent styling and behavior.

Features

  • Cross-platform support: Components work seamlessly across VS Code extension, web, and other platforms
  • Platform Context: Abstraction layer for platform-specific capabilities
  • Tailwind CSS: Shared styling preset for consistent design
  • TypeScript: Full type definitions for all components
  • Storybook: Interactive component documentation and development
  • Multiple Build Formats: Supports ESM, CJS, and UMD formats for different environments
  • CDN Usage: Can be loaded directly in browsers via CDN

Installation

npm install @qwen-code/webui

CDN Usage

You can also use this library directly in the browser via CDN:

Option 1: With JSX Support (using Babel)

<!DOCTYPE html>
<html>
  <head>
    <!-- Load React -->
    <script
      crossorigin
      src="https://unpkg.com/react@18/umd/react.production.min.js"
    ></script>
    <script
      crossorigin
      src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"
    ></script>

    <!-- Load Babel Standalone for JSX processing -->
    <script src="https://unpkg.com/@babel/standalone@7.23.6/babel.min.js"></script>

    <!-- Manually create the jsxRuntime object to satisfy the dependency -->
    <script>
      // Provide a minimal JSX runtime for builds that expect react/jsx-runtime globals.
      const withKey = (props, key) =>
        key == null ? props : Object.assign({}, props, { key });
      const jsx = (type, props, key) =>
        React.createElement(type, withKey(props, key));
      const jsxRuntime = {
        Fragment: React.Fragment,
        jsx,
        jsxs: jsx,
        jsxDEV: jsx,
      };

      window.ReactJSXRuntime = jsxRuntime;
      window['react/jsx-runtime'] = jsxRuntime;
      window['react/jsx-dev-runtime'] = jsxRuntime;
    </script>

    <!-- Load the webui library -->
    <script src="https://unpkg.com/@qwen-code/webui@0.1.0-beta.2/dist/index.umd.js"></script>

    <!-- Load the CSS -->
    <link
      rel="stylesheet"
      href="https://unpkg.com/@qwen-code/webui@0.1.0-beta.2/dist/styles.css"
    />
  </head>
  <body>
    <div id="root"></div>

    <script type="text/babel">
      // Access components from the global QwenCodeWebUI object
      const { ChatViewer } = QwenCodeWebUI;

      // Use the components with JSX support
      const App = () => (
        <ChatViewer messages={/* your messages */} />
      );

      ReactDOM.render(<App />, document.getElementById('root'));
    </script>
  </body>
</html>

Option 2: Without JSX (using React.createElement directly)

<!DOCTYPE html>
<html>
  <head>
    <!-- Load React -->
    <script
      crossorigin
      src="https://unpkg.com/react@18/umd/react.production.min.js"
    ></script>
    <script
      crossorigin
      src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"
    ></script>

    <!-- Manually create the jsxRuntime object to satisfy the dependency -->
    <script>
      // Provide a minimal JSX runtime for builds that expect react/jsx-runtime globals.
      const withKey = (props, key) =>
        key == null ? props : Object.assign({}, props, { key });
      const jsx = (type, props, key) =>
        React.createElement(type, withKey(props, key));
      const jsxRuntime = {
        Fragment: React.Fragment,
        jsx,
        jsxs: jsx,
        jsxDEV: jsx,
      };

      window.ReactJSXRuntime = jsxRuntime;
      window['react/jsx-runtime'] = jsxRuntime;
      window['react/jsx-dev-runtime'] = jsxRuntime;
    </script>

    <!-- Load the webui library -->
    <script src="https://unpkg.com/@qwen-code/webui@0.1.0-beta.2/dist/index.umd.js"></script>

    <!-- Load the CSS -->
    <link
      rel="stylesheet"
      href="https://unpkg.com/@qwen-code/webui@0.1.0-beta.2/dist/styles.css"
    />
  </head>
  <body>
    <div id="root"></div>

    <script>
      // Access components from the global QwenCodeWebUI object
      const { ChatViewer } = QwenCodeWebUI;

      // Use the components with React.createElement (no JSX)
      const App = React.createElement(ChatViewer, {
        messages: [
          /* your messages */
        ],
      });

      ReactDOM.render(App, document.getElementById('root'));
    </script>
  </body>
</html>

For a complete working example, see examples/cdn-usage-demo.html.

Quick Start

import { Button, Input, Tooltip } from '@qwen-code/webui';
import { PlatformProvider } from '@qwen-code/webui/context';

function App() {
  return (
    <PlatformProvider value={platformContext}>
      <Button variant="primary" onClick={handleClick}>
        Click me
      </Button>
    </PlatformProvider>
  );
}

Daemon React SDK (@qwen-code/webui/daemon-react-sdk)

All daemon-related React bindings (Providers, hooks, types) are published under the daemon-react-sdk sub-path. The main entry (@qwen-code/webui) is purely UI components with zero daemon dependency.

import {
  DaemonSessionProvider,
  DaemonWorkspaceProvider,
  useTranscriptBlocks,
  useConnection,
  useActions,
  useStreamingState,
} from '@qwen-code/webui/daemon-react-sdk';

Architecture

Two providers, split by lifecycle axis:

  • DaemonSessionProvider — per-conversation: SSE connection, transcript store, prompt/cancel/model/approval-mode/permission actions.
  • DaemonWorkspaceProvider — per-workspace (outlives sessions): MCP, skills, tools, memory, agents, files.
<DaemonWorkspaceProvider>          ← owns DaemonClient + capabilities
  useMcp / useAgents / useMemory / useTools / ...
  ├── <DaemonSessionProvider>      ← owns session + SSE + transcript store
  │     useTranscriptBlocks / useActions / useConnection / useStreamingState / ...
  │     ├── <ChatPanel />
  │     └── <TerminalPanel />

Basic usage

import {
  DaemonSessionProvider,
  DaemonWorkspaceProvider,
  useTranscriptBlocks,
  useActions,
  useConnection,
} from '@qwen-code/webui/daemon-react-sdk';

function App() {
  return (
    <DaemonWorkspaceProvider baseUrl="http://127.0.0.1:4170" token={token}>
      <DaemonSessionProvider autoReconnect>
        <ChatView />
      </DaemonSessionProvider>
    </DaemonWorkspaceProvider>
  );
}

function ChatView() {
  const blocks = useTranscriptBlocks();
  const { sendPrompt, cancel } = useActions();
  const { status, sessionId, currentModel } = useConnection();
  // render blocks, handle input...
}

Dual-mode usage (chat + terminal share one session)

Wrap both views with a single <DaemonSessionProvider>. Both panels share one SSE connection and one transcript store.

<DaemonWorkspaceProvider baseUrl={baseUrl} token={token}>
  <DaemonSessionProvider autoReconnect>
    <ChatPanel />
    <TerminalPanel />
  </DaemonSessionProvider>
</DaemonWorkspaceProvider>

Do NOT nest multiple <DaemonSessionProvider> for the same session — that creates two SSE connections and potential state divergence.

Session hooks

Hook Returns
useTranscriptBlocks() readonly DaemonTranscriptBlock[] (raw blocks)
useTranscriptState() Full DaemonTranscriptState (blocks + metadata)
useActions() { sendPrompt, cancel, setModel, setApprovalMode, respondToPermission, loadSession, newSession, ... }
useConnection() { status, sessionId, currentModel, currentMode, commands, skills, models, tokenCount, tokenUsage, contextWindow }
useStreamingState() 'idle' | 'waiting' | 'responding' | 'thinking'
usePromptStatus() 'idle' | 'waiting' | 'streaming'
usePendingPermissions() Unresolved permission blocks
useActiveTodoList() Latest todo list, only when it still has active items

Workspace hooks

Require an ancestor <DaemonWorkspaceProvider>:

Hook Description
useMcp(options?) MCP server list + restart + tools
useSkills(options?) Available skills (read-only)
useTools(options?) Workspace tools + enable/disable
useMemory(options?) Memory files + read/write
useAgents(options?) Agent CRUD
useSessions(options?) Session list (switch/new/release require nested DaemonSessionProvider)
useFiles() File operations: glob, read, write, edit, stat
useGlob() globWorkspace(pattern, opts)
useWorkspace() Full workspace context value
useWorkspaceActions() All workspace-level actions

All resource hooks accept { autoLoad?: boolean, enabled?: boolean } and return { data, loading, error, reload }. When nested under an active DaemonSessionProvider, resource hooks also refresh from daemon workspace events that are already broadcast on the session stream (memory_changed, agent_changed, tool_toggled, MCP restart events, and workspace init events). Without an active session, hooks remain pull-based.

Props

DaemonSessionProviderProps:

Prop Type Default Description
baseUrl string? inherited Daemon HTTP base URL (inherited from DaemonWorkspaceProvider when nested; required in standalone mode)
token string? inherited Bearer token (inherited from DaemonWorkspaceProvider when nested)
workspaceCwd string? Override workspace path (uses capabilities if omitted)
initialSessionId string? Restore a specific session on mount
clientId string? Override stable client ID (auto-generated if omitted)
autoConnect boolean true Connect on mount
autoReconnect boolean true Auto-reconnect on disconnect
reconnectDelayMs number 1000 Initial reconnect backoff
maxReconnectDelayMs number 10000 Max reconnect backoff
suppressOwnUserEcho boolean true Suppress own user message echoes

DaemonWorkspaceProviderProps:

Prop Type Default Description
baseUrl string required Daemon HTTP base URL
token string? Bearer token
workspaceCwd string? Override workspace path
autoConnect boolean true Connect and fetch capabilities on mount

Components

UI Components

Button

import { Button } from '@qwen-code/webui';

<Button variant="primary" size="md" loading={false}>
  Submit
</Button>;

Props:

  • variant: 'primary' | 'secondary' | 'danger' | 'ghost' | 'outline'
  • size: 'sm' | 'md' | 'lg'
  • loading: boolean
  • leftIcon: ReactNode
  • rightIcon: ReactNode
  • fullWidth: boolean

Input

import { Input } from '@qwen-code/webui';

<Input
  label="Email"
  placeholder="Enter email"
  error={hasError}
  errorMessage="Invalid email"
/>;

Props:

  • size: 'sm' | 'md' | 'lg'
  • error: boolean
  • errorMessage: string
  • label: string
  • helperText: string
  • leftElement: ReactNode
  • rightElement: ReactNode

Tooltip

import { Tooltip } from '@qwen-code/webui';

<Tooltip content="Helpful tip">
  <span>Hover me</span>
</Tooltip>;

Icons

import { FileIcon, FolderIcon, CheckIcon } from '@qwen-code/webui/icons';

<FileIcon size={16} className="text-gray-500" />;

Available icon categories:

  • FileIcons: FileIcon, FolderIcon, SaveDocumentIcon
  • StatusIcons: CheckIcon, ErrorIcon, WarningIcon, LoadingIcon
  • NavigationIcons: ArrowLeftIcon, ArrowRightIcon, ChevronIcon
  • EditIcons: EditIcon, DeleteIcon, CopyIcon
  • SpecialIcons: SendIcon, StopIcon, CloseIcon

Layout Components

  • Container: Main layout wrapper
  • Header: Application header
  • Footer: Application footer
  • Sidebar: Side navigation
  • Main: Main content area

Message Components

  • Message: Chat message display
  • MessageList: List of messages
  • MessageInput: Message input field
  • WaitingMessage: Loading/waiting state
  • InterruptedMessage: Interrupted state display

Platform Context

The Platform Context provides an abstraction layer for platform-specific capabilities:

import { PlatformProvider, usePlatform } from '@qwen-code/webui/context';

const platformContext = {
  postMessage: (message) => vscode.postMessage(message),
  onMessage: (handler) => {
    window.addEventListener('message', handler);
    return () => window.removeEventListener('message', handler);
  },
  openFile: (path) => {
    /* platform-specific */
  },
  platform: 'vscode',
};

function App() {
  return (
    <PlatformProvider value={platformContext}>
      <YourApp />
    </PlatformProvider>
  );
}

function Component() {
  const { postMessage, platform } = usePlatform();
  // Use platform capabilities
}

Tailwind Preset

Use the shared Tailwind preset for consistent styling:

// tailwind.config.js
module.exports = {
  presets: [require('@qwen-code/webui/tailwind.preset.cjs')],
  // your customizations
};

Development

Running Storybook

cd packages/webui
npm run storybook

Building

npm run build

Type Checking

npm run typecheck

Project Structure

packages/webui/
├── src/
│   ├── components/
│   │   ├── icons/          # Icon components
│   │   ├── layout/         # Layout components
│   │   ├── messages/       # Message components
│   │   └── ui/             # UI primitives
│   ├── context/            # Platform context
│   ├── hooks/              # Custom hooks
│   └── types/              # Type definitions
├── .storybook/             # Storybook config
├── tailwind.preset.cjs     # Shared Tailwind preset
└── vite.config.ts          # Build configuration

License

Apache-2.0