qwen-code/packages/webui
易良 5cb946b55b
feat(scheduled-tasks): allow creating a task with an existing session (#9361)
* feat(scheduled-tasks): allow creating a task with an existing session

POST /scheduled-tasks and the workspace-qualified endpoint now accept an
optional `sessionId`. When provided, the task binds to that existing
session instead of minting a dedicated one. The session is validated up
front: it must be live in the target workspace, idle, not archived, and
not already bound to another scheduled task (checked both in a
best-effort pre-read and authoritatively under the cron write lock).

A failed create never tears down a caller-provided session (only
route-minted sessions roll back); after a successful create the session
follows the regular scheduled-task session lifecycle. Omitting
`sessionId` keeps the dedicated-session behavior unchanged.

Closes #8906

* fix(serve): harden scheduled-task session binding per bot review round 1

Four fixes inside the route, each pinned by a test:

- Move the caller-session  rename to after the cron write commits, so a
  failed create (over-cap/duplicate 409, write 500, generation rollback)
  never leaves the caller's pre-existing session permanently renamed with
  no owning task (nothing restores the prior display name).
- On SessionNotFoundError, consult SessionService.getSessionLocation so an
  archived session — removed from the live map by archiving — still gets
  the documented 409 session_archived instead of a bare 404.
- canonicalizeWorkspace re-throws non-ENOENT filesystem errors (EACCES/EIO/
  ELOOP/ESTALE); surface those as a retryable 500
  scheduled_tasks_session_failed with a stderr log instead of a misleading
  400 session_workspace_mismatch.
- Parse sessionId with parseCallerSuppliedSessionId, the parser every other
  caller-supplied-session-id surface uses: UUID grammar, case-normalized,
  length-bounded (no unbounded echo in error bodies/stderr), and
  duplicate-binding equality per session rather than per spelling.

New tests: disk-backed archived fallback (runtime harness), ELOOP 500,
generic lookup-failure 500 with no side effects, over-cap rejection on the
reuse path, concurrent-create single-bind invariant (updateCronTasks
serializes writers; deleting the under-write-lock check flips the second
response to 201), null→mint, and padded/mixed-case normalization. Stub
session ids migrate to valid UUIDs to match the shared grammar.

* fix(serve): classify persisted-but-not-live sessions in task binding probe

The scheduled-task binding disk probe only special-cased 'archived';
'active' and 'conflict' locations fell through to a 404 that misreported
existing resumable sessions as nonexistent (routine after daemon restarts,
when only task-bound sessions are rehydrated). Answer 409 session_not_live
/ session_conflict for on-disk states and reserve 404 for genuinely absent
ids; add the findSessionIdIgnoringCase fallback for legacy uppercase-spelled
session files (mirrors session-id-admission). Also drop the dead
isArchived switch the bridge never populates, dedup the repeated rename /
lookup-failure bodies behind shared closures, align the invalid_session_id
message with the sibling caller-id surfaces, and pin the new behavior plus
the post-commit rename-failure invariant with tests.

* fix(serve): gate scheduled-task delete teardown on session ownership

Persist whether a task's bound session was minted by the task
(sessionOwnedByTask on DurableCronTask) and only close it on DELETE
when the task owns it — a caller-provided session pre-existed the task
and must survive its deletion. Tasks written before the marker keep
today's teardown (their bound sessions were always task-minted), and
the keepalive stamps ownership when it binds a freshly minted session.

Also stop mapping real filesystem failures in the persisted-session
probe to 404 session_not_found: the probe helpers rethrow non-ENOENT
errors (EACCES/EIO/ESTALE), which now surface as a retryable 500
scheduled_tasks_session_failed with a stderr log, matching the sibling
canonicalizeWorkspace catch in the same block.

Keepalive naming now uses the same payload as the route (task.name ??
task.prompt), so the post-restart sweep no longer clobbers the route's
 name on bound sessions (matters now that caller-provided sessions
are named by the route too).

* fix(serve): close session-binding races in scheduled-task create/reuse

R4-1: re-validate a caller-provided session under the cron write lock;
archive/delete tears the session out of the live map before its cron
hook runs, so a session that left the map between validation and commit
is now rejected with 409 session_not_live instead of binding a
201-returned task to an archived/deleted session.

R4-2: the in-lock duplicate-binding check now covers just-minted
sessions too (boundSessionId, not only providedSessionId) and runs
before the cap check; the alreadyBound branch no longer rolls the
session back, since a committed owner task means a concurrent
reuse-create won the race and owns the session.

R4-3 (narrowed, not closed): DELETE re-reads the cron file right before
closeSession and skips teardown when a surviving task references the
session; the residual re-read-to-close window needs session-scoped
serialization shared with the bind path (follow-up).

R4-4: keepalive bind writes also bail when any committed task already
references the just-minted session, mirroring the route's in-lock
check.

R4-5/R4-6: add the missing discriminating tests (mint-site naming,
sessionOwnedByTask validation); both mutation-verified.

* fix(serve): keepalive must not tear down a session a committed task owns

The round-14 review caught a regression in the duplicate-reference bail:
it routed the "a committed task already references the just-minted
session" case into the orphan rollback. In production wiring
cleanupSession is deleteDaemonSessionIfOrphan, whose requireZeroAttaches
passes for a just-minted session, and whose persisted removal cascades
removeTasksForSessions — so the rollback killed the race-winning task's
live session AND deleted its committed task from the cron file.

The two no-write bail reasons are now distinguishable: the
committed-reference check runs first and, when it fires, keepalive logs
and continues without cleanup — the session is left to its owner
(mirroring the route's symmetric alreadyBound branch, which performs no
rollback for exactly this reason) and this task stays unbound on disk
for the next tick to retry with a fresh session. The original bail
(task no longer bindable) still rolls the orphan back, unchanged.

Also pin three load-bearing behaviors that had no coverage: the
duplicate-check-before-cap-check ordering at the cap boundary
(session_already_bound, never max_tasks_reached with rollback), the
DELETE pre-close re-read failure fallback (still closes the owned
session), and the under-lock re-validation generic-error branch (500,
never coerced to session_not_live). All three mutation-verified.

* fix(serve): serialize scheduled-task session teardown with reuse-create binding (#9415)

* fix(serve): extend scheduled-task session teardown serialization to rollback and keepalive sites (#9415 R6)

* fix(scheduled-tasks): narrow existing session reuse

* fix(scheduled-tasks): restore conversation-bound tasks

* fix(scheduled-tasks): restore conversation runtime tasks

* fix(scheduled-tasks): honor the session-management gate on the primary surface

* test(scheduled-tasks): isolate the Conversations runtime ownership record

The boot-restore test passed no liveDiscoveryStableBaseDir, so
runQwenServe resolved it to ~/.qwen and built the Conversations-runtime
ownership on the machine-global record. A concurrent live owner under
the same HOME (another vitest worker, a shared-runner CI job, a
developer's qwen serve) failed the boot with
'The Conversations runtime is owned by another daemon.' Point the test
at a temp stable base, matching the four daemon boots in
run-qwen-serve-live.test.ts.

* test(scheduled-tasks): cover the ambiguous session-owner rejection path

---------

Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com>
2026-08-24 02:23:45 +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): allow creating a task with an existing session (#9361) 2026-08-24 02:23:45 +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.22.0 (#9736) 2026-08-22 15:23:02 +00:00
postcss.config.cjs feat(webui): Infrastructure Setup (Prerequisites) 2026-01-15 14:32:21 +08:00
README.md fix(webui): revert #8882's transactional session switching to the loading-skeleton model (#9129) 2026-08-14 08:00:25 +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, clientId, workspaceCwd, currentModel, currentMode, capabilities, commands, skills, models, tokenCount, tokenUsage, contextWindow, loadingTranscript, missingSession, … }
useDaemonSessionOwnerGuard() Captures the current attachment identity so stale async UI continuations can be ignored
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)
sessionId string? Restore a specific session and control later session switches
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