qwen-code/packages/webui
Shaojin Wen dd62c3a8e5
feat(scheduled-tasks): gate an isolated run behind a precondition (#6619)
* feat(scheduled-tasks): gate an isolated run behind a precondition

An isolated scheduled task now takes an optional `condition` alongside its
prompt. On every fire the task's bound session evaluates the condition as an
ordinary cron turn, and only dispatches the prompt into a fresh sub-session
when that turn's verdict is YES.

The check deliberately runs in the bound session rather than a throwaway
sub-session:

  - It has exactly the semantics of a `shared` fire — same tools, same
    workspace approval mode — so it introduces no new permission surface.
  - The bound session of an isolated task is otherwise empty, so its
    transcript becomes the task's decision log: the record of why a fire did
    or did not happen.
  - No session is minted for a run that never occurs.

Everything that is not a YES skips the fire: NO, an unparseable answer, a
tool-loop error, a cancelled or timed-out turn. A precondition exists to
withhold an unattended run, so an ambiguous answer must withhold it too.

A `missed` (late-delivered) fire is judged the same way and then keeps its
existing in-session path — a precondition changes whether a fire runs, never
how. The Web Shell's "Run now" evaluates the condition before relaying the
dispatch through the model, so a manual run reproduces a scheduled one and
doubles as the way to test that a condition is written correctly.

The field is isolated-only. `POST`/`PATCH /scheduled-tasks` reject a condition
on a shared task, judging the combined post-patch state so a condition can be
stranded from neither side; the check is gated on the request actually
touching `condition` or `runMode`, so a hand-edited stranded task stays
editable. `isValidTask` requires a non-empty string, since the fire path gates
on truthiness and an empty condition would silently un-guard the task.

* fix(scheduled-tasks): harden the precondition against four review findings

Never judge a `missed` fire. That job is the scheduler's synthetic carrier:
one batched notification covering every one-shot missed in this load, built
from a spread of the first task, whose prompt is a notice ("these were missed
— ask the user before running them") rather than any task's command. Gating it
on the first task's precondition let a `NO` silently suppress the notice for
its siblings, which `removeMissedFromDisk` has already deleted. The scheduler
now strips per-task guard state from the carrier, and the session refuses to
judge a missed fire — the same contract enforced from both ends.

Distinguish a truncated turn from a clean one. A permission cancel or a
detected tool loop returns mid-tool-loop without aborting the turn's signal or
recording an error, so it reached `onComplete` as `'ok'`. A model that emits
`DECISION: YES` as text in the same streaming round as its tool call would then
release the fire on a verdict it never got to revise. Add an `'incomplete'`
outcome, set at both early returns.

Require the verdict to be the whole line. `\b` after the verdict accepted
`DECISION: YES, but I could not verify it` as a YES. The prompt asks for a line
that is exactly one of the two; a hedged answer is not a decision, and a
precondition must fail closed on an answer it cannot trust. Closing markdown
and terminal punctuation are still tolerated.

Require a bound session for a condition. The check is evaluated in the task's
own session — that is what makes its transcript a decision log. A task with no
`sessionId` (tool-created, or created with no bridge to bind one) fires through
the shared per-project durable owner, so its check would be injected into
whichever session holds that lock. Both create and update now reject a
condition on such a task instead of quietly relocating the check.

Also log the decision point: a non-ok outcome reaches stderr, since the
scheduler has already booked the run and `debugLogger` writes nothing unless a
debug log session is active.

* fix(scheduled-tasks): close four more precondition gaps from review

Read the verdict off the final non-empty line, not from anywhere in the text.
The prompt asks the model to *end* its reply with the verdict, so
`DECISION: YES\n\nBut I could not verify it` is not a decision — scanning the
whole reply took the conclusion off the wrong line and released the fire.

Mark a cut-short tool loop at its choke point. `loopDetected` was flagged, but
its sibling `repeatedDuplicateProviderToolCall` takes the quiet exit: it makes
`#buildNextMessageAfterToolRun` return null, ending the turn with no error and
no abort. A model that streamed `DECISION: YES` in that same round then
released the fire on an investigation it never finished. Both cases (and any
future one) are now marked where the follow-up message comes back null, rather
than by enumerating flags — enumerating them is how the sibling was missed.

Fail closed in the two consumers that cannot evaluate a precondition. The
headless and TUI `onFire` callbacks read only `prompt`/`cronExpr`/`missed`, so
a guarded task fired there with its guard ignored — the exact outcome the
precondition exists to prevent. Both now skip such a fire; only the ACP/daemon
session, which owns the sub-session dispatch the verdict gates, runs it.

Distinguish a withheld fire in the run history. The scheduler books the run the
moment it fires, before any verdict exists, so a task that deliberately did
nothing reported "ran at 02:00". `CronTaskRun.withheld` is stamped afterwards
by the evaluating session, addressed by the fire's own minute (the scheduler
writes `runs[].at` from the very `lastFiredAt` it hands to `onFire`), and the
Web Shell tags the entry. Best-effort and never awaited: losing a cosmetic
marker must not affect a fire that has already been decided.

* feat(scheduled-tasks): make the precondition readable, and translate it

The bound session of a guarded task is the feature's decision log, but it read
like a debug dump: every fire echoed the whole instruction wrapper the model
receives — five paragraphs of "end your reply with a final line that is exactly
one of…" — and nothing in it was translatable.

Echo a compact label instead. `CronQueueItem` gains an optional `echoText`: the
text the client shows when the text sent to the model is not fit to read. A
precondition turn now shows " Precondition check" and the user's own condition,
whitespace-collapsed and capped at 280 characters (surrogate-safe). The model
still receives the full wrapper.

Say what the check decided. The model's answer explains its reasoning but cannot
state the consequence, so the scheduler adds one line: the run was skipped
(precondition not met, or the check was cancelled / interrupted / failed), or it
is running — with a `qwen-session://` link to the sub-session that is doing the
work. Without that link the bound session of an isolated task shows nothing at
all for a fire that DID run: the work happens in a sibling the user cannot
reach from here.

The status line opens with a blank line. It is an `agent_message_chunk`, which
the client appends to the assistant message already on screen, and that message
ends on the verdict with no trailing newline — without the break the transcript
renders `DECISION: NO Precondition not met…`. A screenshot caught that; the
assertions did not, so there is now a test for it.

All seven strings go through `t()` and are translated in en/zh/zh-TW (the three
locales `check-i18n` holds to strict key parity). Session.ts had no i18n import
before this; `t()` is initialized on the ACP path by `gemini.tsx`.

Not addressed: the ACP cron path persists no user record at all, so the echo and
the status lines are live-only and a reload shows the model's answers with no
question above them. That is pre-existing — `client.ts` records a cron prompt via
`recordCronPrompt(message, displayText)` only on the core send path, which the
ACP session does not use.

* fix(web-shell): make qwen-session:// links actually clickable

`MarkdownLink` has an interception branch for `qwen-session://<id>` that renders
a button and dispatches `qwen:open-session` so the app shell can navigate. It
has never run.

react-markdown sanitizes every href through `defaultUrlTransform`, which allows
only `http(s)`, `irc(s)`, `mailto` and `xmpp` and rewrites everything else to
`''`. So the scheme was stripped before `components.a` was called: the branch
saw an empty href, fell through, and rendered a plain anchor with no href.
Clicking it did nothing.

Add a `urlTransform` that passes `qwen-session://` through and defers every
other url to the default sanitizer. Letting the scheme through is safe — the
interception branch never puts it in the DOM, it renders `href="#"` and
dispatches the id as an event — and the per-component `isSafeHref` /
`isSafeImageSrc` guards are unchanged.

Dead since #6535 (ac2f371c4) introduced it: the `create_sub_session` tool's link
works only because `ToolGroup` parses `[text](qwen-session://id)` out of plain
tool output with its own regex, never touching react-markdown. A precondition's
"running this task in a new session" line is the first such link to reach an
assistant message, which is how this surfaced.

* fix(scheduled-tasks): stop pretending a manual run evaluates the precondition

"Run now" wrapped the task's condition into the relayed prompt and let the model
decide whether to dispatch. That cannot be made correct from the client, and it
produced three defects:

  - `onRunPrompt` resolves at ADMISSION, so `runScheduledTask` appended an
    ordinary `manual` run record even when the model went on to decide the
    condition was false. The history reported a successful run for work that was
    never done, and — unlike a scheduled fire — nothing could stamp `withheld`.

  - The one-shot branch consumes the task (`/run` deletes it) BEFORE the prompt
    is even enqueued. A false precondition therefore destroyed the task without
    ever running it.

  - The manual wrapper offered only "holds" / "does not hold". The scheduled path
    treats an inability to determine the condition as NO and machine-parses a
    final verdict; here the model owned the dispatch decision, so a tool failure
    or an ambiguous answer had no branch at all and could still reach
    `create_sub_session`.

The verdict is only observable inside the session that computes it. So a manual
run no longer evaluates the guard: "Run now" means run now. The `manual` run
record and the one-shot consumption are truthful again, and there is no second
decision protocol to drift from the scheduler's.

The button says so — a guarded task's tooltip reads "Run now (runs immediately,
ignoring the precondition)", translated in en/zh.

A guarded manual run that reproduces a scheduled fire (verdict, `withheld`
stamping, one-shot consumed only on YES) would have to be dispatched daemon-side,
where the outcome exists. That is a separate change.
2026-07-10 05:56: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): gate an isolated run behind a precondition (#6619) 2026-07-10 05:56: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.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